Skip to content

fix(validate): count characters, accept every major version, and keep citations inside the workspace - #2

Merged
protonspy merged 3 commits into
mainfrom
fix/validator-defects
Jul 30, 2026
Merged

protonspy merged 3 commits into
mainfrom
fix/validator-defects

Conversation

@protonspy

@protonspy protonspy commented Jul 30, 2026 •

Copy link
Copy Markdown
Owner

Three defects found in the review of #1. That PR merged before this commit landed on the branch, so these fixes never reached main — this carries them over unchanged.

Each fix has a regression test that was verified to fail against the pre-fix code, not merely to pass after it.

internal/validate/stack.go — the v10–v19 blind spot

majorVersionRe was ^v[2-9]\d*$. The leading-digit class starts at 2, so only the first digit was ever allowed to be non-1 — v10 through v19 were rejected outright, while v20 passed by coincidence. The doc comment directly above it offered v11 as an example, so the code contradicted its own documentation.

Effect: a module ending /v11 kept the suffix as its base name and was reported stack.undocumented-dependency against a docs/stack.md that documented it correctly. That is the false-positive class the function's own comment says it exists to prevent.

Verified against the old code:

baseName("github.com/go-chi/chi/v10") = "v10", want "chi"
baseName("github.com/go-chi/chi/v11") = "v11", want "chi"

internal/validate/skill.go — characters vs. bytes

The Agent Skills spec quotes its limits in characters; all three sites (name, description, compatibility) compared against len(), which is bytes. Every non-ASCII field was held to a shorter budget than the standard grants.

A description of exactly 1024 accented characters was reported skill.description-too-long — a false positive on a valid skill, and the existing test could not see it because it built its input from strings.Repeat("a", …). Counted in runes now, through a single charLen helper so the three sites cannot drift apart again.

internal/validate/codewiki.go — citations could escape the checkout

A citation target went into filepath.Join unchecked, so ../.. was cleaned into a real path outside root and then opened with os.ReadFile.

What made this worth fixing over the cosmetic findings: a traversal produced no finding at all. The outside file resolved, its line count came back in range, and the validator stayed silent — so the behavior was invisible rather than noisy. The regression test asserts both halves: that codewiki.citation-invalid fires, and that codewiki.citation-out-of-range does not, since the latter would prove the read happened anyway.

Against the pre-fix code the test reported rules = [] for both ../outside.txt and docs/../../outside.txt.

This applies the containment rule the workspace already enforces on argv (per the SafeName convention in CLAUDE.md) at the one place a path arrives from a document instead. Reuses codewiki.citation-invalid rather than adding a rule name, since a citation that leaves the checkout is invalid.

Verification

make check green — gofmt, go vet, go test -race. internal/validate coverage 85.0% → 85.3%. TestFreshArtifactsPassTheirOwnValidators still passes, which is the gate that matters most: a validator firing on scc's own scaffolded output is the worst bug in the product.

Not addressed here

Two findings from the same review are real but are contract changes rather than point fixes, and are left for a separate decision:

  • AtomicWrite overstates its guarantee — os.Rename is not atomic on Windows, which CLAUDE.md treats as a first-class target. Either narrow the documented contract or add a Windows-specific replace.
  • SafeWrite's no-clobber path is a Stat/Rename sequence — a file created between the two can still be replaced.

🤖 Generated with Claude Code

https://claude.ai/code/session_013XH8WYThtTSGJxS7tXRvfp

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened Codewiki citation validation to block workspace escape, including absolute-path and symlink-based traversal.
    • Fixed Agent Skills length-budget checks for non-ASCII text by counting Unicode characters instead of bytes.
    • Expanded Go module major-version suffix recognition to support broader multi-digit patterns.
  • Tests
    • Added coverage for citation containment prevention via .., absolute paths, symlinks, and symlinked workspaces.
    • Added coverage confirming Unicode character-count limits and module naming behavior.

… citations inside the workspace

Three defects found in review, each with a regression test that fails without the
corresponding fix.

stack: majorVersionRe was `^v[2-9]\d*$`, which rejects v10 through v19 — the
leading digit class starts at 2, so only the first digit was ever allowed to be
1-free. The doc comment directly above it claimed v11 as an example, so the code
contradicted its own documentation. A module ending /v11 kept the suffix as its
base name and was reported undocumented against a stack.md that documented it.

