Harden file writes and git operations against symlink/option injection#48
Closed
devill wants to merge 5 commits into
Closed
Harden file writes and git operations against symlink/option injection#48devill wants to merge 5 commits into
devill wants to merge 5 commits into
Conversation
`detectTool` read `bin` from node_modules/<tool>/package.json and joined it
to the package dir with no containment check, then spawned the result (a
`.js` path runs via node). A repo committing
`node_modules/eslint/package.json` with `"bin": {"eslint":"../../evil.js"}`
could make habit-hooks execute an arbitrary committed file on clone + run
(pre-commit/CI), with no `npm install` required.
Resolve the bin and reject any path that lands outside the tool's own
package directory; the tool is then treated as undetected (bundled fallback
is used) instead of spawning attacker-controlled code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JpXA4jQJRGPcMfpC3nmmA
Every `init` writer and the baseline save did `existsSync(path)` then `writeFileSync`/`copyFileSync`. Both follow symlinks, so a dangling symlink committed in the working tree (target absent, so the existsSync guard sees nothing) redirected the write outside the project root — including the git pre-commit hook, which was also chmod'd 0o755, planting an executable at an attacker-chosen path. Add a shared safe-fs helper that opens with O_NOFOLLOW (fails closed with SymlinkWriteError when the final path component is a symlink, no TOCTOU) and route the config/eslint/knip/jscpd/ruff scaffolders, package.json scripts, recommendation merge, baseline save, git hook, and skill copy through it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JpXA4jQJRGPcMfpC3nmmA
`branchBase`/`mainBranch` (from a committed habit-hooks.config.json, a pure data file) and `--since` flowed unvalidated into `git merge-base` and `git diff`. A value beginning with `-` is parsed by git as an option, not a revision — and `git diff --name-only --output=<file> HEAD` writes an arbitrary file. A trailing `--` does not help here because the revision sits in the option position, so validate instead: refuse any ref starting with `-` (UnsafeRefError). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JpXA4jQJRGPcMfpC3nmmA
`MAP[ruleId] ?? ruleId` returned an inherited member when a tool emitted a rule id of `constructor`/`toString`/`__proto__`, so the `?? ruleId` fallback never fired and the resolved smell became a function/object that stringified to `[object Object]` in output. Use Object.hasOwn so only own keys map and unknown rule ids pass through unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JpXA4jQJRGPcMfpC3nmmA
`slugify` replaced `/` but not `\`, so on Windows a crafted rule/smell id could traverse out of the prompts directory when joined to it. Replace both separators and extract the (previously duplicated) helper into one module so loader and registry stay in sync. Not reachable from tool output today — unknown smells never hit the file lookup — but defensive if routing changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JpXA4jQJRGPcMfpC3nmmA
devill
added a commit
that referenced
this pull request
Jun 18, 2026
extractIssues / eslint-wrap / knip-wrap mapped a tool's smell id with `map[id] ?? id`. When a tool emits an id that collides with an Object.prototype member (`constructor`, `toString`, `__proto__`), the lookup returns the inherited member, the `?? id` fallback never fires, and the resolved smell stringifies to `[object Object]` in output. Use Object.hasOwn so only own keys map and every other id passes through unchanged. This is a correctness fix for untrusted tool output, not a privilege/security boundary. Cherry-picked from PR #48 (the rest of that PR was hardening against a synthetic "malicious working tree" threat model that does not fire in habit-hooks' actual usage, and is being dropped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds security hardening to prevent symlink-following attacks during file writes and option-injection attacks in git operations. It also fixes unsafe property lookups that could be exploited via prototype pollution.
Key Changes
File write safety (
safe-fs.ts,safe-fs.test.ts)safeWriteFileSyncandsafeCopyFileSyncthat useO_NOFOLLOWflag to prevent writes through symlinksSymlinkWriteErrorexceptionwriteFileSynccalls withsafeWriteFileSyncacross the codebase (git hooks, baseline storage, config scaffolding, package scripts, jscpd config, skill installation)Git operation safety (
git/scope.ts,git/scope.test.ts)safeRefvalidation to reject git revision arguments starting with-(which would be parsed as options)--output=/tmp/pwnbeing passed as a commit hashgetChangedVsCommitandgetMergeBasecalls--separator in diff command to terminate option parsingUnsafeRefErrorexception for rejected refsTool bin path containment (
detect/tool.ts,detect/tool.test.ts)containedBinvalidation to rejectpackage.json#binpaths that escape the package directory"bin": "../../evil.js"Safe property lookups
map?.[key] ?? fallbackpatterns withObject.hasOwn(map, key)checks in:src/sensors/adapter.ts(smell mapping)src/checks/eslint-wrap.ts(ESLint smell mapping)src/checks/knip-wrap.ts(Knip smell mapping)"constructor"that would incorrectly resolve toObject.prototype.constructorconstructoras a smell ID is handled correctlyRule ID slugification (
prompts/slug.ts,prompts/slug.test.ts)slugifyfunction to dedicated module (previously inline inloader.tsandregistry.ts)/and\path separators (in addition to:and@)../../etc/passwdwhen mapping to prompt filesImplementation Details
O_NOFOLLOWflag closes the TOCTOU window without requiring separate existence checksELOOPerror on open, preventing writes to arbitrary locationsrelative()to detect escape attempts (..prefixes or absolute paths)Object.hasOwn()instead of optional chaining to avoid prototype chain traversalhttps://claude.ai/code/session_011JpXA4jQJRGPcMfpC3nmmA