Skip to content

fix(rollback): capture and report silent filesystem restoration failures#535

Open
DebasmitaBose0 wants to merge 1 commit into
sreerevanth:mainfrom
DebasmitaBose0:fix/359-rollback-filesystem-failure
Open

fix(rollback): capture and report silent filesystem restoration failures#535
DebasmitaBose0 wants to merge 1 commit into
sreerevanth:mainfrom
DebasmitaBose0:fix/359-rollback-filesystem-failure

Conversation

@DebasmitaBose0

@DebasmitaBose0 DebasmitaBose0 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Closes #359

  • FilesystemSnapshot.restore() now extracts per-file with individual error handling and cleanup of partially extracted files
  • rollback() emits warnings when snapshot is missing instead of silently completing
  • RollbackResult gains warnings: list[str] and partial_restoration: bool fields
  • 4 tests in test_rollback.py
  • PR Body:

Summary

Fixes silently ignored failures during filesystem restoration in the Rollback Engine (#359).

Previously, if a snapshot tarball was missing, the rollback would silently mark
the operation as COMPLETED with no indication anything was wrong. Additionally,
if tar.extractall() partially extracted some files before failing, those files
remained on disk in an inconsistent state.

Key changes

  • agentwatch/rollback/engine.py:
    • FilesystemSnapshot.restore() now extracts files one at a time, catches
      per-member exceptions, and cleans up partially extracted files
    • rollback() emits a descriptive warning when snapshot_path is missing
      or was never created, rather than skipping silently
    • RollbackResult adds warnings: list[str] and partial_restoration: bool
  • tests/test_rollback.py — 4 new tests: missing snapshot warning, non-existent
    checkpoint, partial restoration tracking, result state verification

Summary by CodeRabbit

  • Bug Fixes
    • Rollback now reports warnings when a filesystem snapshot is missing instead of silently skipping restoration.
    • Improved rollback handling for partial filesystem restores, helping avoid inconsistent state after failures.
    • Rollback results now clearly indicate when restoration was only partially completed.

Addresses silently ignored failures during filesystem rollback.

- FilesystemSnapshot.restore() now extracts files one at a time,
  catches per-member failures, cleans up partially extracted files
  to prevent inconsistent state, and logs warnings on partial restore
- rollback() now emits a warning when snapshot_path is missing
  instead of silently marking rollback as COMPLETED
- RollbackResult gains 'warnings' and 'partial_restoration' fields
  for downstream visibility into degraded rollback outcomes
- 4 new tests covering missing snapshot, partial restoration tracking,
  and non-existent checkpoint handling

Closes sreerevanth#359
@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

@DebasmitaBose0 is attempting to deploy a commit to the sreerevanth's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

RollbackResult gains warnings and partial_restoration fields. Filesystem snapshot restoration switches from bulk tar extraction to per-member extraction that tracks failures, logs partial-restoration warnings, and cleans up partial files. RollbackEngine now records a warning when a filesystem snapshot is missing instead of silently skipping. Tests added for these behaviors.

Changes

Rollback Engine Warnings and Partial Restoration

Layer / File(s) Summary
Result fields and per-member restore tracking
agentwatch/rollback/engine.py
RollbackResult adds warnings and partial_restoration fields; FilesystemSnapshot.restore extracts tar members individually, tracks successes/failures, logs a partial-restoration warning, and deletes partially extracted files on failure.
Missing snapshot warning and tests
agentwatch/rollback/engine.py, tests/test_rollback.py
RollbackEngine.rollback now records/logs a warning when restore_filesystem is requested but the checkpoint's snapshot is missing; tests import new rollback primitives and cover missing snapshot warnings, missing checkpoint failures, and partial restoration tracking.

Estimated code review effort: 2 (Simple) | ~12 minutes

Possibly related PRs

  • sreerevanth/AgentWatch#229: Both PRs modify the filesystem snapshot restore logic in agentwatch/rollback/engine.py, overlapping in the same tar extraction code path.

Suggested labels: Hard

Poem

A tarball torn, a file gone missing,
No more silent skips — now warnings are hissing!
Per-member extracted, failures tracked with care,
Partial or whole, the truth we now share.
Hop hop hooray, this rabbit's review is done! 🐰📦

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Missing snapshot warnings are added, but rollback still completes successfully when no filesystem restore occurs. Return a non-COMPLETED status or explicit failure when restore_filesystem is requested but the snapshot is missing.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main rollback failure fix.
Out of Scope Changes check ✅ Passed The added partial-restore handling and tests are all directly related to filesystem rollback failure handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

🧪 PR Test Results

Check Result
Tests (pytest tests/) ❌ failure
Lint (ruff check .) ❌ failure
Coverage (agentwatch) n/a

Python 3.12 · commit 787f40e

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
tests/test_rollback.py (2)

47-68: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

This existing security test will likely fail against the updated restore() implementation.

test_malicious_archive_rejected expects pytest.raises(tarfile.FilterError) from restore(), but the new per-member extraction loop in agentwatch/rollback/engine.py catches all exceptions (including FilterError) and treats them as ordinary partial failures instead of re-raising. See the corresponding comment on agentwatch/rollback/engine.py (lines 123-169) for the root-cause fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rollback.py` around lines 47 - 68, The malicious archive test is
expecting tarfile.FilterError, but FilesystemSnapshot.restore is now swallowing
that exception inside the per-member extraction loop in restore() and treating
it as a partial failure. Update the restore() logic in
agentwatch/rollback/engine.py so tarfile.FilterError from the tar extraction
path is not caught by the generic exception handling, and instead is re-raised
immediately while still preserving the existing partial-failure behavior for
non-security extraction errors.

99-115: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Test doesn't exercise the real partial-restoration code path.

test_rollback_result_tracks_partial_restoration only validates a RollbackResult constructed manually by _simulate_partial_restoration(), not actual behavior from FilesystemSnapshot.restore() / RollbackEngine.rollback(). As noted on agentwatch/rollback/engine.py, the production code never sets partial_restoration=True for a real extraction failure, so this test provides false confidence. Once the root-cause fix is applied, add/adjust a test that triggers a genuine per-file extraction failure end-to-end.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rollback.py` around lines 99 - 115, The current rollback test is
only checking a manually constructed RollbackResult, so it never exercises the
real partial-restoration path. Update
test_rollback_result_tracks_partial_restoration to drive a genuine failure
through FilesystemSnapshot.restore() or RollbackEngine.rollback() instead of
_simulate_partial_restoration(), and assert that partial_restoration becomes
True from an actual per-file extraction failure. Use the existing
RollbackResult, RollbackStatus, and rollback engine/snapshot restore flow to
verify end-to-end behavior after the production fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agentwatch/rollback/engine.py`:
- Around line 123-169: The real partial-restoration state is never propagated
from FilesystemSnapshot.restore to RollbackEngine.rollback, so
result.partial_restoration and warnings stay unset for per-file extraction
failures. Update restore() to return or otherwise surface the partial failure
information alongside the restored file list, then wire that through rollback()
so a partial extract sets RollbackResult.partial_restoration=True and appends an
appropriate warning. Use the existing restore() and rollback() flow, plus
RollbackResult, as the main points to locate and connect the behavior.
- Around line 123-169: In restore(), the broad per-member exception handling is
swallowing tarfile.FilterError from tar.extract(..., filter="data"), which can
downgrade unsafe archives into partial restores. Update the _restore loop to
catch tarfile.FilterError separately and re-raise it immediately, while keeping
the existing partial-recovery handling only for non-security extraction
failures; use restore, _restore, and the tar.extract call as the key locations.

---

Duplicate comments:
In `@tests/test_rollback.py`:
- Around line 47-68: The malicious archive test is expecting
tarfile.FilterError, but FilesystemSnapshot.restore is now swallowing that
exception inside the per-member extraction loop in restore() and treating it as
a partial failure. Update the restore() logic in agentwatch/rollback/engine.py
so tarfile.FilterError from the tar extraction path is not caught by the generic
exception handling, and instead is re-raised immediately while still preserving
the existing partial-failure behavior for non-security extraction errors.
- Around line 99-115: The current rollback test is only checking a manually
constructed RollbackResult, so it never exercises the real partial-restoration
path. Update test_rollback_result_tracks_partial_restoration to drive a genuine
failure through FilesystemSnapshot.restore() or RollbackEngine.rollback()
instead of _simulate_partial_restoration(), and assert that partial_restoration
becomes True from an actual per-file extraction failure. Use the existing
RollbackResult, RollbackStatus, and rollback engine/snapshot restore flow to
verify end-to-end behavior after the production fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: edcb785f-a571-4272-9add-b8839bd499ad

📥 Commits

Reviewing files that changed from the base of the PR and between 2bb8ed2 and 787f40e.

📒 Files selected for processing (2)
  • agentwatch/rollback/engine.py
  • tests/test_rollback.py

Comment on lines +123 to +169
async def restore(
snapshot_path: Path,
target_path: Path,
) -> list[str]:
restored: list[str] = []
partial: list[str] = []

def _restore() -> None:
nonlocal restored, partial
extracted: list[str] = []
with tarfile.open(snapshot_path, "r:gz") as tar:
members = tar.getmembers()
tar.extractall(str(target_path), filter="data")
restored.extend([m.name for m in members if m.isfile()])
for member in members:
try:
tar.extract(member, str(target_path), filter="data")
if member.isfile():
extracted.append(member.name)
except Exception:
logger.warning(
"Failed to extract %s from snapshot %s",
member.name,
snapshot_path,
)
partial.append(member.name)
restored = extracted

await asyncio.get_event_loop().run_in_executor(None, _restore)
logger.info("Restored %d files from snapshot %s", len(restored), snapshot_path)

if partial:
logger.warning(
"Partially restored %d of %d files from snapshot %s (%d failures)",
len(restored),
len(restored) + len(partial),
snapshot_path,
len(partial),
)
# Remove partially extracted files to avoid inconsistent state
for fname in partial:
fpath = target_path / fname
try:
if fpath.exists():
fpath.unlink()
except Exception as exc:
logger.warning("Failed to clean up partial file %s: %s", fpath, exc)
else:
logger.info("Restored %d files from snapshot %s", len(restored), snapshot_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

partial_restoration and extraction-failure warnings are never actually populated by the real restore flow.

FilesystemSnapshot.restore() tracks partial failures internally and logs a warning, but its return type stays list[str] — no information about partial failures is returned to the caller. Correspondingly, rollback() (lines 362-376) only appends a warning / never sets result.partial_restoration for the "snapshot completely missing" case; there is no code path where a real per-file extraction failure sets result.partial_restoration = True or adds a warning to result.warnings.

This means the PR's stated goal — surfacing degraded/partial filesystem restoration through RollbackResult — is unmet for the actual partial-extraction scenario; it's only met for the fully-missing-snapshot scenario. This is corroborated by tests/test_rollback.py's test_rollback_result_tracks_partial_restoration, which only asserts against a manually constructed RollbackResult via _simulate_partial_restoration() rather than exercising FilesystemSnapshot.restore()/RollbackEngine.rollback() end-to-end.

Consider returning failure info from restore() and wiring it through rollback():

♻️ Proposed fix to propagate partial-restoration state
     `@staticmethod`
     async def restore(
         snapshot_path: Path,
         target_path: Path,
-    ) -> list[str]:
+    ) -> tuple[list[str], list[str]]:
         restored: list[str] = []
         partial: list[str] = []
         ...
-        return restored
+        return restored, partial
             if restore_filesystem:
                 if cp.snapshot_path and cp.snapshot_path.exists():
-                    restored = await FilesystemSnapshot.restore(
+                    restored, partial = await FilesystemSnapshot.restore(
                         snapshot_path=cp.snapshot_path,
                         target_path=cwd,
                     )
                     result.rolled_back_files = restored
+                    if partial:
+                        result.partial_restoration = True
+                        result.warnings.append(
+                            f"Failed to restore {len(partial)} file(s) from snapshot"
+                        )
                     logger.info("Restored %d files from snapshot", len(restored))

Also applies to: 362-376

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/rollback/engine.py` around lines 123 - 169, The real
partial-restoration state is never propagated from FilesystemSnapshot.restore to
RollbackEngine.rollback, so result.partial_restoration and warnings stay unset
for per-file extraction failures. Update restore() to return or otherwise
surface the partial failure information alongside the restored file list, then
wire that through rollback() so a partial extract sets
RollbackResult.partial_restoration=True and appends an appropriate warning. Use
the existing restore() and rollback() flow, plus RollbackResult, as the main
points to locate and connect the behavior.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Python 3.12 tarfile raise FilterError from TarFile.extract with filter="data" for path traversal members?

💡 Result:

In Python 3.12, while the 'data' filter is intended to provide protection against common security risks—including path traversal—it has been found to be insufficient against several bypass techniques [1][2]. Specifically, simply using filter="data" does not guarantee that a FilterError will be raised for all path traversal attempts. Multiple vulnerabilities have been identified (such as CVE-2025-4517 and CVE-2024-12718) where untrusted archives can bypass this filter, allowing for arbitrary filesystem writes or metadata modifications outside of the intended extraction directory [1][3][4]. Because of these security bypasses, Python versions 3.12.0 through 3.12.10 are considered affected [5]. The Python maintainers recommend upgrading to a patched version (e.g., 3.12.11 or later) to address these vulnerabilities [5]. If you cannot upgrade, it is recommended that you implement additional manual checks, such as verifying that no members contain insecure path segments (like "..") in their names or link targets, before proceeding with extraction [1][4]. While the FilterError exception exists in the tarfile module to signal that a filter has refused to extract a member, these security vulnerabilities demonstrate that the 'data' filter implementation can fail to detect or block all malicious path traversal vectors, thereby failing to raise the expected error [1][6][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- engine.py outline ---\n'
ast-grep outline agentwatch/rollback/engine.py --view expanded || true

printf '\n--- test_rollback.py outline ---\n'
ast-grep outline tests/test_rollback.py --view expanded || true

printf '\n--- relevant engine.py lines ---\n'
nl -ba agentwatch/rollback/engine.py | sed -n '100,190p'

printf '\n--- relevant test_rollback.py lines ---\n'
nl -ba tests/test_rollback.py | sed -n '1,120p'

Repository: sreerevanth/AgentWatch

Length of output: 251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import tarfile, io, tempfile, pathlib, os, traceback

def make_tar(path, member_name, data=b'X'):
    with tarfile.open(path, 'w:gz') as tar:
        info = tarfile.TarInfo(member_name)
        info.size = len(data)
        tar.addfile(info, io.BytesIO(data))

cases = [
    '../evil.txt',
    '/tmp/evil.txt',
    'safe/../../evil.txt',
    'safe.txt',
]

with tempfile.TemporaryDirectory() as td:
    td = pathlib.Path(td)
    for name in cases:
        p = td / ('a.tar.gz' if name == cases[0] else name.replace('/', '_').replace('..','dotdot') + '.tar.gz')
        try:
            make_tar(p, name)
        except Exception as e:
            print('make_tar failed', name, type(e).__name__, e)
            continue
        target = td / 'out'
        target.mkdir()
        with tarfile.open(p, 'r:gz') as tar:
            print(f'\nCASE {name!r}')
            for m in tar.getmembers():
                try:
                    tar.extract(m, str(target), filter="data")
                    print('  extract: ok', m.name)
                except Exception as e:
                    print('  extract:', type(e).__name__, e)
                    print('   bases:', [cls.__name__ for cls in type(e).__mro__[:4]])
PY

Repository: sreerevanth/AgentWatch

Length of output: 551


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import tarfile, io, tempfile, pathlib

def make_tar(path, member_name, data=b'X'):
    with tarfile.open(path, 'w:gz') as tar:
        info = tarfile.TarInfo(member_name)
        info.size = len(data)
        tar.addfile(info, io.BytesIO(data))

cases = ['../evil.txt', '/tmp/evil.txt', 'safe/../../evil.txt', 'safe.txt']

with tempfile.TemporaryDirectory() as td:
    td = pathlib.Path(td)
    for name in cases:
        p = td / (name.replace('/', '_').replace('..','dotdot') + '.tar.gz')
        make_tar(p, name)
        target = td / 'out'
        target.mkdir(exist_ok=True)
        with tarfile.open(p, 'r:gz') as tar:
            print(f'CASE {name!r}')
            for m in tar.getmembers():
                try:
                    tar.extract(m, str(target), filter="data")
                    print('  extract ok')
                except Exception as e:
                    print('  extract', type(e).__name__, str(e))
                    print('  mro', [c.__name__ for c in type(e).__mro__[:5]])
PY

Repository: sreerevanth/AgentWatch

Length of output: 811


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import tarfile, io, tempfile, pathlib, os

print('tarfile.FilterError exists:', hasattr(tarfile, 'FilterError'))
print('FilterError MRO:', [c.__name__ for c in tarfile.FilterError.__mro__[:6]])

def make_tar(path, member_name, data=b'X'):
    with tarfile.open(path, 'w:gz') as tar:
        info = tarfile.TarInfo(member_name)
        info.size = len(data)
        tar.addfile(info, io.BytesIO(data))

cases = ['../evil.txt', '/tmp/evil.txt', 'safe/../../evil.txt', 'safe.txt']

with tempfile.TemporaryDirectory() as td:
    td = pathlib.Path(td)
    for name in cases:
        p = td / (name.replace('/', '_').replace('..','dotdot') + '.tar.gz')
        make_tar(p, name)
        target = td / 'out'
        target.mkdir(exist_ok=True)
        with tarfile.open(p, 'r:gz') as tar:
            member = tar.getmembers()[0]
            print(f'CASE {name!r}: member={member.name!r}')
            try:
                tar.extract(member, str(target), filter='data')
                print('  result: ok')
            except Exception as e:
                print('  result:', type(e).__name__, e)
PY

Repository: sreerevanth/AgentWatch

Length of output: 361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- agentwatch/rollback/engine.py ---'
nl -ba agentwatch/rollback/engine.py | sed -n '1,240p'

echo
echo '--- tests/test_rollback.py ---'
nl -ba tests/test_rollback.py | sed -n '1,220p'

Repository: sreerevanth/AgentWatch

Length of output: 238


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for path in [Path('agentwatch/rollback/engine.py'), Path('tests/test_rollback.py')]:
    print(f'--- {path} ---')
    text = path.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        if 1 <= i <= 260:
            print(f'{i:4d}: {line}')
    print()
PY

Repository: sreerevanth/AgentWatch

Length of output: 14998


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for rel in ['agentwatch/rollback/engine.py', 'tests/test_rollback.py']:
    p = Path(rel)
    print(f'FILE {rel} exists={p.exists()} size={p.stat().st_size if p.exists() else "NA"}')
PY

echo
echo '--- grep for tarfile / malicious archive references ---'
grep -RIn --line-number -E 'malicious_archive_rejected|FilterError|filter="data"|tarfile' agentwatch tests || true

Repository: sreerevanth/AgentWatch

Length of output: 1234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for path in [Path('agentwatch/rollback/engine.py'), Path('tests/test_rollback.py')]:
    print(f'--- {path} ---')
    if not path.exists():
        print('missing')
        continue
    for i, line in enumerate(path.read_text().splitlines(), 1):
        if i <= 260:
            print(f'{i:4d}: {line}')
    print()
PY

echo '--- grep for tarfile / malicious archive references ---'
grep -RIn --line-number -E 'malicious_archive_rejected|FilterError|filter="data"|tarfile' agentwatch tests || true

Repository: sreerevanth/AgentWatch

Length of output: 15963


Re-raise tarfile.FilterError in restore() The per-member except Exception swallows the data filter’s security exception and turns a malicious archive into a partial restore. Catch tarfile.FilterError separately and re-raise it so unsafe snapshots still fail fast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/rollback/engine.py` around lines 123 - 169, In restore(), the
broad per-member exception handling is swallowing tarfile.FilterError from
tar.extract(..., filter="data"), which can downgrade unsafe archives into
partial restores. Update the _restore loop to catch tarfile.FilterError
separately and re-raise it immediately, while keeping the existing
partial-recovery handling only for non-security extraction failures; use
restore, _restore, and the tar.extract call as the key locations.

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.

[Feat] [ELUSOC] Silently Ignored Failures During Filesystem Restoration in the Rollback Engine

1 participant