Skip to content

feat(tidy): non-destructive repo cleanup, plus 25 defect fixes including a shell injection - #13

Merged
leonardocandiani merged 3 commits into
mainfrom
feat/tidy-command-and-security-fixes
Aug 31, 2026
Merged

leonardocandiani merged 3 commits into
mainfrom
feat/tidy-command-and-security-fixes

Conversation

@leonardocandiani

Copy link
Copy Markdown
Owner

What this is

Two things: a new command that cleans up cluttered repos without ever deleting
anything, and 25 defects found by reading the plugin end to end, each one
reproduced by running the engine rather than by reading it.

The new command

/keepwright:tidy takes a repo that has filled up with backup files, committed
build output, byte-identical duplicates, modules nothing imports and an
unreadable root, and leaves it cleaner with every change reversible.

Nothing is ever deleted. The engine knows three operations and none destroys
bytes: quarantine moves a file into .attic/<date>/ keeping its original
path, untrack drops a path from the index while the file stays on disk, and
move relocates. There is no delete path in tidy-apply.ts, and CI greps the
source to keep it that way. It refuses a dirty tree and the default branch, so
deleting the branch is a complete escape hatch, and MANIFEST.json holds the
exact inverse of every operation.

What makes it more than a filename heuristic is the evidence. tidy-scan.ts
builds an import graph and walks it from the real entry points, then combines
that with git history and a mention sweep. A file is called an orphan only when
no entry point reaches it, nothing imports it, and no tracked file names it.
Everything else is a question, not an action. The scanner is deliberately
biased toward calling things used: a false "still in use" costs a line of
output, a false "unused" costs someone their code.

Verified: four refusal paths that write nothing, a dry run that leaves the
tree clean, and apply plus undo returning every blob hash to its prior state.
Against a clone of a real 555-file project, the project's own TypeScript
compiler reports the same zero errors before, after quarantining nine files,
and after the undo. An independent grep, written against a different
implementation than the graph, finds zero references to all nine.

The defects

Critical

The mention workflow interpolated the comment body into a run: script inside
single quotes. Actions substitutes into the script text before bash parses it,
so a comment with a quote broke out. The step fires on every
issue_comment.created, before the mention filter, which sits inside the same
already-substituted script. With contents: write and the schema's
self-hosted default, that was arbitrary execution on the maintainer's machine
by anyone who can comment on an issue. Fixed here, along with three more of the
same class, and CI now fails on any free-text GitHub context inside a run:.

Broke the product in ordinary use

  • Every commit failed right after setup: the hook called a script no template
    generated, and carried placeholders as literal text that it tried to execute.
  • A repo that already had a CLAUDE.md got a red pipeline on its first push,
    which is exactly the "works on any existing repo" case the README advertises.
  • The review skill globbed rules 01 through 07, so two of the nine were never
    read.

Silent failures, the worst category

  • With no criticalFiles set, the workflow grepped for the literal string
    {{CRITICAL_FILE_1}}: the warning never fired and the repo looked watched.
  • The audit counted a repo's own ci.yml as keepwright coverage.
  • The banned-terms grep matched an authorship trailer anywhere in a line, and
    REVIEW.md documents that string, so the repo blocked any PR editing its own
    review doc.
  • The documented merge bypass expanded to a literal word instead of reading its
    variable whenever the project name had a hyphen.
  • Derived patterns, the product's central claim, were mined and declared and
    then reached nothing.

Every lesson became a check with an exit code

apply.ts refuses to install when a placeholder would survive into an executed
file, and that gate found the CRITICAL_FILE defect on its first run. The five
diverging secret pattern lists became one data file that both the shell greps
and the TypeScript scanners read, with the more permissive bound winning
wherever copies disagreed. CI installs the plugin into scratch greenfield and
brownfield repos, plants a fake credential and fails if the validator passes
it, and fails if an emptied pattern list is accepted rather than refused.

Every gate has a positive control: the defect was reintroduced and the gate was
confirmed to reject it.

Removed

The orchestration workflows are no longer copied into the target repo, where
nothing invoked them and where they could not run standalone. customValidators
and mode leave the schema: a versioned config should describe the repo, not
the action being performed on it.

What still needs to be proven

The new CI jobs were simulated locally step by step, with positive controls,
but have never run on GitHub Actions. This PR is that test.

EMPIRICAL VALIDATION

Local suite, 11 checks, all green:

PASS: 11 workflow files parse
PASS: gate accepts the fix, rejects the reintroduced injection, no false positive
PASS: greenfield runnable, brownfield green on day one, apply idempotent
PASS: refusals write nothing, dry run is the default, undo restores byte for byte
PASS: .gitignore returns to its exact prior content
PASS: unknown flag exits 2 on both
PASS: no filesystem delete anywhere in the apply engine
PASS: every git rm is --cached (index only)
PASS: 4 findings, 0 of them proposing to move a live file
PASS: REVIEW.md gets the repo's own patterns, and says so plainly when there are none
PASS: 11 credential shapes caught from shell and from JS, no false positive, empty list fails loudly

Compiler proof on a real repo:

BASELINE typecheck: errors before: 0
applied: quarantine 9
AFTER typecheck: errors after: 0
UNDO: reversed 9 -> errors after undo: 0

