Skip to content

One shell-quoting implementation, not four - #769

Merged
jorgemanrubia merged 5 commits into
mainfrom
shellquote-converge
Sep 19, 2026
Merged

jorgemanrubia merged 5 commits into
mainfrom
shellquote-converge

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 19, 2026

Copy link
Copy Markdown
Member

Four copies of the same escaping routine, and only one of them finished. internal/richtext had the good one; internal/auth, internal/config and internal/commands each carried their own, written before there was a shared home. The way this surfaced is the argument for closing it: one copy's doc comment had drifted into an unmatched parenthesis and a sentence that stopped mid-clause, and all four spelled the POSIX splice with a typographic quote — not a typo but gofmt rewriting the escape in doc-comment prose every time somebody fixed it. Fixing one copy taught nothing about the other three.

Four copies of an escaping routine is how one of them drifts, and the one that drifts is the one interpolating a value that came from the API. richtext.ShellQuote is now the only one, and every caller is on it.

Originally tracked in Four shell-quoting implementations, one of them finished. The shared implementation and the gofmt problem came from The connector runs where it is started: no directory is a project's, whose own doc comment said converging the rest "is not this change's to do".

The copies were not identical, and one caller had noticed

Checked before deleting, not assumed:

  • auth's was byte-for-byte the same function, including its shellActive helper.
  • commands' differed only in spelling — a compiled regexp where richtext walks runes — and agrees with it on every one of the 256 bytes and every rune up to U+2FFFF. Nothing those callers emit changed.
  • config's genuinely differed. It wrapped every value in single quotes whether or not it needed them, where richtext leaves a value that can mean nothing to a shell alone. So an ordinary path in the untrusted-config warnings, which used to read basecamp config trust '/home/x/.basecamp/config.json', now reads basecamp config trust /home/x/.basecamp/config.json. The same shell word, spelled shorter.

What the deleted copy was incidentally guaranteeing

Nothing, as it turns out — but the test standing over it claimed otherwise, and that is the part worth reading.

TestConfigSet_AuthorityKeyWarnsWithPath asserted the path came out surrounded by single quotes, with the message "path must be single-quoted for shell safety". It is the only caller in the repository that depended on config's copy rather than on quoting in general, and it failed on the move.

The assertion was weaker than its message. It ran against an ordinary temp-dir path with no apostrophe in it, so a routine that wrapped a value in quotes and never spliced an embedded one would have passed it unchanged — that is, the single mistake shell quoting exists to prevent was outside what the test could see. It now asks for the property the message was claiming: an ordinary path needs no quoting and stays readable, and a path carrying an apostrophe is spliced out and back in rather than merely wrapped. Mutating richtext.ShellQuote into a naive wrapper fails the new test and passed the old one.

So the emitted text changed, deliberately, and it is flagged here rather than left in the diff. The safety property did not change; it is checked now where before it was assumed.

One caller is not moved, and a fifth copy that was

connect_run.go still calls the old shellQuote name in internal/commands, because another change owns that file right now. The name survives there as a one-line delegation to richtext.ShellQuote — not a fourth implementation — and goes when that line moves.

The internal/connector recovery harness had a fifth copy inside a test file — a hand-rolled one, and the naive spelling: it wrapped unconditionally with no inert check. It builds the #!/bin/sh shim a fake agent is launched through, so it is not asserting against a fixed expected string and had no reason to spell its own. It is on richtext.ShellQuote now; internal/connector already imported richtext, and richtext has no internal dependencies at all, so there was no cycle to work around. The harness tests run that shim through a real shell, which is what confirms it.

Keeping the escape

The '\'' in the doc comment stays in an indented block, which gofmt does not reformat. Verified after writing, not assumed: gofmt -l is clean and the block is unchanged.

