Skip to content
Open
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
22 changes: 22 additions & 0 deletions .claude/hooks/ascii-guard.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
37 changes: 30 additions & 7 deletions internal/cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
41 changes: 41 additions & 0 deletions internal/cli/output_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}