Cleans up a cluttered repo and never deletes anything. The engine knows
exactly three operations and none of them destroys bytes: quarantine moves a
file into .attic/<date>/ with its original path preserved, untrack drops a
path from the index while the file stays on disk, and move relocates a file.
There is no delete path in tidy-apply.ts.

It refuses to run on the default branch or on a dirty tree, so deleting the
branch is always a complete escape hatch, and it writes a MANIFEST.json
carrying the exact inverse of every operation performed. Dry run is the
default; --undo <manifest> --apply replays those inverses, down to removing the
.gitignore line the run appended.

What makes it more than a filename heuristic is the evidence. tidy-scan.ts
builds an import graph over the repo's own sources and walks it from the real
entry points (framework routes with or without the src/ layout, config and test
files, conftest.py, edge functions, anything with a shebang, anything
package.json or a CI workflow executes by path), then combines that with git
history and a textual mention sweep. A file is only called an orphan when no
entry point reaches it, nothing imports it, and no tracked file even names it.
Everything the evidence does not settle is reported as a question rather than
an action, and the scanner is deliberately biased toward calling things used:
an unresolvable import marks its target reachable, and a prose mention
downgrades a quarantine to a review.

The flow follows five phases, each ending in a committed artifact under
.keepwright/tidy/<date>/, with [NEEDS DECISION] markers that gate the planning
phase. Templates for every artifact live in skills/tidy/references/.

Verified end to end: four refusal paths that write nothing, a dry run that
leaves the tree clean, and an apply plus undo that returns every tracked path
and blob hash to its pre-tidy state. Against a clone of a real 555-file
project, the project's own TypeScript compiler reports the same zero errors
before the run, after quarantining nine files, and after the undo.

Autor: Leonardo Candiani
… more defects

The mention workflow interpolated the comment body, the issue body and the
issue title straight into a run: script inside single quotes. Actions
substitutes an expression into the script TEXT before bash parses it, so a
comment carrying a quote closed the quoting and the rest of it ran as commands.
The step fires on every issue_comment.created, before the mention filter, which
lives inside the same already-substituted script and therefore protected
nothing. With contents: write and the schema's self-hosted runner default, that
was arbitrary command execution on the maintainer's machine, triggerable by
anyone who can comment on an issue. Every untrusted field now travels through
the step's env: block, the way the issue-triage workflow already did. The same
class is closed in the auto-merge branch name, the Supabase deploy input and
the auto-review base ref, and CI now fails if any known free-text GitHub
context field appears inside a run: block.

Three defects broke the product in ordinary use. Every commit failed right
after setup: the installed lefthook.yml called a validators runner that no
template generated, and carried SOURCE_GLOB and CMD_TYPECHECK as literal text,
so the pre-commit type-check tried to execute the token as a command. A repo
that already had a CLAUDE.md got a red pipeline on its first push, because the
same run installs the rules and the validator that fails when a rule has no
pointer; apply now appends the missing pointers without rewriting a line the
maintainer wrote. And the review skill globbed rules 01 through 07, so two of
the nine were never consulted.

The most valuable class was the silent one. With no criticalFiles configured,
the auto-review workflow grepped the changed-file list for the literal string
CRITICAL_FILE_1, so the warning never fired and the repo looked watched while
nothing watched it. The audit counted a repo's own ci.yml as coverage for a
pipeline running none of these checks. The banned-terms grep matched an
authorship trailer anywhere in a line, and REVIEW.md documents that exact
string, so the repo blocked any PR that edited its own review doc. The
documented merge bypass expanded to a literal word instead of reading its
variable whenever the project name contained a hyphen. And derived patterns,
the product's central claim, were mined and declared and then reached nothing,
so every PR was still reviewed against a generic ideal.

Each lesson became a check with an exit code rather than a comment. apply.ts
refuses to install when a placeholder would survive into a file that gets
executed, and that gate found the CRITICAL_FILE defect on its first run. The
five diverging copies of the secret pattern list became one data file that the
shell greps and the TypeScript scanners both read, with the more permissive
bound winning wherever two copies disagreed. CI installs the plugin into
scratch greenfield and brownfield repos, plants a fake credential and fails if
the validator passes it, and fails if an emptied pattern list is accepted.

Removed: the orchestration workflows are no longer copied into the target repo,
where nothing invoked them and where they could not run; customValidators and
mode leave the schema, since a versioned config should describe the repo and
not the action being performed on it.

Autor: Leonardo Candiani
…d string

The consistency check pinned the literal string
`grep -E -f scripts/validators/secret-patterns.ere`, which stopped existing when
the workflows started stripping comments and blank lines into a temp file before
grepping. The workflows were correct; the check had gone stale against a later
change in the same branch, and it failed on the first real Actions run.

It now asserts the invariant instead of a spelling: each consumer must name the
shared file and must grep with -f, and no workflow may carry a credential prefix
inline. Verified with a positive control, by removing the read from one consumer
and confirming the check rejects it.

Autor: Leonardo Candiani
@leonardocandiani
leonardocandiani merged commit 7688e15 into main Aug 31, 2026
2 checks passed
@leonardocandiani
leonardocandiani deleted the feat/tidy-command-and-security-fixes branch August 31, 2026 21:17
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