Skip to content

Commit ee75926

Browse files
committed
fix: harden isolation, elision, and concurrency per adversarial arc review
A 16-agent adversarial review (4 dimensions, every finding refuted-or- confirmed against live code) confirmed 11 findings; all fixed except one deliberate deferral (exclusive gate held across approval waits — needs the approval-split refactor; recorded in tasks/todo.md). - DATA LOSS (high): a child that committed its work left a clean worktree, so cleanup removed it and orphaned the commits. Creation now records a base-SHA sidecar (next to the worktree, never inside it); commits ahead of base count as changes and force retention, with unknown provenance failing closed to retention. - FALSE ISOLATION (high): foreground shell inherited the process cwd and relative file-tool paths resolved against it, so isolated children mutated the original repo. Host exec (protocol, local, ssh, ACP fallback) gained a cwd argument; foreground shell passes the runtime work dir, and write/replace/read resolve relative paths against it while preserving the relative-escape error contract. - REGRESSION (high): safe mode now disables the read-only-command prompt elision — users who disabled auto-approval keep every checkpoint. - REGRESSION (high): untrusted-project hook stripping now publishes a session notification (web/ACP visible), not just a shell log line. - MCP readOnlyHint annotations enable supports_parallel via property; worktree add/remove serializes per repo; CHANGELOG documents the same-step serialization and stderr-diagnostics behavior changes.
1 parent 44a9c88 commit ee75926

18 files changed

Lines changed: 274 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Adversarial branch review hardening.** A multi-agent review pass confirmed and fixed: committed-but-clean isolation worktrees are now retained (commits ahead of the creation base count as changes — they were previously orphaned on cleanup); foreground shell commands and relative-path file edits now resolve against the agent's work dir, so worktree isolation actually binds them (host exec gained a `cwd` argument); safe mode now also disables the read-only-command prompt elision; MCP tools annotated `readOnlyHint` run in parallel in the same-step gate; worktree add/remove serializes per repo. Behavior notes: same-step tool calls without `supports_parallel` now serialize deterministically (previously fully concurrent), and text-mode error diagnostics moved to stderr — capture `2>&1` or use `--output-format stream-json` if you scraped stdout.
1819
- **The agent now knows its own permission posture.** A live permissions-state reminder renders the enforced profile, safe-mode/yolo/auto flags, mutation/network allowances, session-approved actions, and the shell gate's command-shaping rules — re-emitted exactly when the posture changes (/yolo, /auto, /trust, new approvals) instead of the model discovering policy through denied tool calls.
1920
- **Edits recover from whitespace and smart-punctuation drift.** StrReplaceFile no longer hard-fails with "old string not found" when the only mismatch is trailing whitespace, indentation, or smart quotes/dashes: a graduated line-window ladder relocates the edit, replaces the actual file slice (preserving CRLF endings), and names the relaxation it used in the tool message. Multiple fuzzy hits without replace_all still error, so ambiguity is never silently resolved.
2021
- **`isolation="worktree"` is now enforced for background write agents.** Previously it only recorded intent, so parallel coders shared one working tree and could clobber each other. A write-profile child now runs in its own git worktree of HEAD; its final report names the worktree path with a diff summary so changes merge deliberately, clean worktrees are removed, non-git roots fail with an actionable error, and read-profile children ignore the request.

