Skip to content

Commit 5c7a585

Browse files
committed
fix: connect tty context and secure archive extraction
1 parent 445093a commit 5c7a585

8 files changed

Lines changed: 152 additions & 14 deletions

File tree

minicode/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ def main() -> None:
376376
resume_session=args.resume,
377377
list_sessions_only=args.list_sessions,
378378
memory_manager=memory_mgr,
379+
context_manager=context_mgr,
379380
)
380381
except KeyboardInterrupt:
381382
print("\n\nInterrupted by user. Shutting down gracefully...")

minicode/tools/archive_utils.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,55 @@
88
from pathlib import Path
99

1010
from minicode.tooling import ToolDefinition, ToolContext, ToolResult
11+
from minicode.workspace import resolve_tool_path
12+
13+
14+
def _resolve_archive_member(destination: Path, member_name: str) -> Path:
15+
normalized_name = member_name.replace("\\", "/")
16+
member_path = Path(normalized_name)
17+
if member_path.is_absolute() or any(part == ".." for part in member_path.parts):
18+
raise ValueError(f"Archive member escapes extraction destination: {member_name}")
19+
20+
destination_root = destination.resolve()
21+
target = (destination_root / member_path).resolve()
22+
try:
23+
target.relative_to(destination_root)
24+
except ValueError as error:
25+
raise ValueError(f"Archive member escapes extraction destination: {member_name}") from error
26+
return target
27+
28+
29+
def _safe_extract_zip(source: Path, destination: Path) -> None:
30+
destination.mkdir(parents=True, exist_ok=True)
31+
with zipfile.ZipFile(source, "r") as zf:
32+
for info in zf.infolist():
33+
target = _resolve_archive_member(destination, info.filename)
34+
if info.is_dir():
35+
target.mkdir(parents=True, exist_ok=True)
36+
continue
37+
target.parent.mkdir(parents=True, exist_ok=True)
38+
with zf.open(info, "r") as src, open(target, "wb") as dst:
39+
shutil.copyfileobj(src, dst)
40+
41+
42+
def _safe_extract_tar(source: Path, destination: Path) -> None:
43+
destination.mkdir(parents=True, exist_ok=True)
44+
with tarfile.open(source, "r:*") as tar:
45+
for member in tar.getmembers():
46+
if member.issym() or member.islnk():
47+
raise ValueError(f"Archive member uses unsupported link: {member.name}")
48+
target = _resolve_archive_member(destination, member.name)
49+
if member.isdir():
50+
target.mkdir(parents=True, exist_ok=True)
51+
continue
52+
if not member.isfile():
53+
continue
54+
target.parent.mkdir(parents=True, exist_ok=True)
55+
src = tar.extractfile(member)
56+
if src is None:
57+
continue
58+
with src, open(target, "wb") as dst:
59+
shutil.copyfileobj(src, dst)
1160

1261

1362
# ---------------------------------------------------------------------------
@@ -185,23 +234,20 @@ def _validate_tar_extract(input_data: dict) -> dict:
185234

186235

187236
def _run_tar_extract(input_data: dict, context: ToolContext) -> ToolResult:
188-
source = Path(context.cwd) / input_data["source"]
237+
source = resolve_tool_path(context, input_data["source"], "read")
189238
dest_dir = input_data.get("destination", "")
190239

191240
if not source.exists():
192241
return ToolResult(ok=False, output=f"Source not found: {input_data['source']}")
193242

194243
try:
195244
if dest_dir:
196-
destination = Path(context.cwd) / dest_dir
245+
destination = resolve_tool_path(context, dest_dir, "write")
197246
else:
198247
# Extract to same directory as archive
199248
destination = source.parent / source.stem
200249

201-
destination.mkdir(parents=True, exist_ok=True)
202-
203-
with tarfile.open(source, "r:*") as tar:
204-
tar.extractall(destination)
250+
_safe_extract_tar(source, destination)
205251

206252
return ToolResult(ok=True, output=f"Extracted to {destination}")
207253
except Exception as e:
@@ -291,22 +337,19 @@ def _validate_zip_extract(input_data: dict) -> dict:
291337

292338

293339
def _run_zip_extract(input_data: dict, context: ToolContext) -> ToolResult:
294-
source = Path(context.cwd) / input_data["source"]
340+
source = resolve_tool_path(context, input_data["source"], "read")
295341
dest_dir = input_data.get("destination", "")
296342

