Skip to content

Fix /etc/ persistence marker FP: path-nesting collision, different from .profile - #107

Merged
MoSLoF merged 1 commit into
mainfrom
claude/depsnort-project-structure-5re5ui
Aug 25, 2026
Merged

Fix /etc/ persistence marker FP: path-nesting collision, different from .profile#107
MoSLoF merged 1 commit into
mainfrom
claude/depsnort-project-structure-5re5ui

Conversation

@MoSLoF

@MoSLoF MoSLoF commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Same FP sweep that produced D-130 (.profile).

The gap

/etc/ was also a flat, punctuation-anchored persistenceMarkers entry matched as a raw substring — and it also fired on any relative path that merely NESTS a directory literally named "etc", with nothing to do with the real absolute Unix system directory:

require('./etc/templates/config.js')   → fires
require('./spec/etc/config.js')        → fires

Why D-130's fix doesn't carry over

containsWord's boundary check only asks whether the immediately-preceding byte is a non-identifier character — and both . and / pass that test, so ./etc/ would still match even with the dual-boundary check applied. .profile's FP shape (user.profileImage) and /etc/'s FP shape (./etc/templates/) are structurally different problems wearing the same "punctuation-anchored raw substring" surface: one is an identifier-continuation collision, the other is a path-nesting collision. They need different fixes, not the same one applied twice.

The fix

etcAbsolutePathRe, a new regex requiring the leading / of /etc/ to be the START of a quoted string, a shell/JS argument, or the whole source — matched as a whitelist of legitimate preceding characters (quote, backtick, whitespace, and the shell/JS separators ; & | ( = > < ,) rather than a blacklist, so any preceding character the list doesn't anticipate fails closed. /etc/ was pulled out of the flat persistenceMarkers list entirely, mirroring the existing startupFolderRe/"startup-folder" precedent (a genuinely new regex, not extended eligibility for containsWord). IsPersistenceMarker gained a matching special case.

Deliberately left unfixed

This does NOT distinguish a READ of /etc/ (fs.existsSync('/etc/os-release'), a common, legitimate OS/libc-detection idiom in native-module installers — the exact idiom that motivated checking this marker) from a WRITE establishing persistence. No other persistence marker in this list makes that distinction either — a bare fs.existsSync('~/.bashrc') also trips VC-002g today — so singling out /etc/ for read/write semantics would be inconsistent with the rest of the list, not more correct. That would need actual call-site semantic analysis, a materially bigger change than a boundary check; noted as a residual.

Validation

  • New test TestPersistenceEtcAbsolutePathPrecision (etc_fp_test.go), same structure as TestPersistenceDotProfilePrecision: three benign nested-path cases that must not raise persistence, three real-technique shapes that must (a bare quoted write, a shell append via redirect, and the OS-detection read — deliberately still flagged).
  • Mutation-proven twice, independently: removing the etcAbsolutePathRe evaluation in scanCaps fails all three real cases; separately, removing the IsPersistenceMarker special case (leaving the scanCaps evaluation intact) also fails all three — both halves of the fix are independently load-bearing, not redundant with each other.
  • gofmt -l clean, go build ./..., go vet ./... silent.
  • Full suite green (34 packages), -race clean on internal/installsurface.
  • Live CLI validation: a synthetic install hook doing only require('./etc/templates/config.js') now scores clean of VC-002g. A real fs.writeFileSync('/etc/cron.d/evil', ...) combined with a piped-curl cradle still fires both VC-002f and VC-002g correctly.

Files

  • internal/installsurface/analyze.goetcAbsolutePathRe, the scanCaps/IsPersistenceMarker wiring.
  • internal/installsurface/etc_fp_test.go — new regression test.
  • docs/DECISIONS.md — D-131.

Generated by Claude Code

…om .profile (D-131)

Same FP sweep that produced D-130. /etc/ was also a flat, punctuation-
anchored persistenceMarkers entry matched as a raw substring, and also
fired on any relative path merely nesting a directory literally named
"etc" -- require('./etc/templates/config.js'),
require('./spec/etc/config.js') -- with nothing to do with the real
absolute Unix system directory.

D-130's fix (routing through containsWord) does not close this one:
containsWord's boundary check only asks whether the preceding byte is
a non-identifier character, and both "." and "/" pass that test, so
./etc/ would still match. The two FP shapes are structurally
different problems wearing the same surface -- .profile is an
identifier-continuation collision, /etc/ is a path-nesting collision
-- and need different fixes.

Added etcAbsolutePathRe: requires the leading "/" of "/etc/" to be
the start of a quoted string, shell/JS argument, or the whole source,
matched as a whitelist of legitimate preceding characters rather than
a blacklist, so an unanticipated preceding character fails closed.
Pulled /etc/ out of the flat marker list entirely, mirroring the
existing startupFolderRe/"startup-folder" precedent, with a matching
IsPersistenceMarker special case.

Deliberately does not distinguish a READ of /etc/
(fs.existsSync('/etc/os-release'), a common, legitimate OS/libc-
detection idiom) from a WRITE establishing persistence -- no other
marker in this list makes that distinction either, so singling this
one out would be inconsistent, not more correct; noted as a residual.

Mutation-proven twice, independently: removing the etcAbsolutePathRe
evaluation and, separately, removing the IsPersistenceMarker special
case each fail all three real-technique test cases on their own --
both halves of the fix are independently load-bearing. Live CLI
validation confirms the benign nested-path case scores clean while a
real /etc/ write combined with a cradle still fires correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLd1shywzWPsLgkpLxEyPj
@MoSLoF
MoSLoF merged commit a4390a7 into main Aug 25, 2026
11 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.

2 participants