The real-shell test in internal/richtext now also covers the shapes the three deleted copies carried — a profile name, a config path with an apostrophe in a home directory, an opaque feed position — plus a tab. It stays behind //go:build unix because this repository builds for Windows and /bin/sh is not there; GOOS=windows go vet is clean for every package touched here.

internal/richtext.ShellQuote is now the only one. The copies in
internal/auth, internal/config and internal/commands are gone and their
callers moved to it.

auth's copy was byte-identical. commands' copy differed only in spelling — a
compiled regexp where richtext walks runes — and agrees with it on every
byte and every rune up to U+2FFFF, so nothing those callers emit changed.

config's copy did differ: it wrapped every value in single quotes whether or
not it needed them, so a path that can mean nothing to a shell now appears
bare in the untrusted-config warnings. The same shell word, spelled shorter.
A test asserted the old spelling as "shell safety", and that assertion was
weaker than it read: it passed for a wrapper that never spliced an embedded
quote at all. It now asks for the property instead — a path carrying an
apostrophe is spliced, not merely wrapped — and fails against such a
wrapper, which the one it replaces did not.

One call site is left on the old name: connect_run.go, which another change
owns. shellQuote there delegates to richtext.ShellQuote and goes when that
line moves.
Copilot AI balanced review requested due to automatic review settings September 19, 2026 07:16
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) auth OAuth authentication labels Sep 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

A new platform-independent test contains a path assertion that fails on Windows.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Centralizes POSIX shell quoting in richtext.ShellQuote and migrates command, configuration, and authentication callers to it.

Changes:

  • Removes duplicate quoting implementations.
  • Updates emitted commands and warnings to use shared quoting.
  • Expands coverage for shell-sensitive values.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
internal/​richtext/​shellquote.go Documents the canonical quoting implementation.
internal/​richtext/​shellquote_unix_test.go Expands real-shell quoting cases.
internal/​config/​trust_test.go Tests quoted trust-warning paths.
internal/​config/​config.go Uses shared quoting in trust warnings.
internal/​commands/​templates.go Quotes profile arguments centrally.
internal/​commands/​files.go Replaces local quoting logic.
internal/​commands/​files_test.go Tests breadcrumb quoting behavior.
internal/​commands/​feed.go Quotes feed positions centrally.
internal/​commands/​connect.go Migrates connector hints to shared quoting.
internal/​commands/​connect_operator.go Migrates operator hints.
internal/​commands/​connect_doctor.go Migrates doctor remediation hints.
internal/​commands/​connect_doctor_mcp_unix.go Migrates MCP command rendering.
internal/​commands/​config.go Uses shared quoting in config warnings.
internal/​commands/​config_test.go Expands config warning coverage.
internal/​auth/​auth.go Removes the authentication-local implementation.
internal/​auth/​agent.go Migrates agent login commands.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/config/trust_test.go Outdated
"An inert path comes through unquoted" is a statement about POSIX paths. A
Windows path carries backslashes, which mean something to a shell, so
richtext.ShellQuote rightly quotes it and there is no bare path to assert —
the new trust-warning test would have failed on Windows, which this
repository builds for.

The Go suite runs on ubuntu-latest only, so nothing would have caught it:
the assertion would have stayed green here while being wrong for anyone
running the tests on Windows.

Split rather than deleted. The apostrophe case is the portable half and
stays in trust_test.go — no escape reaches inside single quotes on any
platform, so the splice has to happen everywhere. The bare-path case moves
to trust_unix_test.go behind //go:build unix, alongside the real-shell tests
in internal/richtext that are gated for the same reason. The commands-side
warning test asks richtext.ShellQuote for the expected spelling rather than
writing quotes out, so it means the same thing on both.

Copilot on #769.
Copilot AI review requested due to automatic review settings September 19, 2026 07:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Two tests still depend on the host temporary-directory spelling and can fail in valid environments.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Avoid asserting platform-dependent temp path spelling

internal/​commands/​config_test.go:371