297343
if not source.exists():
298344
return ToolResult(ok=False, output=f"Source not found: {input_data['source']}")
299345

300346
try:
301347
if dest_dir:
302-
destination = Path(context.cwd) / dest_dir
348+
destination = resolve_tool_path(context, dest_dir, "write")
303349
else:
304350
destination = source.parent / source.stem
305351

306-
destination.mkdir(parents=True, exist_ok=True)
307-
308-
with zipfile.ZipFile(source, "r") as zf:
309-
zf.extractall(destination)
352+
_safe_extract_zip(source, destination)
310353

311354
return ToolResult(ok=True, output=f"Extracted to {destination}")
312355
except Exception as e:
@@ -326,4 +369,4 @@ def _run_zip_extract(input_data: dict, context: ToolContext) -> ToolResult:
326369
},
327370
validator=_validate_zip_extract,
328371
run=_run_zip_extract,
329-
)
372+
)

minicode/tty_app.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def run_tty_app(
6262
resume_session: str | None = None,
6363
list_sessions_only: bool = False,
6464
memory_manager: Any | None = None,
65+
context_manager: Any | None = None,
6566
) -> list[ChatMessage]:
6667
"""Event-driven full-screen TTY application, ported from the TypeScript version.
6768
@@ -74,7 +75,17 @@ def run_tty_app(
7475
return messages
7576

7677
session = load_or_create_session(cwd, resume_session)
77-
args, state = build_tty_runtime_state(runtime, tools, model, messages, cwd, permissions, session, memory_manager)
78+
args, state = build_tty_runtime_state(
79+
runtime,
80+
tools,
81+
model,
82+
messages,
83+
cwd,
84+
permissions,
85+
session,
86+
memory_manager,
87+
context_manager,
88+
)
7889

7990
# Throttled renderer: coalesces rapid rerender() calls to reduce flickering
8091
throttled = _ThrottledRenderer(lambda: _render_screen(args, state), min_interval=0.016)

minicode/tui/input_handler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import logging
44
import os
55
import sys
6+
import threading
67
import time
78
from typing import Any, Callable
89
from minicode.tui.input_parser import KeyEvent, ParsedInputEvent, TextEvent, WheelEvent, parse_input_chunk
910
from minicode.tui.state import ScreenState, TtyAppArgs
1011
from minicode.cli_commands import try_handle_local_command, find_matching_slash_commands
1112
from minicode.agent_loop import run_agent_turn
13+
from minicode.context_manager import save_context_state
1214
from minicode.history import save_history_entries
1315
from minicode.local_tool_shortcuts import parse_local_tool_shortcut
1416
from minicode.prompt import build_system_prompt
@@ -579,8 +581,13 @@ def _run_agent_background():
579581
on_assistant_message=on_assistant_message,
580582
on_progress_message=on_progress_message,
581583
on_assistant_stream_chunk=on_assistant_stream_chunk,
584+
store=state.app_state,
585+
context_manager=args.context_manager,
582586
runtime=args.runtime,
583587
)
588+
if args.context_manager is not None:
589+
args.context_manager.messages = next_messages
590+
save_context_state(args.context_manager)
584591
with agent_thread_lock:
585592
agent_result["messages"] = next_messages
586593
except Exception as e:

minicode/tui/session_flow.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def build_tty_runtime_state(
6767
permissions: PermissionManager,
6868
session: SessionData,
6969
memory_manager: Any | None = None,
70+
context_manager: Any | None = None,
7071
) -> tuple[TtyAppArgs, ScreenState]:
7172
args = TtyAppArgs(
7273
runtime=runtime,
@@ -76,6 +77,7 @@ def build_tty_runtime_state(
7677
cwd=cwd,
7778
permissions=permissions,
7879
memory_manager=memory_manager,
80+
context_manager=context_manager,
7981
)
8082

8183
state = ScreenState(

minicode/tui/state.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class TtyAppArgs:
2121
cwd: str
2222
permissions: PermissionManager
2323
memory_manager: Any | None = None
24+
context_manager: Any | None = None
2425

2526

2627
@dataclass

tests/test_tools.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from pathlib import Path
2+
import io
23
import sys
4+
import tarfile
5+
import zipfile
36

47
import pytest
58

69
import minicode.tools.run_command as run_command_module
710
from minicode.permissions import PermissionManager
811
from minicode.tools.run_command import _build_execution_command, split_command_line
912
from minicode.tools.patch_file import patch_file_tool
13+
from minicode.tools.archive_utils import tar_extract_tool, zip_extract_tool
1014
from minicode.tools.run_command import run_command_tool
1115
from minicode.tools.write_file import write_file_tool
1216
from minicode.tooling import ToolContext
@@ -141,6 +145,39 @@ def test_full_tool_registry_can_opt_into_utility_wrappers(tmp_path: Path) -> Non
141145
assert "csv_parse" in names
142146

143147

148+
def test_zip_extract_rejects_entries_that_escape_destination(tmp_path: Path) -> None:
149+
archive = tmp_path / "evil.zip"
150+
with zipfile.ZipFile(archive, "w") as zf:
151+
zf.writestr("../escape.txt", "owned")
152+
153+
result = zip_extract_tool.run(
154+
{"source": "evil.zip", "destination": "out"},
155+
ToolContext(cwd=str(tmp_path), permissions=None),
156+
)
157+
158+
assert result.ok is False
159+
assert "escapes extraction destination" in result.output
160+
assert not (tmp_path / "escape.txt").exists()
161+
162+
163+
def test_tar_extract_rejects_entries_that_escape_destination(tmp_path: Path) -> None:
164+
archive = tmp_path / "evil.tar"
165+
payload = b"owned"
166+
info = tarfile.TarInfo("../escape.txt")
167+
info.size = len(payload)
168+
with tarfile.open(archive, "w") as tf:
169+
tf.addfile(info, io.BytesIO(payload))
170+
171+
result = tar_extract_tool.run(
172+
{"source": "evil.tar", "destination": "out"},
173+
ToolContext(cwd=str(tmp_path), permissions=None),
174+
)
175+
176+
assert result.ok is False
177+
assert "escapes extraction destination" in result.output
178+
assert not (tmp_path / "escape.txt").exists()
179+
180+
144181
def test_core_tool_registry_does_not_import_utility_modules(tmp_path: Path) -> None:
145182
utility_modules = [
146183
"minicode.tools.archive_utils",

tests/test_tty_app.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
summarize_tool_input,
88
summarize_tool_output,
99
)
10+
import minicode.tui.input_handler as input_handler_module
11+
from minicode.context_manager import ContextManager
1012
from minicode.permissions import PermissionManager
13+
from minicode.tooling import ToolRegistry
1114
from minicode.tui.runtime_control import _ThrottledRenderer as RuntimeThrottledRenderer
1215
from minicode.tui.event_flow import _handle_event
1316
from minicode.tui.input_parser import KeyEvent
@@ -147,3 +150,36 @@ def handle_input(*_args, **_kwargs):
147150

148151
assert "handle_input" not in calls
149152
assert state.input == ""
153+
154+
155+
def test_tty_input_passes_and_persists_context_manager(tmp_path, monkeypatch) -> None:
156+
captured: dict = {}
157+
saved: list[ContextManager] = []
158+
context_manager = ContextManager(model="default", context_window=1000)
159+
160+
def fake_run_agent_turn(**kwargs):
161+
captured.update(kwargs)
162+
manager = kwargs["context_manager"]
163+
manager.messages = list(kwargs["messages"])
164+
return [*kwargs["messages"], {"role": "assistant", "content": "done"}]
165+
166+
monkeypatch.setattr(input_handler_module, "run_agent_turn", fake_run_agent_turn)
167+
monkeypatch.setattr(input_handler_module, "save_context_state", saved.append, raising=False)
168+
169+
state = ScreenState(input="Please inspect context", cursor_offset=22)
170+
args = TtyAppArgs(
171+
runtime={"model": "default"},
172+
tools=ToolRegistry([]),
173+
model=object(),
174+
messages=[{"role": "system", "content": "sys"}],
175+
cwd=str(tmp_path),
176+
permissions=PermissionManager(str(tmp_path)),
177+
context_manager=context_manager,
178+
)
179+
180+
assert input_handler_module._handle_input(args, state, lambda: None) is False
181+
state.agent_thread.join(timeout=5)
182+
183+
assert captured["context_manager"] is context_manager
184+
assert saved == [context_manager]
185+
assert state.agent_result["messages"][-1] == {"role": "assistant", "content": "done"}

0 commit comments

Comments
 (0)