skill: the spec's limits are quoted in characters and were compared against
len(), which is bytes. Every non-ASCII name, description, or compatibility field
was held to a shorter budget than the standard grants — a false positive on a
valid skill, which the existing ASCII test could not see. Counted in runes now,
via one helper so the three sites cannot drift apart.

codewiki: a citation target went into filepath.Join unchecked, so ../.. was
cleaned into a real path outside root and then opened. The finding a traversal
produced was nothing at all — the outside file resolved, its lines counted in
range, and the validator stayed silent — so this was invisible rather than
noisy. Same containment rule the workspace already applies to argv, at the one
place a path arrives from a document instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XH8WYThtTSGJxS7tXRvfp
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@protonspy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e91c6a63-740c-4d87-9958-201a66afaba6

📥 Commits

Reviewing files that changed from the base of the PR and between 61fd6e4 and e51adf2.

📒 Files selected for processing (1)
  • internal/validate/knowledge_test.go
📝 Walkthrough

Walkthrough

The validator updates Codewiki citation containment, counts skill field limits by Unicode characters, and broadens Go module major-version suffix recognition. Tests cover traversal, absolute and symlink paths, Unicode boundaries, and module-name normalization.

Changes

Codewiki citation path security

Layer / File(s) Summary
Guard citation resolution
internal/validate/codewiki.go, internal/validate/knowledge_test.go
Citation targets are checked lexically and after symlink resolution, rejecting absolute paths and workspace escapes before file or range checks. Tests cover traversal, absolute citations, symlink escapes, and workspaces behind symlinks.

Skill character limits

Layer / File(s) Summary
Count Unicode characters
internal/validate/skill.go, internal/validate/skill_test.go
Compatibility, name, and description length checks use rune counts, with tests covering non-ASCII boundary values.

Module major-version parsing

Layer / File(s) Summary
Expand major-version matching
internal/validate/stack.go, internal/validate/knowledge_test.go
baseName recognizes single- and multi-digit v2+ module suffixes while preserving v0, v1, and non-major v* cases.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the three main validator fixes in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/validator-defects

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/validate/skill_test.go (1)

297-319: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add Unicode boundary coverage for name.

The production change in internal/validate/skill.go Lines 129-131 is not exercised by these additions. Add fixtures at 64 and 65 runes, asserting skill.name-too-long is absent/present respectively; the non-ASCII fixture may also correctly produce skill.name-invalid.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/validate/skill_test.go` around lines 297 - 319, Extend
TestLengthLimitsCountCharactersNotBytes with name-length fixtures containing
exactly 64 runes and then 65 runes, asserting skill.name-too-long is absent at
the limit and present above it. Use non-ASCII content to exercise rune counting,
and allow the over-limit case to also report skill.name-invalid.
internal/validate/codewiki.go (1)

137-152: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Possible duplication with workspace.SafeName.

The comment above checkCitation states this is "Same rule as workspace.SafeName, at the one place a path arrives from a document rather than from argv." If SafeName implements equivalent absolute/traversal rejection, consider extracting a shared helper (e.g. in an internal path-safety package) so the two containment rules can't drift apart over time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/validate/codewiki.go` around lines 137 - 152, The path-safety logic
in resolveInRoot duplicates workspace.SafeName’s absolute-path and traversal
checks. Extract the shared containment validation into a common internal
path-safety helper, update both resolveInRoot and SafeName to use it, and
preserve their existing return behavior and input-specific handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/validate/codewiki.go`:
- Around line 137-152: Update resolveInRoot and the isFile path to prevent
citations from traversing symlinks: validate the resolved target and its parent
components with symlink-aware filesystem checks, or reject any path containing
symlinks before os.Stat follows them. Ensure targets such as links escaping root
are rejected rather than treated as workspace files, while preserving valid
non-symlink paths.

---

Nitpick comments:
In `@internal/validate/codewiki.go`:
- Around line 137-152: The path-safety logic in resolveInRoot duplicates
workspace.SafeName’s absolute-path and traversal checks. Extract the shared
containment validation into a common internal path-safety helper, update both
resolveInRoot and SafeName to use it, and preserve their existing return
behavior and input-specific handling.

In `@internal/validate/skill_test.go`:
- Around line 297-319: Extend TestLengthLimitsCountCharactersNotBytes with
name-length fixtures containing exactly 64 runes and then 65 runes, asserting
skill.name-too-long is absent at the limit and present above it. Use non-ASCII
content to exercise rune counting, and allow the over-limit case to also report
skill.name-invalid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 60b3e5e2-6067-425f-ac29-b123ce8e9da6

📥 Commits

Reviewing files that changed from the base of the PR and between 43a5b8a and 13507e0.

📒 Files selected for processing (5)
  • internal/validate/codewiki.go
  • internal/validate/knowledge_test.go
  • internal/validate/skill.go
  • internal/validate/skill_test.go
  • internal/validate/stack.go

Comment thread internal/validate/codewiki.go
The lexical containment check landed in the previous commit is necessary and not
sufficient. A symlink that sits inside the workspace and points out of it never
spells "..", so filepath.Clean has nothing to collapse and the check passes it
through — then isFile and os.ReadFile follow the link and the kernel performs
the escape the path itself never described.

It failed the same silent way the original traversal did: the linked file
resolved, its lines counted in range, and the validator reported nothing at all.
A regression test asserts both halves, that citation-invalid fires and that
citation-out-of-range does not, since the latter would prove the read happened.

The root is resolved alongside the target rather than compared raw. A workspace
legitimately lives under a symlink — macOS puts every t.TempDir() behind
/private/var — and matching a fully resolved target against an unresolved root
would call every citation in such a workspace an escape. The second test pins
that direction so the containment check cannot be tightened into a false
positive on a real workspace.

A target that does not resolve is left to the existing unresolved finding:
nothing was followed, so nothing escaped, and naming a typo an escape would be
the less useful of the two answers.

Found in review of #2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XH8WYThtTSGJxS7tXRvfp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/validate/codewiki.go`:
- Around line 164-174: The symlink validation in the path-resolution flow is
vulnerable to a check-to-use race before checkCitation reads the file. Update
the interaction between the visible EvalSymlinks/within logic and checkCitation
so file validation and reading use a root-confined, no-follow handle-based path,
preserving rejection of resolved paths outside realRoot; otherwise enforce an
immutable-workspace precondition.

