Summary
Non-glob protected path entries are matched with a raw String.includes() against the full path. Any directory pattern whose name appears as a substring of another directory name triggers false positives. In particular, the built-in out/ rule matches every path under a user's /home/lOGOUT/ home directory, so legitimate file writes get blocked with DAMAGE_CONTROL_BLOCKED: Cannot write protected path "out/".
Environment
- opencode-damage-control: 1.5.0 (same code on current
main)
- opencode: 1.18.25 (Linux, x86_64)
- OS: Ubuntu (Linux)
Real-world impact
opencode's plan mode writes plan files under either <repo>/.opencode/plans/<ts>-<slug>.md or ~/.local/share/opencode/plans/<ts>-<slug>.md. On a machine whose username is logout (/home/logout/...), every single plan-file write was rejected:
DAMAGE_CONTROL_BLOCKED: Cannot write protected path "out/"
Protection level: readOnly
Observed in practice: 9 consecutive blocked writes within 25 minutes (with the agent retrying after each block), all targeting paths like:
/home/logout/.local/share/opencode/plans/1787824560041-hidden-comet.md
/home/logout/projets/logout/worms/.opencode/plans/1787836297164-mighty-comet.md
/home/logout/projets/gitlab/6admin.io/blog/.opencode/plans/1787824560041-hidden-comet.md
The only escape currently is disabling the rule entirely via paths.override: { "out/": "none" }, which removes a legitimate guardrail (writes to actual out/ build directories are no longer protected at all).
Root cause
src/patterns.ts, checkPathProtection() (non-glob branch):
// Non-glob: expand home and do prefix/substring matching
const expandedPath = p.path.replace('~', home);
if (filePath.includes(expandedPath) || filePath.includes(p.path)) {
return p;
}
filePath.includes("out/") is a raw substring test: "…/home/lOGOUT/.local/…" contains out/, so the rule fires. The doc comment above the function says directory patterns should "check if filePath starts with or contains the dir", which reads as segment-boundary semantics, but the implementation is plain substring matching.
The same flaw exists in commandReferencesPath() (shell-command matching), so rm -rf ~/logout-proj/dist would also be flagged for the dist/ rule.
Affected built-in directory rules include at least: dist/, build/, out/, venv/, target/, .git/, .github/, plus any user-defined trailing-slash entry. Examples of colliding real-world directory names: logout/, about/, output-logs/ (contains out/? no, but pour/, thought/, without/ do), distance/, builders/, convenience/, objective/.
Minimal repro
import { checkPathProtection, DEFAULT_PROTECTED_PATHS } from "./dist/patterns.js";
// false positive: matches the built-in out/ rule
console.log(checkPathProtection("/home/logout/.local/share/opencode/plans/plan.md", DEFAULT_PROTECTED_PATHS));
// => { path: 'out/', level: 'readOnly' }
// expected: null
Proposed fix
For directory patterns (entries ending with /), match on path-segment boundaries instead of substrings:
if (p.path.endsWith('/')) {
const dir = expandedPath.replace(/\/+$/, '');
const normalized = filePath.replace(/\/+$/, '');
if (normalized === dir || normalized.startsWith(dir + '/') || normalized.includes('/' + dir + '/')) {
return p;
}
continue;
}
"/home/logout/x" no longer matches out/ (no /out/ segment)
"/project/out/a.js" and relative "out/a.js" still match
- literal entries without a trailing slash (
~/.ssh, ~/.config/opencode) and glob entries (.env*, *.pem) keep their current semantics
The identical segment-boundary logic should be applied to commandReferencesPath() for non-glob entries.
Happy to open a PR with the fix + tests if you are open to it.
Summary
Non-glob protected path entries are matched with a raw
String.includes()against the full path. Any directory pattern whose name appears as a substring of another directory name triggers false positives. In particular, the built-inout/rule matches every path under a user's/home/lOGOUT/home directory, so legitimate file writes get blocked withDAMAGE_CONTROL_BLOCKED: Cannot write protected path "out/".Environment
main)Real-world impact
opencode's plan mode writes plan files under either
<repo>/.opencode/plans/<ts>-<slug>.mdor~/.local/share/opencode/plans/<ts>-<slug>.md. On a machine whose username islogout(/home/logout/...), every single plan-file write was rejected:Observed in practice: 9 consecutive blocked writes within 25 minutes (with the agent retrying after each block), all targeting paths like:
/home/logout/.local/share/opencode/plans/1787824560041-hidden-comet.md/home/logout/projets/logout/worms/.opencode/plans/1787836297164-mighty-comet.md/home/logout/projets/gitlab/6admin.io/blog/.opencode/plans/1787824560041-hidden-comet.mdThe only escape currently is disabling the rule entirely via
paths.override: { "out/": "none" }, which removes a legitimate guardrail (writes to actualout/build directories are no longer protected at all).Root cause
src/patterns.ts,checkPathProtection()(non-glob branch):filePath.includes("out/")is a raw substring test:"…/home/lOGOUT/.local/…"containsout/, so the rule fires. The doc comment above the function says directory patterns should "check iffilePathstarts with or contains the dir", which reads as segment-boundary semantics, but the implementation is plain substring matching.The same flaw exists in
commandReferencesPath()(shell-command matching), sorm -rf ~/logout-proj/distwould also be flagged for thedist/rule.Affected built-in directory rules include at least:
dist/,build/,out/,venv/,target/,.git/,.github/, plus any user-defined trailing-slash entry. Examples of colliding real-world directory names:logout/,about/,output-logs/(containsout/? no, butpour/,thought/,without/do),distance/,builders/,convenience/,objective/.Minimal repro
Proposed fix
For directory patterns (entries ending with
/), match on path-segment boundaries instead of substrings:"/home/logout/x"no longer matchesout/(no/out/segment)"/project/out/a.js"and relative"out/a.js"still match~/.ssh,~/.config/opencode) and glob entries (.env*,*.pem) keep their current semanticsThe identical segment-boundary logic should be applied to
commandReferencesPath()for non-glob entries.Happy to open a PR with the fix + tests if you are open to it.