Conversation
All four are wrong behavior with no error signal, each regression-proven. 1. classifier: quoting defeated the unsnapshotted-path guard. _hits_unsnapshotted_path split raw text, so `rm -rf ".git"` tokenized as `".git"`, missed _NOT_SNAPSHOTTED, and was classified reversible=True: it auto-ran with NO confirm and the ledger recorded it undoable, while .git is never snapshotted. Exactly the silent un-undoable surprise the module docstring forbids. Same-file divergence, since _mentions_outside_path already excludes quote chars. 2. tools/edit: no workspace containment. `edit` used a bare Path.write_text while write_file routes in-workspace writes through _safe_write_within_workspace, so editing an in-workspace symlink wrote THROUGH it to the target and left the link in place. It also reported the resolved target (_rel) instead of the path the caller named. Now mirrors write_file and reports via _rel_no_resolve. 3. rules/snapshots: documented glob skips were silent no-ops. OPENDOT.md advertises `skip: *.log`, but IgnoreRules.skipped was pure set membership, so the pattern only matched a file literally named `*.log`; real .log files were snapshotted and clobbered on restore. Adds an fnmatch pass for user glob entries; force_include still wins and literal defaults are unchanged. 4. cli: OPENDOT_MAX_USD / OPENDOT_MAX_TOKENS had no effect. The CLI always passed argparse's None, and an explicit None overrides AgentConfig's default_factory, so the factory never ran and the budget guards never fired. README documents these env vars as supplying the defaults. _build_agent now falls back to them when the flag is absent; the flag still wins. 390 passed, ruff clean. Bump to 0.4.3.
There was a problem hiding this comment.
🟡 Changes recommended
The unsnapshotted-path detection in the irreversibility classifier still has an operator-adjacent bypass (e.g., redirection without whitespace), which can incorrectly skip confirmation for destructive deletes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes several “silent contract violation” cases where opendot could incorrectly treat actions as undoable (or misapply documented defaults), and adds regression tests to prevent reintroduction.
Changes:
- Harden irreversibility classification for deletes of unsnapshotted paths when quoted.
- Make
edituse the same in-workspace write containment aswrite_file, and report the user-specified path (not a symlink target). - Make documented
skip:glob patterns actually apply, and ensure CLI budget defaults honorOPENDOT_MAX_USD/OPENDOT_MAX_TOKENSwhen flags are absent; bump version to 0.4.3.
File summaries
| File | Description |
|---|---|
| tests/test_tools.py | Adds regression tests for edit containment + reporting when editing a symlink path. |
| tests/test_rules.py | Adds tests proving skip: *.log matches filenames and snapshot: still wins. |
| tests/test_config.py | Adds tests verifying CLI budget defaults fall back to env vars and flags override them. |
| tests/test_classifier.py | Adds tests ensuring quoted unsnapshotted paths remain irreversible and quoted normal paths remain reversible. |
| src/opendot/tools/local.py | Routes in-workspace edit writes through _safe_write_within_workspace and reports via _rel_no_resolve. |
| src/opendot/reversibility/snapshots.py | Implements glob-aware user skip matching via fnmatch while preserving literal defaults and force_include. |
| src/opendot/reversibility/classifier.py | Updates _hits_unsnapshotted_path to avoid quote-based bypasses (needs one more hardening fix—see comment). |
| src/opendot/cli.py | Ensures CLI passes env-derived defaults for budget caps when flags are omitted (None). |
| src/opendot/init.py | Bumps __version__ to 0.4.3. |
| pyproject.toml | Bumps package version to 0.4.3. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Address review of #162: stripping quotes closed only one instance of the bug. Splitting on [\s/]+ still let any shell operator glued to the name hide it, so `rm -rf .git>out` was classified reversible=True and auto-ran with no confirm. (The ;/&&/| forms were caught only incidentally, because _split_segments splits those into segments first; redirection is not a segment splitter.) Adopt the sibling helper's character class instead of ad-hoc stripping, plus "/" so nested components are still seen: re.findall(r"[^\s/'\"|&;<>()]+", command). One consistent rule now covers quotes, redirections, pipes, separators and parens. Whole-component matching is preserved, so `mygitrepo` and `gitignore` stay reversible. Test extended to the operator forms (fails on the previous quote-only fix at `rm -rf .git>out`) plus a no-false-alarm case. 391 passed, ruff clean.
There was a problem hiding this comment.
🟢 Approval recommended
The fixes are narrowly scoped, align behavior with documented guarantees, and are backed by targeted tests that cover the previously silent failure modes.
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Lite
Four documented-contract violations found sweeping the codebase. Each is wrong behavior with no error signal, and each has a test that fails on the old code.
1. High — quoting defeated the unsnapshotted-path guard
_hits_unsnapshotted_pathsplit raw command text, so quote characters stayed glued to the token:rm -rf .gitrm -rf ".git"rm -rf 'node_modules'reversible=True means the confirm prompt is skipped entirely, the command runs, and
before_action(reversible=True)writes a ledger entry claiming it's undoable — but.gitis in the snapshot skip set, so nothing was captured andopendot undocan't restore it. That's precisely the "silent un-undoable surprise" the module docstring forbids.This was a same-file divergence: the sibling
_mentions_outside_pathalready tokenizes with[^\s'"|&;<>]+(quote-safe), and_first_wordusesshlex. Only this helper was quote-naive.2. High —
edithad no workspace containmenteditused a barePath.write_text, whilewrite_fileroutes in-workspace writes through_safe_write_within_workspace(documented: "if any path component is a symlink … the write does not follow it"). Identical input, opposite behavior:edit(before)write_filereal.txt(the target)link.txt(as asked)editis registered as an in-workspace mutating tool and documented "surgical, undoable", so it now mirrorswrite_fileand reports via_rel_no_resolve(the helper that exists for exactly this, and whichmovealready uses).3. Medium — documented glob skips were silent no-ops
OPENDOT.mdadvertisesskip: data, *.log, secrets/, butIgnoreRules.skippedwas pure set membership, so*.logonly matched a file named*.log. A user's.logfiles were snapshotted anyway — and, perrestore_snapshot, overwritten/deleted on restore. The existing test only asserted"*.log"landed inextra_skip, never that it matched, so it passed while the feature didn't work. Adds anfnmatchpass for glob-looking user entries;force_includestill wins and literal defaults are untouched.4. Medium —
OPENDOT_MAX_USD/OPENDOT_MAX_TOKENShad no effectAgentConfigreads them viadefault_factory, but the CLI always passes argparse's value —Nonewhen the flag is absent — and an explicitly-passedNoneoverridesdefault_factory, so the factory never runs:So
OPENDOT_MAX_USD=0.50 opendot -p "…"ran with no spend cap and no warning, while README:124 states the env vars supply the defaults. Fixed at the single chokepoint (_build_agent); the CLI flag still wins.Tests
8 new tests. All five behavioral ones fail on the pre-fix source (verified by stashing the source changes and re-running) and pass with the fix. 390 passed, ruff check + format clean. Bumped to 0.4.3.
Deliberately not included
Three further findings I left out rather than change behavior unilaterally, as they're design calls rather than clear contract violations:
write_filetakes a confirm-then-plain-write path when a symlink resolves outside the workspace, skipping the containment tiers. It is confirm-gated and honestly logged irreversible, so it's a broken-guarantee/false-test issue (the containment tests call the private helpers directly, bypassing the public gate) rather than a silent escape.grep --contextre-emits overlapping windows, duplicating lines on nearby matches; andmax_matches=0returns one match labelled "capped at 0".--sandboxaccepts--deny/--usd/--tokens/--api-basebut forwards none of them into the container.Happy to file these as issues.