The plain branch still depends on the spelling of the process temp root. If TMPDIR contains an apostrophe, absPath also contains one and richtext.ShellQuote(absPath) correctly splices it, so the raw-path substring assertion fails even though the warning is correct. The exact ShellQuote assertion above already covers the platform-dependent spelling; keep the extra splice checks only for the fixture that deliberately adds an apostrophe.

Comment thread internal/config/trust_unix_test.go Outdated
t.TempDir() is rooted under TMPDIR, and a valid POSIX TMPDIR may hold
apostrophes, spaces or anything else; macOS resolves /var through a symlink,
CI sandboxes use paths nobody would guess, and Windows spells the whole thing
differently. A shell-quoting test rooted in it asserts something about the
host rather than about the quoting — and the bare-path one would have failed
outright on a host whose temporary directory carries a quote.

Both config fixtures are now a relative path under a directory the test
changes into, so the expectation is an exact string that means the same
thing on every machine: `basecamp config trust repo/.basecamp/config.json`
and `basecamp config trust 'o'\''brien/config.json'`. Verified by running
them with TMPDIR set to a directory whose name carries an apostrophe, a
space, a non-ASCII character and a command substitution; the shape they
replace fails there.

The commands-side warning is about an absolute path and cannot be made
relative, so it drops the raw-substring assertion on the ordinary fixture —
which was the same dependency one step milder — and keeps comparing against
richtext.ShellQuote. The splice checks stay on the fixture that deliberately
carries an apostrophe.

Copilot on #769.
Copilot AI review requested due to automatic review settings September 19, 2026 07:48
Copilot AI previously approved these changes Sep 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approved

The centralized implementation preserves shell safety and has focused regression coverage for intentional behavior changes.

Review effort: Balanced
Findings: None

Resolved since last review (1)

A one-line hand-rolled implementation in a test file, and the naive
spelling: it wrapped unconditionally with no inert check. It builds the
#!/bin/sh shim a fake agent is launched through, so it is generating a
script a real shell runs rather than asserting against a fixed expected
string — there was no reason for it to spell its own.

No cycle to work around: internal/connector already imports internal/
richtext, and richtext has no internal dependencies at all. The harness
tests execute that shim through /bin/sh, which is what confirms the move.

richtext.ShellQuote is now the only implementation in the repository. The
one remaining use of the old `shellQuote` name, in internal/commands, is a
forwarder to it and says so at its definition.
Copilot AI review requested due to automatic review settings September 19, 2026 08:07
Copilot AI dismissed their stale review, a newer Copilot review was requested September 19, 2026 08:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

A separate test helper still implements POSIX shell quoting independently, leaving the consolidation incomplete.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread internal/richtext/shellquote.go
internal/harness/codex_unix_test.go spliced the quotes inline, with no
function around them, to put a temporary path into a script it runs through
sh. Searching for `func shellQuote` could never have found it — and an
inline expression is the copy most likely to drift, because it has no doc
comment to be wrong and nothing to grep for later. Converging an
implementation means grepping for the pattern, not the function.

No cycle: internal/richtext has no internal dependencies at all, so nothing
can import it into one.

The quoting there is load-bearing, and now proven so rather than assumed.
With TMPDIR set to a directory named `o'brien dir café $(id)`, passing the
path through unencoded fails the descendant test — the script writes its pid
somewhere the test does not look — and encoding it passes.

The rest of the sweep: richtext.go's HTML-entity replacements are a
different job, and the composer's `'a' 'b' 'c'` handling parses a drag
payload back apart rather than encoding anything for a shell.
Copilot AI review requested due to automatic review settings September 19, 2026 08:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approved

The consolidation preserves shell safety and includes focused cross-platform regression coverage.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@jorgemanrubia
jorgemanrubia merged commit 20c9f7a into main Sep 19, 2026
28 checks passed
@jorgemanrubia
jorgemanrubia deleted the shellquote-converge branch September 19, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth OAuth authentication commands CLI command implementations tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants