Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.10.0] - 2026-01-06

### Features

- **Deletion tracking**: `shutil.rmtree()` and file deletions are now queued for approval
- Full support for fd-based directory operations (fdopendir, dirfd, unlinkat)
- Deletions displayed in approve TUI with file/directory counts
- Audit logging for deletion events
- Add PyPy sandbox support for macOS ARM64
- Add slash commands for changelog, pr, and release skills
- Mount home directory read-only in sandbox VFS

### Enhancements

- Add `SHANNOT_SANDBOX=1` environment variable for sandbox detection by scripts
- Add darwin/arm64 struct sizes to validation
- Add `check_output` to subprocess stub for `platform.node()` support
- Add `pwd` stub and populate environ with HOME/USER for `expanduser()` support
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "shannot"
version = "0.9.4"
version = "0.10.0"
description = "Sandboxed system administration for LLM agents"
readme = "README.md"
license = {text = "Apache-2.0"}
Expand Down
133 changes: 133 additions & 0 deletions shannot/approve.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,16 @@ def render(self) -> None:

cmd_count = len(session.commands)
write_count = len(session.pending_writes)
delete_count = len(session.pending_deletions)
date = session.created_at[:10]
name = session.name[:30]

# Show counts
counts = f"{cmd_count:>2} cmds"
if write_count:
counts += f", {write_count} writes"
if delete_count:
counts += f", {delete_count} deletes"

# Show remote target if present
remote_tag = ""
Expand Down Expand Up @@ -294,10 +297,41 @@ def render(self) -> None:
if len(s.pending_writes) > 3:
print(f" ... ({len(s.pending_writes) - 3} more)")

# Show pending deletions summary (collapsed by directory)
if s.pending_deletions:
from .pending_deletion import format_size, summarize_deletions

print()
total_size = sum(d.get("size", 0) for d in s.pending_deletions)
count = len(s.pending_deletions)
print(f" \033[1mDeletions ({count} items, {format_size(total_size)}):\033[0m")

# Group by root directory
summaries = summarize_deletions(s.pending_deletions)
for i, summary in enumerate(summaries[:3]):
root = summary["root"]
file_count = summary["file_count"]
dir_count = summary["dir_count"]
size = summary["total_size"]

parts = []
if file_count:
parts.append(f"{file_count} files")
if dir_count:
parts.append(f"{dir_count} dirs")
detail = ", ".join(parts)

size_str = format_size(size)
print(f" {i + 1:>3}. \033[31mDELETE\033[0m {root} ({detail}, {size_str})")
if len(summaries) > 3:
print(f" ... ({len(summaries) - 3} more directories)")

print()
help_text = " \033[90m[Up/Down] scroll [v] view script"
if s.pending_writes:
help_text += " [w] view writes"
if s.pending_deletions:
help_text += " [d] view deletes"
help_text += " [x] execute [r] reject [Esc] back\033[0m"
print(help_text)

Expand All @@ -321,6 +355,9 @@ def handle_key(self, key: str) -> Action | View | None:
elif key == "w" and self.session.pending_writes:
return PendingWritesListView(self.session)

elif key == "d" and self.session.pending_deletions:
return PendingDeletionsListView(self.session)

elif key == "x":
return Action("execute", [self.session])

Expand Down Expand Up @@ -479,6 +516,84 @@ def handle_key(self, key: str) -> Action | View | None:
return None


# ==============================================================================
# Pending Deletions List View
# ==============================================================================


class PendingDeletionsListView(View):
"""List of pending file/directory deletions for a session."""

def __init__(self, session: Session):
self.session = session
self.cursor = 0

def render(self) -> None:
clear_screen()
rows, cols = get_terminal_size()

from .pending_deletion import format_size

total_size = sum(d.get("size", 0) for d in self.session.pending_deletions)
print(f"\033[1m Pending Deletions: {self.session.name} ({format_size(total_size)}) \033[0m")
print()

if not self.session.pending_deletions:
print(" No pending deletions.")
print()
print(" \033[90m[Esc] back\033[0m")
return

visible_rows = rows - 8
if visible_rows < 3:
visible_rows = 3