packages/pythinker-host/src/pythinker_host/__init__.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,14 +220,18 @@ async def mkdir(
220220
"""Create a directory at the given path."""
221221
...
222222

223-
async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
223+
async def exec(
224+
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
225+
) -> HostProcess:
224226
"""
225227
Execute a command with arguments and return the running process.
226228
227229
Args:
228230
*args: Command and its arguments.
229231
env: Environment variables for the subprocess. If None, inherits
230232
from the parent process.
233+
cwd: Working directory for the subprocess. If None, inherits the
234+
backend's current working directory (process cwd locally).
231235
"""
232236
...
233237

@@ -347,8 +351,10 @@ async def mkdir(path: StrOrHostPath, parents: bool = False, exist_ok: bool = Fal
347351
return await get_current_host().mkdir(path, parents=parents, exist_ok=exist_ok)
348352

349353

350-
async def exec(*args: str, env: Mapping[str, str] | None = None) -> HostProcess:
351-
return await get_current_host().exec(*args, env=env)
354+
async def exec(
355+
*args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
356+
) -> HostProcess:
357+
return await get_current_host().exec(*args, env=env, cwd=cwd)
352358

353359

354360
from pythinker_host._current import current_host as current_host # noqa: E402

packages/pythinker-host/src/pythinker_host/local.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,9 @@ async def mkdir(
190190
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
191191
await asyncio.to_thread(local_path.mkdir, parents=parents, exist_ok=exist_ok)
192192

193-
async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
193+
async def exec(
194+
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
195+
) -> HostProcess:
194196
if not args:
195197
raise ValueError("At least one argument (the program to execute) is required.")
196198

@@ -208,6 +210,7 @@ async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostPr
208210
stdout=asyncio.subprocess.PIPE,
209211
stderr=asyncio.subprocess.PIPE,
210212
env=env,
213+
cwd=cwd,
211214
**process_options,
212215
)
213216
return self.Process(process)

packages/pythinker-host/src/pythinker_host/ssh.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,9 @@ async def mkdir(
303303
raise FileExistsError(f"{path} already exists")
304304
await self._sftp.mkdir(str(path))
305305

306-
async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
306+
async def exec(
307+
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
308+
) -> HostProcess:
307309
if not args:
308310
raise ValueError("At least one argument (the program to execute) is required.")
309311
command = " ".join(shlex.quote(arg) for arg in args)
@@ -313,8 +315,9 @@ async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostPr
313315
# cwd before running the command.
314316
#
315317
# This is intentionally strict: if cwd doesn't exist, the command fails.
316-
if self._cwd:
317-
command = f"cd {shlex.quote(self._cwd)} && {command}"
318+
effective_cwd = cwd or self._cwd
319+
if effective_cwd:
320+
command = f"cd {shlex.quote(effective_cwd)} && {command}"
318321
process = await self._connection.create_process(command, encoding=None, env=env)
319322
return self.Process(process)
320323

src/pythinker_code/acp/host.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,10 @@ async def mkdir(
296296
) -> None:
297297
await self._fallback.mkdir(path, parents=parents, exist_ok=exist_ok)
298298

299-
async def exec(self, *args: str, env: Mapping[str, str] | None = None) -> HostProcess:
300-
return await self._fallback.exec(*args, env=env)
299+
async def exec(
300+
self, *args: str, env: Mapping[str, str] | None = None, cwd: str | None = None
301+
) -> HostProcess:
302+
return await self._fallback.exec(*args, env=env, cwd=cwd)
301303

302304
def _abs_path(self, path: StrOrHostPath) -> str:
303305
host_path = path if isinstance(path, HostPath) else HostPath(path)

src/pythinker_code/app.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,30 @@ async def create(
388388
from pythinker_code.hooks.engine import HookEngine
389389

390390
hook_engine = HookEngine(config.hooks, cwd=str(session.work_dir))
391+
if config.disabled_project_hooks:
392+
# The load-time logger.warning only reaches shell users; publish a
393+
# notification so web/ACP frontends also learn why their project
394+
# hooks did not run and how to enable them.
395+
from pythinker_code.notifications.models import NotificationEvent
396+
397+
runtime.notifications.publish(
398+
NotificationEvent(
399+
id=f"project-hooks-disabled:{session.id}",
400+
category="system",
401+
type="project_hooks_disabled",
402+
source_kind="config",
403+
source_id="project_trust",
404+
title="Project hooks disabled (untrusted project)",
405+
body=(
406+
"Hooks defined in "
407+
+ ", ".join(config.disabled_project_hooks)
408+
+ " are disabled until you trust this project. Run /trust to "
409+
"enable them (takes effect on /reload or next start)."
410+
),
411+
severity="warning",
412+
dedupe_key=f"project-hooks-disabled:{session.id}",
413+
)
414+
)
391415
soul.set_hook_engine(hook_engine)
392416
runtime.hook_engine = hook_engine
393417

src/pythinker_code/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,8 @@ def _read_toml(path: Path) -> dict[str, Any]:
379379
local_file: Path | None = None
380380
project_dict: dict[str, Any] = {}
381381
local_dict: dict[str, Any] = {}
382+
project_trusted = True
383+
stripped_hook_files: list[str] = []
382384

383385
if project_root is not None:
384386
from pythinker_code.project_trust import is_project_trusted
@@ -406,6 +408,7 @@ def _read_toml(path: Path) -> dict[str, Any]:
406408
local_dict = {}
407409
for scope_dict, scope_file in ((project_dict, project_file), (local_dict, local_file)):
408410
if scope_dict.pop("hooks", None) is not None:
411+
stripped_hook_files.append(str(scope_file))
409412
logger.warning(
410413
"Project hooks in {file} are disabled until the project is "
411414
"trusted; run /trust to enable them",
@@ -444,6 +447,8 @@ def _read_toml(path: Path) -> dict[str, Any]:
444447
raise ConfigError("Invalid configuration:\n" + "\n".join(enriched)) from exc
445448

446449
# ── METADATA ──────────────────────────────────────────────────────────
450+
if project_root is not None and not project_trusted:
451+
config.disabled_project_hooks = stripped_hook_files
447452
config.is_from_default_location = True
448453
config.source_file = user_file
449454
if user_file.exists():
@@ -1103,6 +1108,15 @@ class Config(BaseModel):
11031108
mcp: MCPConfig = Field(default_factory=MCPConfig, description="MCP configuration")
11041109
tui: TUIConfig = Field(default_factory=TUIConfig, description="TUI rendering configuration")
11051110
hooks: list[HookDef] = Field(default_factory=list, description="Hook definitions") # pyright: ignore[reportUnknownVariableType]
1111+
disabled_project_hooks: list[str] = Field(
1112+
default_factory=list,
1113+
exclude=True,
1114+
description=(
1115+
"Config files whose project-scope hooks were stripped because the "
1116+
"project is untrusted. Populated at load; surfaced as a "
1117+
"notification so non-shell frontends see it too."
1118+
),
1119+
)
11061120
merge_all_available_skills: bool = Field(
11071121
default=True,
11081122
description=(

src/pythinker_code/soul/toolset.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,6 +1296,16 @@ def mcp_server_name(self) -> str:
12961296
"""Name of the MCP server this tool belongs to."""
12971297
return self._mcp_server_name
12981298

1299+
@property
1300+
def supports_parallel(self) -> bool:
1301+
"""Honor the MCP readOnlyHint annotation in the same-step gate.
1302+
1303+
Read-only server tools (doc/resource lookups) may overlap instead of
1304+
serializing; anything unannotated stays exclusive (safe default).
1305+
"""
1306+
annotations = getattr(self._mcp_tool, "annotations", None)
1307+
return bool(getattr(annotations, "readOnlyHint", False))
1308+
12991309
async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:
13001310
# Call-time re-check of the list-time filter: defense in depth for
13011311
# tool maps shared across agents (e.g. runtime.mcp_tools handed to

src/pythinker_code/subagents/worktree.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,21 @@
99
from __future__ import annotations
1010

1111
import asyncio
12+
from collections import defaultdict
1213
from pathlib import Path
1314

1415
from pythinker_code.utils.logging import logger
1516

1617
_GIT_TIMEOUT_S = 30.0
18+
# Serialize worktree add/remove per repo: git's internal locking is reliable
19+
# on current versions, but concurrent isolated agents should not depend on it.
20+
_REPO_LOCKS: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
21+
22+
23+
def _base_sha_file(worktree: Path) -> Path:
24+
# Sidecar NEXT TO the worktree, never inside it — an untracked file inside
25+
# would make every clean worktree look dirty.
26+
return worktree.parent / f"{worktree.name}.base-sha"
1727

1828

1929
class WorktreeError(Exception):
@@ -58,21 +68,58 @@ async def create_agent_worktree(repo_dir: Path, dest: Path) -> None:
5868
# Resume of an isolated agent reuses its existing worktree.
5969
return
6070
dest.parent.mkdir(parents=True, exist_ok=True)
61-
code, _, stderr = await _git(["worktree", "add", "--detach", str(dest), "HEAD"], repo_dir)
62-
if code != 0:
63-
first_line = stderr.splitlines()[0] if stderr else "unknown git error"
64-
raise WorktreeError(f"could not create isolation worktree at {dest}: {first_line}")
71+
async with _REPO_LOCKS[str(repo_dir)]:
72+
code, _, stderr = await _git(["worktree", "add", "--detach", str(dest), "HEAD"], repo_dir)
73+
if code != 0:
74+
first_line = stderr.splitlines()[0] if stderr else "unknown git error"
75+
raise WorktreeError(f"could not create isolation worktree at {dest}: {first_line}")
76+
# Record the creation base so committed-but-clean child work is detected
77+
# later; commits ahead of this SHA must never be silently removed.
78+
code, base_sha, _ = await _git(["rev-parse", "HEAD"], dest)
79+
if code == 0 and base_sha:
80+
_base_sha_file(dest).write_text(base_sha + "\n", encoding="utf-8")
6581

6682

6783
async def worktree_change_summary(worktree: Path) -> str:
68-
"""Short human summary of changes in *worktree*; empty string when clean."""
84+
"""Short human summary of changes in *worktree*; empty string when clean.
85+
86+
"Changes" includes commits the child made on its detached HEAD: a child
87+
that commits its work leaves a clean working tree, and `worktree remove`
88+
would orphan those commits as dangling objects.
89+
"""
90+
parts: list[str] = []
91+
commits_ahead = await _commits_ahead_of_base(worktree)
92+
if commits_ahead:
93+
parts.append(f"{commits_ahead} commit(s) ahead of the creation base")
6994
code, status, _ = await _git(["status", "--porcelain"], worktree)
70-
if code != 0 or not status:
71-
return ""
72-
_, diff_stat, _ = await _git(["diff", "--stat", "HEAD"], worktree)
73-
untracked = sum(1 for line in status.splitlines() if line.startswith("??"))
74-
parts = [part for part in (diff_stat, f"{untracked} untracked file(s)" if untracked else "")]
75-
return "\n".join(part for part in parts if part)
95+
if code == 0 and status:
96+
_, diff_stat, _ = await _git(["diff", "--stat", "HEAD"], worktree)
97+
if diff_stat:
98+
parts.append(diff_stat)
99+
untracked = sum(1 for line in status.splitlines() if line.startswith("??"))
100+
if untracked:
101+
parts.append(f"{untracked} untracked file(s)")
102+
return "\n".join(parts)
103+
104+
105+
async def _commits_ahead_of_base(worktree: Path) -> int:
106+
"""Commits on the worktree's detached HEAD since creation.
107+
108+
Missing or unreadable sidecar fails CLOSED (pretend one commit exists)
109+
when HEAD cannot be compared — losing work is the only unacceptable
110+
outcome, so unknown provenance means retain.
111+
"""
112+
sidecar = _base_sha_file(worktree)
113+
try:
114+
base_sha = sidecar.read_text(encoding="utf-8").strip()
115+
except OSError:
116+
base_sha = ""
117+
if not base_sha:
118+
return 1 # unknown provenance — retain
119+
code, count, _ = await _git(["rev-list", "--count", f"{base_sha}..HEAD"], worktree)
120+
if code != 0:
121+
return 1
122+
return int(count or 0)
76123

77124

78125
async def cleanup_agent_worktree(repo_dir: Path, worktree: Path, *, has_changes: bool) -> str:
@@ -84,10 +131,12 @@ async def cleanup_agent_worktree(repo_dir: Path, worktree: Path, *, has_changes:
84131
"""
85132
if has_changes:
86133
return "retained"
87-
code, _, stderr = await _git(["worktree", "remove", str(worktree)], repo_dir)
134+
async with _REPO_LOCKS[str(repo_dir)]:
135+
code, _, stderr = await _git(["worktree", "remove", str(worktree)], repo_dir)
88136
if code != 0:
89137
logger.warning(
90138
"Could not remove clean isolation worktree {wt}: {err}", wt=worktree, err=stderr
91139
)
92140
return "retained"
141+
_base_sha_file(worktree).unlink(missing_ok=True)
93142
return "removed"

src/pythinker_code/tools/file/read.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,13 @@ async def __call__(self, params: Params) -> ToolReturnValue:
111111

112112
try:
113113
raw = HostPath(params.path).expanduser()
114-
p = raw.canonical()
114+
# Relative tool paths resolve against the runtime work dir
115+
# (override-aware), NOT the process cwd — an isolated child's
116+
# relative write must land in its worktree. `raw` keeps the
117+
# original form: the workspace-escape rule for relative paths
118+
# checks (and reports) what the caller actually passed.
119+
base_joined = raw if raw.is_absolute() else self._work_dir.joinpath(str(raw))
120+
p = base_joined.canonical()
115121

116122
# Resolve the real (symlink-followed) path for security checks only.
117123
# os.path.realpath follows symlinks at every component including the leaf,

0 commit comments

Comments
 (0)