Skip to content

Commit 32108f0

Browse files
committed
fix: address CodeRabbit review findings on the security-remediation diff
Critical permission-classifier bypasses: - sudo long value-options (`sudo --user alice rm -rf /`) were not consumed, so the wrapped destructive payload classified as the option's value. Add the long forms to _SUDO_VALUE_OPTS. - uv global options before `run` (`uv --directory repo run rm -rf /`) hid the subcommand; the global flag's value was mistaken for it. Add _uv_strip_global_opts and apply it in both mutation and destructive paths. Major: - file_restore: treat missing (None) or malformed-base64 content as a corrupt restore point instead of silently writing an empty/garbage file. - web/fetch: _ip_is_blocked now fails closed (blocks) on an unparseable address. - scratchpad: stop unlinking the advisory lock file (split-inode race); keep it persistent and add *.scratchpad.lock to the written .gitignore patterns. - soul shutdown: don't re-await an already-finished task in the cleanup loop — retrieve its exception without re-raising so the rest of shutdown still runs. - pythinkersoul: on mid-tool interruption, keep the real results of calls that already completed (captured via on_tool_result) and only synthesize the interruption marker for still-pending calls. Minor / nitpick: - /import arg parsing now uses shlex via a shared parse_import_args helper (soul/slash + ui/shell/export_import), preserving quoted paths. - web/runner: offload the blocking wire-file stat with asyncio.to_thread; document the intentional broad except at the per-message dispatch boundary. - cli/vis: rename unused callback param to _ctx. - tests: regression cases for both bypasses; strengthened import token-count assertions; lock-file persistence test; minor annotations.
1 parent a39d29b commit 32108f0

16 files changed

Lines changed: 160 additions & 37 deletions

File tree