start = max(0, self.cursor - visible_rows // 2)
visible = self.session.pending_deletions[start : start + visible_rows]

for i, del_data in enumerate(visible):
idx = start + i
pointer = "\033[36m>\033[0m" if idx == self.cursor else " "
path = del_data.get("path", "?")
target_type = del_data.get("target_type", "file")
size = del_data.get("size", 0)
remote = "\033[33m[R]\033[0m" if del_data.get("remote") else " "

type_icon = "DIR" if target_type == "directory" else " "
size_str = format_size(size) if size > 0 else ""

display_path = path[: cols - 35]
if len(path) > cols - 35:
display_path += "..."

line = f" {pointer} \033[31mDEL\033[0m {type_icon} {remote} {display_path:<50}"
print(f"{line} {size_str:>10}")

print()
remaining = len(self.session.pending_deletions) - start - len(visible)
if remaining > 0:
print(f" ... and {remaining} more")
print()
print(" \033[90m[Up/Down] scroll [Esc] back\033[0m")

def handle_key(self, key: str) -> Action | View | None:
if not self.session.pending_deletions:
if key in ("b", "\x1b"):
return Action("back")
return None

if key in ("b", "\x1b"):
return Action("back")

elif key in ("j", "\x1b[B"):
self.cursor = min(self.cursor + 1, len(self.session.pending_deletions) - 1)

elif key in ("k", "\x1b[A"):
self.cursor = max(self.cursor - 1, 0)

return None


# ==============================================================================
# Pending Write Diff View
# ==============================================================================
Expand Down Expand Up @@ -569,6 +684,8 @@ def render(self) -> None:
counts = f"{len(s.commands)} commands"
if s.pending_writes:
counts += f", {len(s.pending_writes)} writes"
if s.pending_deletions:
counts += f", {len(s.pending_deletions)} deletes"
print(f" - {s.name} ({counts})")

print()
Expand Down Expand Up @@ -680,6 +797,22 @@ def _build_lines(self) -> list[str]:
lines.append(f"✗ {path} ({error})")
lines.append("")

# Show completed deletions (if any)
if self.session.completed_deletions:
lines.append("--- deletions ---")
for del_info in self.session.completed_deletions:
path = del_info.get("path", "")
if del_info.get("skipped"):
continue # Don't show skipped items
if del_info.get("success"):
target_type = del_info.get("target_type", "file")
type_label = " [dir]" if target_type == "directory" else ""
lines.append(f"✓ {path}{type_label}")
else:
error = del_info.get("error", "unknown")
lines.append(f"✗ {path} ({error})")
lines.append("")

lines.append("--- stdout ---")
if self.session.stdout:
lines.extend(self.session.stdout.split("\n"))
Expand Down
27 changes: 27 additions & 0 deletions shannot/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"command_decision",
"file_write_queued",
"file_write_executed",
"file_deletion_queued",
"file_deletion_executed",
"approval_decision",
"execution_started",
"execution_completed",
Expand Down Expand Up @@ -224,6 +226,7 @@ def log_session_created(session: Session) -> None:
"script_path": session.script_path,
"commands_count": len(session.commands),
"writes_count": len(session.pending_writes),
"deletions_count": len(session.pending_deletions),
},
target=session.target,
)
Expand All @@ -238,6 +241,7 @@ def log_session_loaded(session: Session) -> None:
"status": session.status,
"commands_count": len(session.commands),
"writes_count": len(session.pending_writes),
"deletions_count": len(session.pending_deletions),
},
target=session.target,
)
Expand Down Expand Up @@ -300,6 +304,28 @@ def log_file_write_queued(
)


def log_file_deletion_queued(
session_id: str | None,
path: str,
target_type: str,
size_bytes: int,
remote: bool,
target: str | None = None,
) -> None:
"""Log file/directory deletion queueing event."""
get_logger().log(
"file_deletion_queued",
session_id,
{
"path": path,
"target_type": target_type,
"size_bytes": size_bytes,
"remote": remote,
},
target=target,
)


def log_approval_decision(
sessions: list[Session],
action: Literal["approved", "rejected"],
Expand All @@ -326,6 +352,7 @@ def log_execution_started(session: Session) -> None:
{
"commands_to_execute": len(session.commands),
"writes_to_execute": len(session.pending_writes),
"deletions_to_execute": len(session.pending_deletions),
},
target=session.target,
)
Expand Down
1 change: 1 addition & 0 deletions shannot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ def cmd_run_remote(args: argparse.Namespace) -> int:
print(f" Target: {args.target}")
print(f" Commands queued: {len(session.commands)}")
print(f" File writes queued: {len(session.pending_writes)}")
print(f" Deletions queued: {len(session.pending_deletions)}")
print(" Run 'shannot approve' to review and execute.")
return 0
else:
Expand Down
5 changes: 4 additions & 1 deletion shannot/interact.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@
if len(arguments) < 1:
return help()

class SandboxedProc(
MixRemote, MixSubprocess, MixPyPy, MixVFS, MixDumpOutput, MixAcceptInput, VirtualizedProc
):

Check warning

Code scanning / CodeQL

Conflicting attributes in base classes Warning

Base classes have conflicting values for attribute 'file_writes_pending':
List
and
List
.
Base classes have conflicting values for attribute 'file_deletions_pending':
List
and
List
.
Base classes have conflicting values for attribute 's_rewinddir':
Function s_rewinddir
and
signature()()
.
virtual_cwd = "/tmp"
vfs_root = Dir({"tmp": Dir({})})

Expand Down Expand Up @@ -99,10 +99,12 @@
elif option == "--dry-run":
SandboxedProc.subprocess_dry_run = True
SandboxedProc.vfs_track_writes = True # Track file writes for approval
SandboxedProc.vfs_track_deletions = True # Track file deletions for approval
elif option == "--session-id":
session_id = value
# Enable write tracking for session execution (writes committed after)
# Enable tracking for session execution (writes/deletions committed after)
SandboxedProc.vfs_track_writes = True
SandboxedProc.vfs_track_deletions = True
elif option == "--script-name":
SandboxedProc.subprocess_script_name = value # type: ignore[misc]
sandbox_args["script_name"] = value
Expand Down Expand Up @@ -319,6 +321,7 @@
print(f"\n*** Session created: {session.id} ***")
print(f" Commands queued: {len(session.commands)}")
print(f" File writes queued: {len(session.pending_writes)}")
print(f" Deletions queued: {len(session.pending_deletions)}")
print(" Run 'shannot approve' to review and execute.")
else:
print("\n*** No commands or writes were queued. ***")
Expand Down
21 changes: 18 additions & 3 deletions shannot/mix_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class MixSubprocess:
subprocess_pending = [] # Commands awaiting approval
subprocess_approved = set() # Commands approved this session
file_writes_pending = [] # File writes awaiting approval
file_deletions_pending = [] # File/dir deletions awaiting approval

# Execution tracking (populated during execution, NOT dry-run)
# Note: Use _get_executed_commands() to access - ensures instance-level list
Expand Down Expand Up @@ -277,14 +278,18 @@ def save_pending(self):

def finalize_session(self):
"""
Create a Session from queued commands and writes after script completes.
Create a Session from queued commands, writes, and deletions.

Call this at the end of a dry-run execution to bundle all
queued commands and file writes into a reviewable session.
queued operations into a reviewable session.

Returns the created Session, or None if nothing was queued.
"""
if not self.subprocess_pending and not self.file_writes_pending:
if (
not self.subprocess_pending
and not self.file_writes_pending
and not self.file_deletions_pending
):
return None

from .session import create_session
Expand All @@ -297,6 +302,14 @@ def finalize_session(self):
elif isinstance(write, dict):
pending_write_dicts.append(write)

# Convert PendingDeletion objects to dicts for serialization
pending_deletion_dicts = []
for deletion in self.file_deletions_pending:
if hasattr(deletion, "to_dict"):
pending_deletion_dicts.append(deletion.to_dict())
elif isinstance(deletion, dict):
pending_deletion_dicts.append(deletion)

session = create_session(
script_path=self.subprocess_script_path or "<unknown>",
commands=list(self.subprocess_pending),
Expand All @@ -305,10 +318,12 @@ def finalize_session(self):
analysis=self.subprocess_analysis or "",
sandbox_args=self.subprocess_sandbox_args,
pending_writes=pending_write_dicts,
pending_deletions=pending_deletion_dicts,
)

self.subprocess_pending.clear()
self.file_writes_pending.clear()
self.file_deletions_pending.clear()
return session

def load_session_commands(self, session):
Expand Down
Loading
Loading