Răsfoiți Sursa

feat(supply-chain-defense): Adopt term.sh design system

Retrofit the security auditors' human framing; stream separation is preserved
(TSV/--json data on stdout, framing on stderr — verified zero ANSI in the data).

Bash auditors (integrity-audit, preinstall-check, scan-extensions) render the
full term.sh panel on stderr (term_init 2) — brand header, │ rail, colored
section sub-headers, term_status_row check rows, footer health indicator — for a
human at a TTY (or FORCE_COLOR); piped/quiet keeps the legacy "== section =="
framing, so any stderr consumer is unaffected. Hand-rolled C_*/ANSI replaced with
term.sh (now NO_COLOR/TERM_ASCII aware); em-dashes in framing strings swapped for
ASCII so stderr is pure under TERM_ASCII=1.

Python scanners (exposure-check, config-drift-check, postinstall-audit) gain a
lazy inline Term helper (recomputes per call so it reflects the UTF-8 reconfigure;
ASCII fallback on TERM_ASCII or a non-UTF stream) and colorize their header /
verdict / [warn] lines. The findings these print to stdout stay plain data.

tests/run.sh gains a terminal-design block: term.sh/inline-Term adoption + ASCII
purity of every auditor's framing under TERM_ASCII=1 + the stdout-stays-plain
contract. 118/118 offline (the unrelated pre-existing WORKTREE-GUARD assertion is
untouched by this change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 3 zile în urmă
părinte
comite
eb2fc4c87e

+ 40 - 2
skills/supply-chain-defense/scripts/config-drift-check.py

@@ -51,6 +51,44 @@ SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "worktrees", "__pycache__",
              "dist", "build", ".next", ".svelte-kit", ".astro", "vendor"}
 SCHEMA = "claude-mods.supply-chain-defense.config-drift-check/v1"
 
+
+class Term:
+    """Inline ANSI helper mirroring skills/_lib/term.sh (bash-only; per
+    TERMINAL-DESIGN.md §9 the Python port is inline). Honors FORCE_COLOR /
+    NO_COLOR / TERM_ASCII; ASCII-glyph fallback on a non-UTF stream encoding."""
+
+    _C = {"green": "\033[32m", "orange": "\033[38;5;208m", "red": "\033[31m",
+          "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m"}
+    _G = {"ok": "✓", "bad": "✗", "warn": "▲", "unknown": "?"}
+    _A = {"ok": "+", "bad": "x", "warn": "!", "unknown": "?"}
+    _MC = {"ok": "green", "bad": "red", "warn": "orange", "unknown": "cyan"}
+
+    def __init__(self, stream): self.s = stream
+
+    @property
+    def ascii(self):
+        enc = (getattr(self.s, "encoding", "") or "").lower()
+        return (os.environ.get("TERM_ASCII") == "1" or os.environ.get("FLEET_ASCII") == "1"
+                or "utf" not in enc)
+
+    @property
+    def color(self):
+        if os.environ.get("FORCE_COLOR"):
+            return True
+        if (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb"
+                or not getattr(self.s, "isatty", lambda: False)()):
+            return False
+        return True
+
+    def c(self, n, t):
+        return f"{self._C.get(n, '')}{t}{self._C['off']}" if self.color else t
+
+    def mark(self, st):
+        return self.c(self._MC.get(st, ""), (self._A if self.ascii else self._G).get(st, "."))
+
+
+TERM = Term(sys.stderr)
+
 # Build-config filename stems (any of these extensions). A loader appended here
 # runs at build time — the Stage-2 EtherHiding execution vector.
 CONFIG_STEMS = {
@@ -267,7 +305,7 @@ def collect_from_roots(roots):
     for root in roots:
         base = Path(root).expanduser()
         if not base.exists():
-            log(f"[warn] root does not exist: {base}")
+            log(TERM.c("orange", f"[warn] root does not exist: {base}"))
             continue
         if base.is_file():
             if is_config_file(base):
@@ -357,7 +395,7 @@ def main():
         for sev, kind, detail in scan_file(p):
             findings.append({"file": str(p), "severity": sev, "kind": kind, "detail": detail})
 
-    log(f"=== config-drift-check: {scanned} config file(s) scanned — {len(findings)} finding(s) ===")
+    log(TERM.c("cyan", f"=== config-drift-check: {scanned} config file(s) scanned - {len(findings)} finding(s) ==="))
 
     if args.json:
         print(json.dumps({

+ 46 - 7
skills/supply-chain-defense/scripts/exposure-check.py

@@ -34,6 +34,45 @@ DEFAULT_CATALOG = Path(__file__).resolve().parent.parent / "assets" / "exposure-
 def log(msg): print(msg, file=sys.stderr)
 
 
+class Term:
+    """Inline ANSI helper mirroring skills/_lib/term.sh (bash-only; per
+    TERMINAL-DESIGN.md §9 the Python port is inline). Lazy properties so it
+    reflects a later stream reconfigure(); honors FORCE_COLOR / NO_COLOR /
+    TERM_ASCII and falls back to ASCII glyphs on a non-UTF stream encoding."""
+
+    _C = {"green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
+          "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m"}
+    _G = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "unknown": "?"}
+    _A = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "unknown": "?"}
+    _MC = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim", "unknown": "yellow"}
+
+    def __init__(self, stream): self.s = stream
+
+    @property
+    def ascii(self):
+        enc = (getattr(self.s, "encoding", "") or "").lower()
+        return (os.environ.get("TERM_ASCII") == "1" or os.environ.get("FLEET_ASCII") == "1"
+                or "utf" not in enc)
+
+    @property
+    def color(self):
+        if os.environ.get("FORCE_COLOR"):
+            return True
+        if (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb"
+                or not getattr(self.s, "isatty", lambda: False)()):
+            return False
+        return True
+
+    def c(self, n, t):
+        return f"{self._C.get(n, '')}{t}{self._C['off']}" if self.color else t
+
+    def mark(self, st):
+        return self.c(self._MC.get(st, ""), (self._A if self.ascii else self._G).get(st, "."))
+
+
+TERM = Term(sys.stderr)
+
+
 def die(msg, code) -> NoReturn:
     log(f"ERROR: {msg}")
     sys.exit(code)
@@ -82,7 +121,7 @@ def walk(roots):
     for root in roots:
         base = Path(root).expanduser()
         if not base.exists():
-            log(f"[warn] root does not exist: {base}")
+            log(TERM.c("orange", f"[warn] root does not exist: {base}"))
             continue
         for dirpath, dirnames, filenames in os.walk(base):
             dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
@@ -326,8 +365,8 @@ def main():
 
     roots = args.root or ["."]
     index, schema_ver, n_entries = load_catalog(Path(args.catalog).expanduser())
-    log(f"=== exposure-check: {n_entries} IOC entries (schema {schema_ver}), "
-        f"roots: {', '.join(roots)} ===")
+    log(TERM.c("cyan", f"=== exposure-check: {n_entries} IOC entries (schema {schema_ver}), "
+                       f"roots: {', '.join(roots)} ==="))
 
     components = collect(roots)
     if not args.no_extensions:
@@ -356,14 +395,14 @@ def main():
             for c in components:
                 print(f"{c['ecosystem']}\t{c['name']}\t{c['version']}\t{c['source']}")
         for f in findings:
-            log(f"  [EXPOSED] {f['ecosystem']} {f['name']}@{f['version']} "
+            log(f"  {TERM.mark('bad')} [EXPOSED] {f['ecosystem']} {f['name']}@{f['version']} "
                 f"({f['severity']}, {f['ioc_id']}) - {f['source']}")
 
     if findings:
-        log(f"EXPOSED: {len(findings)} installed package(s) match the IOC catalog. "
-            f"Treat as incident: isolate, rotate creds, remove the package.")
+        log(TERM.c("red", f"EXPOSED: {len(findings)} installed package(s) match the IOC catalog. "
+                          f"Treat as incident: isolate, rotate creds, remove the package."))
         sys.exit(EXIT_EXPOSED)
-    log(f"Clean: 0 of {len(components)} scanned components match the catalog.")
+    log(f"{TERM.mark('ok')} {TERM.c('green', f'Clean: 0 of {len(components)} scanned components match the catalog.')}")
     sys.exit(EXIT_OK)
 
 

+ 68 - 10
skills/supply-chain-defense/scripts/integrity-audit.sh

@@ -38,13 +38,48 @@ while [[ $# -gt 0 ]]; do
   shift
 done
 
-# stderr framing — colored only when stderr is a TTY and NO_COLOR unset.
-if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
+# Terminal design system (skills/_lib/term.sh): framing on stderr (term_init 2),
+# the TSV/--json data product stays plain on stdout. The full enclosing panel
+# renders for a human at a TTY (or FORCE_COLOR); piped/quiet keeps the legacy
+# "== section ==" framing so any stderr consumer is unaffected.
+__lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2; __HAVE_TERM=1
+else __HAVE_TERM=0; fi
+# Color vars for the legacy (non-panel) path; sourced from term.sh when present so
+# they honor NO_COLOR / TERM_ASCII, else hand-rolled with the original TTY gate.
+if [[ "$__HAVE_TERM" -eq 1 ]]; then
+  C_Y="$TERM_C_YELLOW"; C_G="$TERM_C_GREEN"; C_D="$TERM_C_DIM"; C_O="$TERM_C_OFF"
+elif [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
   C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_O=$'\033[0m'
 else C_Y=""; C_G=""; C_D=""; C_O=""; fi
-section() { [[ "$QUIET" -eq 1 ]] && return; printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
-info()    { [[ "$QUIET" -eq 1 ]] && return; printf '   %s\n' "$1" >&2; }
-vinfo()   { [[ "$VERBOSE" -eq 1 ]] && printf '   %s\n' "$1" >&2; }
+
+PANEL=0
+if [[ "$__HAVE_TERM" -eq 1 && "$QUIET" -eq 0 ]] && { [ -t 2 ] || [ -n "${FORCE_COLOR:-}" ]; }; then PANEL=1; fi
+__PANEL_OPEN=0
+popen() {
+  [[ "$PANEL" -eq 1 && "$__PANEL_OPEN" -eq 0 ]] || return 0
+  { term_panel_open supply-chain "integrity-audit"; term_panel_vert; } >&2; __PANEL_OPEN=1
+}
+section() {
+  [[ "$QUIET" -eq 1 ]] && return
+  if [[ "$PANEL" -eq 1 ]]; then
+    popen
+    { term_panel_vert
+      if [[ -n "${2:-}" ]]; then term_panel_line "$(term_color cyan "$1")  $(term_color dim "$2")"
+      else term_panel_line "$(term_color cyan "$1")"; fi
+    } >&2
+  else printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; fi
+}
+info() {
+  [[ "$QUIET" -eq 1 ]] && return
+  if [[ "$PANEL" -eq 1 ]]; then popen; term_panel_line "$(term_color dim "$1")" >&2
+  else printf '   %s\n' "$1" >&2; fi
+}
+vinfo() {
+  [[ "$VERBOSE" -eq 1 ]] || return 0
+  if [[ "$PANEL" -eq 1 ]]; then popen; term_panel_line "$(term_color dim "$1")" >&2
+  else printf '   %s\n' "$1" >&2; fi
+}
 
 HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
 HAS_ZIZMOR=0; command -v zizmor >/dev/null 2>&1 && HAS_ZIZMOR=1
@@ -72,8 +107,12 @@ record() {
       --arg e "$entries" '{category:$c, source:$s, kind:$k, entries:($e|split("\n")|map(select(length>0)))}')
     REVIEW_JSON+=("$obj")
   fi
-  printf '   %s[review]%s %s %s: %s\n' "$C_Y" "$C_O" "$kind" "$source" \
-    "$(echo "$entries" | paste -sd',' - 2>/dev/null)" >&2
+  local flat_e; flat_e="$(echo "$entries" | paste -sd',' - 2>/dev/null)"
+  if [[ "$PANEL" -eq 1 ]]; then
+    popen; term_status_row warn "$kind  $source" "$flat_e" >&2
+  else
+    printf '   %s[review]%s %s %s: %s\n' "$C_Y" "$C_O" "$kind" "$source" "$flat_e" >&2
+  fi
 }
 
 json_key_entries() {  # file key -> newline-separated entry list (jq)
@@ -189,14 +228,33 @@ if [[ "$JSON" -eq 1 ]]; then
 fi
 
 if [[ "$REVIEW_COUNT" -eq 0 ]]; then
-  [[ "$QUIET" -eq 0 ]] && printf '%sClean: nothing flagged for review.%s\n' "$C_G" "$C_O" >&2
+  if [[ "$QUIET" -eq 0 ]]; then
+    if [[ "$PANEL" -eq 1 ]]; then
+      popen
+      { term_panel_vert
+        term_panel_close "review each ${TERM_DOT} rotate creds if unexplained" "$(term_health healthy "clean")"
+      } >&2
+    else
+      printf '%sClean: nothing flagged for review.%s\n' "$C_G" "$C_O" >&2
+    fi
+  fi
   exit "$EXIT_OK"
 fi
 if [[ "$QUIET" -eq 0 ]]; then
-  printf '%s%d item(s) flagged for review — confirm YOU added each.%s\n' "$C_Y" "$REVIEW_COUNT" "$C_O" >&2
-  cat >&2 <<'EOF'
+  if [[ "$PANEL" -eq 1 ]]; then
+    popen
+    { term_panel_vert
+      term_panel_line "$(term_color dim "not proof of compromise - if any entry is unexplained, treat as an incident:")"
+      term_panel_line "$(term_color dim "  1. isolate the machine   2. rotate every reachable credential   3. investigate")"
+      term_panel_vert
+      term_panel_close "confirm YOU added each" "$(term_health warning "$REVIEW_COUNT to review")"
+    } >&2
+  else
+    printf '%s%d item(s) flagged for review - confirm YOU added each.%s\n' "$C_Y" "$REVIEW_COUNT" "$C_O" >&2
+    cat >&2 <<'EOF'
    Not proof of compromise. If any entry is unexplained, treat as an incident:
      1. Isolate the machine.  2. Rotate every reachable credential.  3. Investigate.
 EOF
+  fi
 fi
 exit "$EXIT_REVIEW"

+ 42 - 4
skills/supply-chain-defense/scripts/postinstall-audit.py

@@ -53,6 +53,44 @@ SKIP_DIRS = {".git", ".hg", ".svn", "worktrees", "__pycache__"}
 SEVERITIES = ("low", "medium", "high")
 SCHEMA = "claude-mods.supply-chain-defense.postinstall-audit/v1"
 
+
+class Term:
+    """Inline ANSI helper mirroring skills/_lib/term.sh (bash-only; per
+    TERMINAL-DESIGN.md §9 the Python port is inline). Honors FORCE_COLOR /
+    NO_COLOR / TERM_ASCII; ASCII-glyph fallback on a non-UTF stream encoding."""
+
+    _C = {"green": "\033[32m", "orange": "\033[38;5;208m", "red": "\033[31m",
+          "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m"}
+    _G = {"ok": "✓", "bad": "✗", "warn": "▲", "unknown": "?"}
+    _A = {"ok": "+", "bad": "x", "warn": "!", "unknown": "?"}
+    _MC = {"ok": "green", "bad": "red", "warn": "orange", "unknown": "cyan"}
+
+    def __init__(self, stream): self.s = stream
+
+    @property
+    def ascii(self):
+        enc = (getattr(self.s, "encoding", "") or "").lower()
+        return (os.environ.get("TERM_ASCII") == "1" or os.environ.get("FLEET_ASCII") == "1"
+                or "utf" not in enc)
+
+    @property
+    def color(self):
+        if os.environ.get("FORCE_COLOR"):
+            return True
+        if (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb"
+                or not getattr(self.s, "isatty", lambda: False)()):
+            return False
+        return True
+
+    def c(self, n, t):
+        return f"{self._C.get(n, '')}{t}{self._C['off']}" if self.color else t
+
+    def mark(self, st):
+        return self.c(self._MC.get(st, ""), (self._A if self.ascii else self._G).get(st, "."))
+
+
+TERM = Term(sys.stderr)
+
 # Lifecycle script verbs that download or spawn a shell — the Shai-Hulud entry.
 LIFECYCLE_KEYS = ("preinstall", "install", "postinstall", "prepare")
 LIFECYCLE_RED = re.compile(
@@ -123,7 +161,7 @@ def save_cache(path: Path, cache: dict):
         tmp.write_text(json.dumps(cache), encoding="utf-8")
         tmp.replace(path)
     except OSError as e:
-        log(f"[warn] could not save cache: {e}")
+        log(TERM.c("orange", f"[warn] could not save cache: {e}"))
 
 
 def iter_package_dirs(roots):
@@ -131,7 +169,7 @@ def iter_package_dirs(roots):
     for root in roots:
         base = Path(root).expanduser()
         if not base.exists():
-            log(f"[warn] root does not exist: {base}")
+            log(TERM.c("orange", f"[warn] root does not exist: {base}"))
             continue
         for dirpath, dirnames, _ in os.walk(base):
             dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
@@ -423,8 +461,8 @@ def main():
         save_cache(cache_path, cache)
 
     elapsed = round(time.time() - t0, 1)
-    log(f"=== postinstall-audit: {len(packages)} packages ({scanned} scanned, "
-        f"{cached} cache hits) in {elapsed}s — {len(findings)} flagged ===")
+    log(TERM.c("cyan", f"=== postinstall-audit: {len(packages)} packages ({scanned} scanned, "
+                       f"{cached} cache hits) in {elapsed}s - {len(findings)} flagged ==="))
 
     if args.json:
         print(json.dumps({"data": {"findings": findings,

+ 34 - 8
skills/supply-chain-defense/scripts/preinstall-check.sh

@@ -53,6 +53,25 @@ HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
 HAS_SOCKET=0; command -v socket >/dev/null 2>&1 && HAS_SOCKET=1
 
 emit() { [[ "$QUIET" -eq 1 ]] && return; printf '%s\n' "$1" >&2; }
+
+# Terminal design system: framing on stderr (term_init 2); TSV/--json stays plain
+# on stdout. Full panel for a human at a TTY (or FORCE_COLOR); else legacy emit.
+__lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2; __HAVE_TERM=1
+else __HAVE_TERM=0; TERM_DOT="|"; fi
+PANEL=0
+if [[ "$__HAVE_TERM" -eq 1 && "$QUIET" -eq 0 ]] && { [ -t 2 ] || [ -n "${FORCE_COLOR:-}" ]; }; then PANEL=1; fi
+__PANEL_OPEN=0
+popen() {
+  [[ "$PANEL" -eq 1 && "$__PANEL_OPEN" -eq 0 ]] || return 0
+  { term_panel_open supply-chain "preinstall ${TERM_DOT} ${ECOSYSTEM}"; term_panel_vert; } >&2; __PANEL_OPEN=1
+}
+prow() {  # mark legacy-prefix text
+  if [[ "$PANEL" -eq 1 ]]; then popen; term_status_row "$1" "$3" >&2
+  else emit "  $2 $3"; fi
+}
+pinfo() { [[ "$PANEL" -eq 1 ]] && { popen; term_panel_line "$(term_color dim "$1")" >&2; } || emit "$1"; }
+
 now_epoch=$(date +%s); inside=0; unavailable=0
 JSON_OBJS=()
 
@@ -83,11 +102,11 @@ result() {  # name version published
     '{ecosystem:$e, name:$n, version:($v|select(length>0)), published:($p|select(length>0)), age_days:(if $d<0 then null else $d end), inside_cooldown:$ic}')")
   # human framing → stderr
   if [[ "$ic" == "true" ]]; then
-    emit "  [INSIDE COOLDOWN] ${name}@${version} — ${days}d ago (< ${COOLDOWN_DAYS}d). Hold off."
+    prow bad "[INSIDE COOLDOWN]" "${name}@${version} - ${days}d ago (< ${COOLDOWN_DAYS}d). Hold off."
   elif [[ "$days" -ge 0 ]]; then
-    emit "  [ok] ${name}@${version} — ${days}d ago (>= ${COOLDOWN_DAYS}d)."
+    prow ok "[ok]" "${name}@${version} - ${days}d ago (>= ${COOLDOWN_DAYS}d)."
   else
-    emit "  [?] ${name} — version/publish time not found or registry unreachable."
+    prow unknown "[?]" "${name} - version/publish time not found or registry unreachable."
   fi
 }
 
@@ -145,7 +164,7 @@ check_go() {  # proxy.golang.org/<module>/@v/<version>.info  (or /@latest)
   result "$mod" "$(jq -r '.Version // empty' <<<"$json")" "$(jq -r '.Time // empty' <<<"$json")"
 }
 
-emit "=== Pre-install check (${ECOSYSTEM}, cooldown ${COOLDOWN_DAYS}d) ==="
+if [[ "$PANEL" -eq 1 ]]; then popen; else emit "=== Pre-install check (${ECOSYSTEM}, cooldown ${COOLDOWN_DAYS}d) ==="; fi
 for spec in "${PKGS[@]}"; do
   case "$ECOSYSTEM" in
     npm) check_npm "$spec" ;; pypi) check_pypi "$spec" ;;
@@ -160,12 +179,19 @@ if [[ "$JSON" -eq 1 ]]; then
 fi
 
 if [[ "$QUIET" -eq 0 ]]; then
+  [[ "$PANEL" -eq 1 ]] && term_panel_vert >&2 || emit ""
   if [[ "$HAS_SOCKET" -eq 1 ]]; then
-    emit ""; emit "Behavioural verdict:"
-    for spec in "${PKGS[@]}"; do n="${spec%@*}"; n="${n%==*}"; emit "  socket package score ${ECOSYSTEM} ${n}"; done
+    pinfo "behavioural verdict:"
+    for spec in "${PKGS[@]}"; do n="${spec%@*}"; n="${n%==*}"; pinfo "  socket package score ${ECOSYSTEM} ${n}"; done
   else
-    emit ""; emit "Behavioural scan (free):  npm install -g socket   # then: socket package score ${ECOSYSTEM} <pkg>"
-    emit "Or depscore MCP (no key):  claude mcp add --transport http socket-mcp https://mcp.socket.dev/"
+    pinfo "behavioural scan (free):  npm install -g socket   # then: socket package score ${ECOSYSTEM} <pkg>"
+    pinfo "or depscore MCP (no key):  claude mcp add --transport http socket-mcp https://mcp.socket.dev/"
+  fi
+  if [[ "$PANEL" -eq 1 && "$__PANEL_OPEN" -eq 1 ]]; then
+    ph_state="healthy"; ph_text="outside cooldown"
+    [[ "$unavailable" -eq 1 ]] && { ph_state="warning"; ph_text="registry unavailable"; }
+    [[ "$inside" -eq 1 ]] && { ph_state="warning"; ph_text="inside cooldown"; }
+    { term_panel_vert; term_panel_close "hold new releases ${TERM_DOT} --json for data" "$(term_health "$ph_state" "$ph_text")"; } >&2
   fi
 fi
 

+ 45 - 14
skills/supply-chain-defense/scripts/scan-extensions.sh

@@ -50,10 +50,36 @@ while [[ $# -gt 0 ]]; do
 done
 
 HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
-if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_R=$'\033[31m'; C_O=$'\033[0m'
+
+# Terminal design system: framing on stderr (term_init 2); the inventory/--json
+# data product stays plain on stdout. Full panel for a human at a TTY (or
+# FORCE_COLOR); piped/quiet keeps the legacy "== section ==" framing.
+__lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
+if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2; __HAVE_TERM=1
+else __HAVE_TERM=0; TERM_DOT="|"; fi
+if [[ "$__HAVE_TERM" -eq 1 ]]; then
+  C_Y="$TERM_C_YELLOW"; C_G="$TERM_C_GREEN"; C_D="$TERM_C_DIM"; C_R="$TERM_C_RED"; C_O="$TERM_C_OFF"
+elif [[ -t 2 && -z "${NO_COLOR:-}" ]]; then C_Y=$'\033[33m'; C_G=$'\033[32m'; C_D=$'\033[2m'; C_R=$'\033[31m'; C_O=$'\033[0m'
 else C_Y=""; C_G=""; C_D=""; C_R=""; C_O=""; fi
-section(){ [[ "$QUIET" -eq 1 ]] || printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; }
-info(){ [[ "$QUIET" -eq 1 ]] || printf '   %s\n' "$1" >&2; }
+PANEL=0
+if [[ "$__HAVE_TERM" -eq 1 && "$QUIET" -eq 0 ]] && { [ -t 2 ] || [ -n "${FORCE_COLOR:-}" ]; }; then PANEL=1; fi
+__PANEL_OPEN=0
+popen(){ [[ "$PANEL" -eq 1 && "$__PANEL_OPEN" -eq 0 ]] || return 0; { term_panel_open supply-chain "scan-extensions"; term_panel_vert; } >&2; __PANEL_OPEN=1; }
+section(){
+  [[ "$QUIET" -eq 1 ]] && return
+  if [[ "$PANEL" -eq 1 ]]; then
+    popen
+    { term_panel_vert
+      if [[ -n "${2:-}" ]]; then term_panel_line "$(term_color cyan "$1")  $(term_color dim "$2")"
+      else term_panel_line "$(term_color cyan "$1")"; fi
+    } >&2
+  else printf '%s== %s ==%s %s\n' "$C_D" "$1" "$C_O" "${2:-}" >&2; fi
+}
+info(){
+  [[ "$QUIET" -eq 1 ]] && return
+  if [[ "$PANEL" -eq 1 ]]; then popen; term_panel_line "$(term_color dim "$1")" >&2
+  else printf '   %s\n' "$1" >&2; fi
+}
 
 # ── --deep: auto-detect the engine; recommend (don't require) if absent ────
 # Lean by default — guarddog+semgrep are NOT kept on the machine. If --deep is asked
@@ -99,7 +125,8 @@ for base in "${EXT_DIRS[@]}"; do
       gout=$(PYTHONUTF8=1 guarddog npm scan "$ext" --exit-non-zero-on-finding 2>/dev/null); grc=$?
       if [[ $grc -ne 0 ]] && echo "$gout" | grep -qiE 'potentially malicious|source code matches'; then
         FINDINGS=$((FINDINGS+1))
-        printf '   %s[FINDING]%s %s\n' "$C_R" "$C_O" "$id" >&2
+        if [[ "$PANEL" -eq 1 ]]; then popen; term_status_row bad "$id" "behavioural finding" >&2
+        else printf '   %s[FINDING]%s %s\n' "$C_R" "$C_O" "$id" >&2; fi
         echo "$gout" | grep -iE 'found|matches|: This' | head -5 | sed 's/^/        /' >&2
         [[ "$HAS_JQ" -eq 1 ]] && FIND_JSON+=("$(jq -cn --arg i "$id" --arg d "$(echo "$gout" | tr '\n' ' ' | head -c 400)" '{id:$i,engine:"guarddog",detail:$d}')")
       fi
@@ -108,7 +135,7 @@ for base in "${EXT_DIRS[@]}"; do
 done
 
 # ── 2. Claude Code plugins: inventory + pinned-commit ──────────────────────
-section "Claude Code plugins" "pinned-commit inventory  verify each against its marketplace"
+section "Claude Code plugins" "pinned-commit inventory - verify each against its marketplace"
 PMETA="$HOME/.claude/plugins/installed_plugins.json"
 if [[ -f "$PMETA" && "$HAS_JQ" -eq 1 ]]; then
   while IFS= read -r line; do info "$line"; done < <(jq -r '.plugins | to_entries[] | .key as $n | .value[] | "\($n)  sha=\(.gitCommitSha[0:12])  scope=\(.scope)  updated=\(.lastUpdated)"' "$PMETA" 2>/dev/null)
@@ -135,20 +162,24 @@ if [[ "$JSON" -eq 1 ]]; then
     '{data:{inventory: map(select(length>0)), findings:$f}, meta:{deep:($deep==1), recency_days:$days, finding_count:($f|length), schema:"axiom.tool.scan-extensions.report/v1"}}'
 fi
 
+# vclose <state> <hotkeys> <text>  — panel footer, or a colored legacy verdict line.
+vclose() {  # state hotkeys legacy-color legacy-text
+  if [[ "$PANEL" -eq 1 ]]; then
+    popen; { term_panel_vert; term_panel_close "$2" "$(term_health "$1" "$4")"; } >&2
+  else printf '%s%s%s\n' "$3" "$4" "$C_O" >&2; fi
+}
 if [[ "$DEEP_OK" -eq 1 ]]; then
   if [[ "$FINDINGS" -eq 0 ]]; then
-    [[ "$QUIET" -eq 1 ]] || printf '%sBehavioural: GuardDog found no indicators in scanned extensions.%s\n' "$C_G" "$C_O" >&2
+    [[ "$QUIET" -eq 1 ]] || vclose healthy "exposure-check for known IOCs" "$C_G" "behavioural: GuardDog found no indicators"
     exit "$EXIT_OK"
   fi
-  [[ "$QUIET" -eq 1 ]] || printf '%s%d extension(s) with behavioural findings — inspect + treat as incident.%s\n' "$C_R" "$FINDINGS" "$C_O" >&2
+  [[ "$QUIET" -eq 1 ]] || vclose critical "inspect ${TERM_DOT} treat as incident" "$C_R" "$FINDINGS extension(s) with behavioural findings"
   exit "$EXIT_FINDING"
 fi
-if [[ "$DEEP_SKIPPED" -eq 1 ]]; then
-  [[ "$QUIET" -eq 1 ]] || {
-    printf '%sBEHAVIOURAL SCAN SKIPPED%s — guarddog/semgrep not installed (kept off by default).\n' "$C_Y" "$C_O" >&2
-    printf '   Ran inventory + recency only — this is NOT a clean behavioural verdict.\n' >&2
-    printf '   Enable on-demand:  uv tool install guarddog semgrep   (then re-run --deep)\n' >&2
-  }
+if [[ "$DEEP_SKIPPED" -eq 1 && "$QUIET" -eq 0 ]]; then
+  info "BEHAVIOURAL SCAN SKIPPED - guarddog/semgrep not installed (kept off by default)."
+  info "  ran inventory + recency only - this is NOT a clean behavioural verdict."
+  info "  enable on-demand:  uv tool install guarddog semgrep   (then re-run --deep)"
 fi
-[[ "$QUIET" -eq 1 ]] || printf '%sInventory done. %d item(s) changed within %dd — review those; run exposure-check.py for known-IOC matching.%s\n' "$C_D" "$RECENT" "$DAYS" "$C_O" >&2
+[[ "$QUIET" -eq 1 ]] || vclose healthy "exposure-check.py for known-IOC matching" "$C_D" "inventory done - $RECENT item(s) changed within ${DAYS}d, review those"
 exit "$EXIT_OK"

+ 27 - 0
skills/supply-chain-defense/tests/run.sh

@@ -408,6 +408,33 @@ expect_has  "flags tasks autorun shell" "tasks-autorun-shell" "$out"
 out="$("$PYTHON" "$CD" --root "$SB/cd-evil" --json --findings-only 2>/dev/null)"
 expect_has  "json envelope schema" "config-drift-check/v1" "$out"
 
+# ── terminal design system (term.sh adoption + ASCII purity) ───────────────
+echo "-- terminal design system --"
+for s in integrity-audit preinstall-check scan-extensions; do
+  grep -q '_lib/term.sh' "$SCRIPTS/$s.sh" && ok "$s.sh sources _lib/term.sh" || no "$s.sh does not source _lib/term.sh"
+done
+for s in exposure-check config-drift-check postinstall-audit; do
+  grep -q 'class Term' "$SCRIPTS/$s.py" && ok "$s.py carries inline Term" || no "$s.py missing inline Term"
+done
+# Framing must be pure ASCII under TERM_ASCII=1 (design principle #3). Captures
+# stderr only; all of these run fully offline against an empty fixture tree.
+ascii_pure() { # desc run...
+  local d="$1"; shift
+  local e; e="$(TERM_ASCII=1 FORCE_COLOR=1 "$@" 2>&1 1>/dev/null)"
+  if printf '%s' "$e" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
+    no "$d framing emits non-ASCII under TERM_ASCII=1"
+  else ok "$d framing pure ASCII under TERM_ASCII=1"; fi
+}
+TDE="$SB/td-empty"; mkdir -p "$TDE"
+ascii_pure "integrity-audit" env HOME="$TDE" APPDATA="$TDE" bash "$SCRIPTS/integrity-audit.sh" "$TDE"
+ascii_pure "scan-extensions" env HOME="$TDE" SC_EXT_DIRS="$TDE/none" bash "$SCAN"
+ascii_pure "exposure-check"  env HOME="$TDE" "$PYTHON" "$SCRIPTS/exposure-check.py" --root "$TDE" --no-extensions
+ascii_pure "config-drift"    "$PYTHON" "$SCRIPTS/config-drift-check.py" --root "$TDE"
+ascii_pure "postinstall"     "$PYTHON" "$SCRIPTS/postinstall-audit.py" --root "$TDE"
+# stdout data stays plain even under FORCE_COLOR (the pipeable contract).
+tdo="$(FORCE_COLOR=1 env HOME="$TDE" "$PYTHON" "$SCRIPTS/exposure-check.py" --root "$TDE" --no-extensions 2>/dev/null)"
+case "$tdo" in *$'\033'*) no "exposure-check stdout leaked ANSI";; *) ok "exposure-check stdout stays plain data";; esac
+
 # ── summary ────────────────────────────────────────────────────────────────
 echo "=== $PASS passed, $FAIL failed ==="
 [[ "$FAIL" -eq 0 ]] || exit 1