In `@internal/validate/knowledge_test.go`:
- Around line 467-482: Update the citation in the symlink validation test around
runValidator to request lines 1-3 instead of 1-2, while keeping the existing
assertions unchanged so a symlink-read regression produces
codewiki.citation-out-of-range.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ac33d16-4126-44ba-ae26-7cf710a604b8

📥 Commits

Reviewing files that changed from the base of the PR and between 13507e0 and 61fd6e4.

📒 Files selected for processing (2)
  • internal/validate/codewiki.go
  • internal/validate/knowledge_test.go

Comment on lines +164 to +174
realResolved, err := filepath.EvalSymlinks(resolved)
if err != nil {
// The target does not exist, so nothing was followed and nothing escaped. The
// lexical result stands and the caller reports it as unresolved, which is the
// more useful finding than calling a typo an escape.
return resolved, true
}
if !within(realRoot, realResolved) {
return "", false
}
return resolved, true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Avoid the symlink check-to-use race.

Line 169 returns the lexical path; checkCitation subsequently calls isFile and os.ReadFile on it. A process able to modify the checkout can swap a checked-safe component for an escaping symlink after EvalSymlinks, causing an out-of-workspace read. Use a root-confined, no-follow handle-based read path (or explicitly require an immutable workspace).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/validate/codewiki.go` around lines 164 - 174, The symlink validation
in the path-resolution flow is vulnerable to a check-to-use race before
checkCitation reads the file. Update the interaction between the visible
EvalSymlinks/within logic and checkCitation so file validation and reading use a
root-confined, no-follow handle-based path, preserving rejection of resolved
paths outside realRoot; otherwise enforce an immutable-workspace precondition.

Comment thread internal/validate/knowledge_test.go
Both escape tests asserted that codewiki.citation-out-of-range did not fire, as
the evidence that the outside file was never opened. The fixture has two lines
and the citation asked for 1-2, which is inside it — so that finding would have
stayed silent whether the read happened or not, and the assertion proved
nothing. The commits that added them claimed the tests pinned both halves; only
the citation-invalid half was actually pinned.

Citing 1-3 puts the range past the end of the fixture, so a regression that
follows the traversal or the symlink now reports out-of-range and the assertion
fails. Confirmed in both directions: against the pre-fix code the traversal case
reports [codewiki.citation-out-of-range] where it previously reported [], which
is positive evidence of the read rather than the absence of a finding.

Found in review of #2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XH8WYThtTSGJxS7tXRvfp
@protonspy
protonspy merged commit 32bdd30 into main Jul 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant