diff --git a/.claude/hooks/ascii-guard.sh b/.claude/hooks/ascii-guard.sh new file mode 100755 index 0000000..cc8bdfe --- /dev/null +++ b/.claude/hooks/ascii-guard.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Agent-loop guard, paired with the internal/sourceguard CI test: after a .go +# file is edited, run the non-ASCII source guard and block the edit (exit 2, +# stderr fed back to the agent) when it introduces an unsanctioned non-ASCII +# rune. Wired from .claude/settings.json as a PostToolUse hook on +# Edit|Write|MultiEdit. Dependency-free (no jq); works on macOS and Linux. +set -u + +# The PostToolUse payload arrives as JSON on stdin. Trigger only when it +# references a .go file, so edits to other file types are no-ops. +input=$(cat) +case "$input" in +*'.go"'*) ;; +*) exit 0 ;; +esac + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +if ! out=$(go test ./internal/sourceguard/ -count=1 2>&1); then + printf '%s\n' "$out" >&2 + exit 2 +fi diff --git a/.claude/settings.json b/.claude/settings.json index dc20082..0fb2266 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "input=$(cat); case \"$input\" in *'.go\"'*) cd \"$CLAUDE_PROJECT_DIR\" || exit 0; out=$(go test ./internal/sourceguard/ -count=1 2>&1) || { printf '%s\\n' \"$out\" >&2; exit 2; };; esac", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/ascii-guard.sh\"", "statusMessage": "non-ASCII source guard", "timeout": 60 } diff --git a/internal/cli/output.go b/internal/cli/output.go index b2d0881..47c64d7 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -176,18 +176,41 @@ func renderSearchResult(w io.Writer, origin string, matches []skillcore.CatalogE return err } +// cellSanitizer neutralizes characters that would corrupt the tabwriter layout: +// a tab inside a cell is read by tabwriter as a column separator, and a newline +// or carriage-return breaks the one-row-per-line invariant. Each is replaced +// with a single space so cell content can never inject extra columns or rows — +// catalog descriptions and skill names are taken verbatim from manifests/paths +// and are not guaranteed control-character-free (PR #22 review). +var cellSanitizer = strings.NewReplacer("\t", " ", "\n", " ", "\r", " ") + // writeAlignedColumns renders rows as space-padded, fixed-width columns via the // stdlib text/tabwriter (elastic tabstops) so columns line up across rows in // compact human output. It is the one shared table renderer for search and the -// verify failure list — no duplicated tabwriter setup. Each row's cells are -// tab-joined; the final cell is newline-terminated, so it is never right-padded -// (no trailing whitespace). Cell width is counted in runes (tabwriter assumes -// equal-width code points), matching truncateDesc's rune budget so the column -// math and the clip agree. An empty rows slice writes nothing. +// verify failure list — no duplicated tabwriter setup. Each cell is sanitized of +// tabs/newlines (see cellSanitizer) and then tab-joined; the final cell is +// newline-terminated, so it is never right-padded (no trailing whitespace). Cell +// width is counted in runes (tabwriter assumes equal-width code points), +// matching truncateDesc's rune budget so the column math and the clip agree. An +// empty rows slice writes nothing. func writeAlignedColumns(w io.Writer, rows [][]string) error { - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + // tabwriter params: minwidth, tabwidth, padding, padchar, flags. + tw := tabwriter.NewWriter( + w, + 0, // minwidth + 0, // tabwidth + 2, // padding: spaces between columns + ' ', // padchar + 0, // flags + ) + for _, cells := range rows { - if _, err := fmt.Fprintln(tw, strings.Join(cells, "\t")); err != nil { + clean := make([]string, len(cells)) + for i, c := range cells { + clean[i] = cellSanitizer.Replace(c) + } + + if _, err := fmt.Fprintln(tw, strings.Join(clean, "\t")); err != nil { return err } } diff --git a/internal/cli/output_test.go b/internal/cli/output_test.go new file mode 100644 index 0000000..95e26ad --- /dev/null +++ b/internal/cli/output_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "strings" + "testing" +) + +// TestWriteAlignedColumns_NeutralizesTabsInCells guards the PR #22 review bug: a +// tab embedded in a cell must not be read by tabwriter as a column separator +// (which would corrupt alignment). The renderer sanitizes \t/\n/\r to spaces, so +// a tabbed description stays a single cell. +func TestWriteAlignedColumns_NeutralizesTabsInCells(t *testing.T) { + t.Parallel() + + var b strings.Builder + + rows := [][]string{ + {"name", "desc with\ttab"}, + {"longer-name", "second"}, + } + + if err := writeAlignedColumns(&b, rows); err != nil { + t.Fatalf("writeAlignedColumns: %v", err) + } + + out := b.String() + + // Exactly two rows render — the embedded tab did not split a cell into a new + // column or inject a row. + if lines := strings.Split(strings.TrimRight(out, "\n"), "\n"); len(lines) != 2 { + t.Fatalf("want 2 rendered lines, got %d:\n%q", len(lines), out) + } + + // The tab became a space inside the cell, so the description survives as one + // contiguous token. If the tab had leaked into tabwriter as a separator, the + // two halves would be split across padded columns and this substring (single + // space) would not appear. + if !strings.Contains(out, "desc with tab") { + t.Errorf("embedded tab not sanitized to a space; got:\n%q", out) + } +}