src/pythinker_code/cli/vis.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
@cli.callback(invoke_without_command=True)
1414
def vis(
15-
ctx: typer.Context,
15+
_ctx: typer.Context,
1616
host: Annotated[
1717
str | None,
1818
typer.Option("--host", "-H", help="Bind to specific IP address"),

src/pythinker_code/file_restore.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import base64
4+
import binascii
45
import re
56
import time
67
import uuid
@@ -92,6 +93,16 @@ def restore_file_restore_point(session: Session, restore_id: str) -> FileRestore
9293
if not point.existed:
9394
point.path.unlink(missing_ok=True)
9495
return point
96+
# An existed-file restore must carry its content. Missing content (None) or
97+
# malformed base64 is a corrupt restore point — fail loudly instead of
98+
# silently writing an empty/garbage file. An empty string is a legitimately
99+
# empty file and decodes to b"".
100+
if point.content_b64 is None:
101+
raise FileNotFoundError(f"Corrupt restore point: {restore_id}")
102+
try:
103+
content = base64.b64decode(point.content_b64, validate=True)
104+
except (binascii.Error, ValueError) as exc:
105+
raise FileNotFoundError(f"Corrupt restore point: {restore_id}") from exc
95106
point.path.parent.mkdir(parents=True, exist_ok=True)
96-
point.path.write_bytes(base64.b64decode(point.content_b64 or ""))
107+
point.path.write_bytes(content)
97108
return point

src/pythinker_code/scratchpad.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,14 @@
3434

3535
# Patterns written to the project .gitignore when the agent starts in a git repo.
3636
# These directories are local-only agent state and must never be committed.
37-
_GITIGNORE_ENTRIES = (".pythinker/", ".pythinker-review/", ".pythinker-review-flow/")
37+
_GITIGNORE_ENTRIES = (
38+
".pythinker/",
39+
".pythinker-review/",
40+
".pythinker-review-flow/",
41+
# The advisory lock file is kept on disk for correct flock coordination, so
42+
# ignore it rather than letting it dirty the project's working tree.
43+
"*.scratchpad.lock",
44+
)
3845
_GITIGNORE_SECTION_HEADER = "# pythinker — local agent state (do not commit)"
3946

4047
StatusReason = Literal[
@@ -434,9 +441,11 @@ def _exclude_lock(path: Path) -> Generator[None]:
434441
with contextlib.suppress(OSError):
435442
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
436443
finally:
444+
# Keep the lock file on disk: unlinking it lets a concurrent writer
445+
# create a fresh inode and acquire a second, independent flock, so the
446+
# two writers would no longer be serialized. Releasing the flock (above)
447+
# and closing the handle is enough.
437448
fh.close()
438-
with contextlib.suppress(OSError):
439-
lock_file.unlink()
440449

441450

442451
async def _append_gitignore_entries(work_dir: HostPath) -> None:

src/pythinker_code/soul/__init__.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,19 @@ async def run_soul(
238238
soul_task.result() # this will raise if any exception was raised in the run task
239239
finally:
240240
for task in (soul_task, cancel_event_task, notification_task):
241-
if task is not None:
242-
task.cancel()
243-
with contextlib.suppress(asyncio.CancelledError):
244-
await task
241+
if task is None:
242+
continue
243+
if task.done():
244+
# Already finished (e.g. soul_task raised and was surfaced above).
245+
# Retrieve any exception so it isn't flagged "never retrieved", but
246+
# do not re-await/re-raise — that would abort the rest of shutdown
247+
# (notification flush, wire.shutdown/join) and leak UI resources.
248+
if not task.cancelled():
249+
task.exception()
250+
continue
251+
task.cancel()
252+
with contextlib.suppress(asyncio.CancelledError):
253+
await task
245254
try:
246255
await _deliver_notifications_to_wire_once(runtime, wire)
247256
except Exception:

src/pythinker_code/soul/permission.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,34 @@ class PermissionProfile:
212212
_GIT_NETWORK = {"clone", "fetch", "ls-remote"}
213213
_WRAPPER_COMMANDS = {"command", "env", "nohup", "sudo", "time"}
214214
# sudo options that consume a following separate word (the value is NOT the command).
215-
_SUDO_VALUE_OPTS = {"-u", "-g", "-U", "-C", "-p", "-r", "-t", "-T", "-h", "-R", "-D"}
215+
_SUDO_VALUE_OPTS = {
216+
# Short value-taking options.
217+
"-u",
218+
"-g",
219+
"-U",
220+
"-C",
221+
"-p",
222+
"-r",
223+
"-t",
224+
"-T",
225+
"-h",
226+
"-R",
227+
"-D",
228+
# Long forms with a space-separated value (e.g. ``sudo --user alice rm``).
229+
# The ``--opt=value`` form carries its value inline, so it is consumed as a
230+
# single token and needs no entry here.
231+
"--user",
232+
"--group",
233+
"--other-user",
234+
"--close-from",
235+
"--prompt",
236+
"--role",
237+
"--type",
238+
"--command-timeout",
239+
"--host",
240+
"--chroot",
241+
"--chdir",
242+
}
216243
# GNU time options that consume a following separate word.
217244
_TIME_VALUE_OPTS = {"-o", "-f", "--output", "--format"}
218245

@@ -463,10 +490,11 @@ def _segment_mutation_reason(tokens: list[str]) -> str | None:
463490
if subcommand in _GIT_NETWORK:
464491
return f"network access via git {subcommand}"
465492
if base == "uv":
466-
run_payload = _uv_run_payload(args)
493+
uv_args = _uv_strip_global_opts(args)
494+
run_payload = _uv_run_payload(uv_args)
467495
if run_payload and (r := _segment_mutation_reason(run_payload)):
468496
return f"uv run: {r}"
469-
nonopts = [a for a in args if not a.startswith("-")]
497+
nonopts = [a for a in uv_args if not a.startswith("-")]
470498
if nonopts:
471499
head = nonopts[0]
472500
sub = nonopts[1] if (head in _UV_SUBNAMESPACES and len(nonopts) > 1) else head
@@ -702,6 +730,28 @@ def _xargs_payload(args: list[str]) -> list[str]:
702730
}
703731

704732

733+
def _uv_strip_global_opts(args: list[str]) -> list[str]:
734+
"""Drop uv's *global* options (and the values of value-taking ones) that
735+
precede the subcommand, so ``uv --directory repo run rm`` resolves to
736+
``run rm`` and the wrapped command is not hidden behind a global flag's
737+
value (``uv --directory repo run rm -rf /`` must still classify as ``rm``).
738+
739+
Reuses ``_UV_RUN_VALUE_OPTS`` (a superset of uv's value-taking global options)
740+
to decide which flags consume a following word; ``--opt=value`` carries its
741+
value inline and is consumed as one token.
742+
"""
743+
i = 0
744+
while i < len(args) and args[i].startswith("-"):
745+
if args[i] == "--":
746+
i += 1
747+
break
748+
if "=" not in args[i] and args[i] in _UV_RUN_VALUE_OPTS and i + 1 < len(args):
749+
i += 2
750+
else:
751+
i += 1
752+
return args[i:]
753+
754+
705755
def _uv_run_payload(args: list[str]) -> list[str] | None:
706756
"""For ``uv run [opts] <cmd> ...``, return the wrapped command tokens, else ``None``.
707757
@@ -891,7 +941,7 @@ def _segment_destructive_reason(tokens: list[str]) -> str | None:
891941
if payload and (r := _segment_destructive_reason(payload)):
892942
return f"{base}: {r}"
893943
if base == "uv":
894-
run_payload = _uv_run_payload(args)
944+
run_payload = _uv_run_payload(_uv_strip_global_opts(args))
895945
if run_payload and (r := _segment_destructive_reason(run_payload)):
896946
return f"uv run: {r}"
897947
return None

src/pythinker_code/soul/pythinkersoul.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,6 +1505,16 @@ async def _append_notification(view: NotificationView) -> None:
15051505
# Normalize: merge adjacent user messages for clean API input
15061506
effective_history = normalize_history(self._context.history)
15071507

1508+
# Capture tool results as they stream in. If the batch is interrupted
1509+
# mid-flight, already-completed calls must keep their real output rather
1510+
# than being overwritten with a synthetic "interrupted" marker; only the
1511+
# still-pending calls get the marker (see the CancelledError handler).
1512+
completed_tool_results: dict[str, ToolResult] = {}
1513+
1514+
def _on_tool_result(tool_result: ToolResult) -> None:
1515+
completed_tool_results[tool_result.tool_call_id] = tool_result
1516+
wire_send(tool_result)
1517+
15081518
async def _run_step_once() -> StepResult:
15091519
# run an LLM step (may be interrupted)
15101520
from pythinker_code.telemetry import metrics as _m
@@ -1535,7 +1545,7 @@ async def _run_step_once() -> StepResult:
15351545
self._agent.toolset,
15361546
effective_history,
15371547
on_message_part=wire_send,
1538-
on_tool_result=wire_send,
1548+
on_tool_result=_on_tool_result,
15391549
)
15401550
finally:
15411551
reset_step_permission_profile(profile_token)
@@ -1668,12 +1678,15 @@ async def _pythinker_core_step_with_retry() -> StepResult:
16681678
try:
16691679
results = await result.tool_results()
16701680
except asyncio.CancelledError:
1671-
# Interrupted mid-tool: persist the assistant message and a synthetic
1672-
# interruption marker for every tool_call so the next turn does not see
1673-
# unanswered tool_calls (which providers reject). Shield the write from
1674-
# the same cancellation so it completes, then re-raise.
1681+
# Interrupted mid-tool: persist the assistant message plus a result
1682+
# for every tool_call so the next turn does not see unanswered
1683+
# tool_calls (which providers reject). Keep the real output of calls
1684+
# that already completed (streamed via on_tool_result); only the
1685+
# still-pending calls get a synthetic interruption marker. Shield the
1686+
# write from the same cancellation so it completes, then re-raise.
16751687
interrupted = [
1676-
ToolResult(
1688+
completed_tool_results.get(tc.id)
1689+
or ToolResult(
16771690
tool_call_id=tc.id,
16781691
return_value=ToolRuntimeError(message="Tool call interrupted by user."),
16791692
)

src/pythinker_code/soul/slash.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,9 @@ async def export(soul: PythinkerSoul, args: str):
320320
@registry.command(name="import")
321321
async def import_context(soul: PythinkerSoul, args: str):
322322
"""Import context from a file or session ID"""
323-
from pythinker_code.utils.export import perform_import
323+
from pythinker_code.utils.export import parse_import_args, perform_import
324324

325-
tokens = args.split()
326-
force = "--force" in tokens
327-
target = sanitize_cli_path(" ".join(t for t in tokens if t != "--force"))
325+
target, force = parse_import_args(args)
328326
if not target:
329327
wire_send(TextPart(text="Usage: /import <file_path or session_id>"))
330328
return

src/pythinker_code/tools/web/fetch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def _ip_is_blocked(address: str) -> bool:
2929
try:
3030
ip = ipaddress.ip_address(address)
3131
except ValueError:
32-
return False
32+
return True # fail closed: block addresses we cannot parse/classify
3333
return (not ip.is_global) or ip.is_multicast # W2 fail-closed shape
3434

3535

src/pythinker_code/ui/shell/export_import.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pythinker_code.ui.shell.console import console
1010
from pythinker_code.ui.shell.slash import ensure_pythinker_soul, registry, shell_mode_registry
1111
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens
12-
from pythinker_code.utils.path import sanitize_cli_path, shorten_home
12+
from pythinker_code.utils.path import shorten_home
1313
from pythinker_code.wire.types import TurnBegin, TurnEnd
1414

1515
if TYPE_CHECKING:
@@ -66,15 +66,13 @@ async def export(app: Shell, args: str):
6666
@shell_mode_registry.command(name="import")
6767
async def import_context(app: Shell, args: str):
6868
"""Import context from a file or session ID"""
69-
from pythinker_code.utils.export import perform_import
69+
from pythinker_code.utils.export import parse_import_args, perform_import
7070

7171
soul = ensure_pythinker_soul(app)
7272
if soul is None:
7373
return
7474

75-
tokens = args.split()
76-
force = "--force" in tokens
77-
target = sanitize_cli_path(" ".join(t for t in tokens if t != "--force"))
75+
target, force = parse_import_args(args)
7876
_t = _get_tui_tokens()
7977
if not target:
8078
console.print(f"[{_t.warning}]Usage: /import <file_path or session_id>[/]")

src/pythinker_code/utils/export.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,23 @@
3939
"""Common tool-call argument keys whose values make good one-line hints."""
4040

4141

42+
def parse_import_args(args: str) -> tuple[str, bool]:
43+
"""Parse ``/import`` arguments into ``(sanitized_path, force)``.
44+
45+
Uses shell-style tokenization so quoted/escaped paths with spaces survive
46+
instead of being collapsed by ``str.split``. ``--force`` anywhere in the
47+
tokens sets the flag and is dropped from the path before sanitization.
48+
"""
49+
try:
50+
tokens = shlex.split(args)
51+
except ValueError:
52+
# Unbalanced quotes: fall back to a plain split rather than raising.
53+
tokens = args.split()
54+
force = "--force" in tokens
55+
path = sanitize_cli_path(" ".join(t for t in tokens if t != "--force"))
56+
return path, force
57+
58+
4259
def _is_checkpoint_message(msg: Message) -> bool:
4360
"""Check if a message is an internal checkpoint marker."""
4461
if msg.role != "user" or len(msg.content) != 1:

0 commit comments

Comments
 (0)