Skip to content

Commit 33a2f8a

Browse files
committed
fix: remediate 65 security and correctness audit findings
Implements the remediation plan for the validated audit findings (1 Critical, 15 High, 29 Medium, 20 Low) across five phases, each fix behind a TDD test and gated by `make check` plus a security review. Phase 0 — shell permission classifier: close the read-only / auto-mode bypass cluster (interior &/|& separators, casefolded base commands, wrapper value-options, find/xargs/awk payloads, glued output redirection, unsafe `git -c`, uv sub-namespaces and `uv run` option-prefix bypass). Phase 1 — confinement, egress, telemetry: symlink-resolve file read/write/edit and grep before workspace/sensitive checks; fail-closed SSRF with a connection-pinned resolver; bounded shell wait; Sentry path/home redaction; invisible-char and case-folded sensitive-file handling; sensitive-import gate; untrusted-output wrapping; subagent-id path validation. Phase 2 — tool dispatch, lifecycle, context integrity: MCP-vs-builtin tool collisions; run_soul task-leak cleanup; restore-id/path traversal guards; compaction rollback; mid-tool-cancel turn balance; cyclic-extend detection. Phase 3 — wire server, auth, web-server: wire read-loop hardening; OAuth refresh / device-id / 403 handling; provider base_url validation; replay watermark; session-leak cleanup; ZIP-import validation. Phase 4 — UI/usage/CLI: ANSI sanitization at render boundaries (incl. generic tool arg-key names); usage-meter consumed-vs-remaining; reset-window loop guard; RunAgents approval fingerprint over child prompts; owner-only MCP config; live Typer help; /restore traversal and error handling; bounded approval-request store. Review-found gaps were fixed with regression tests: uv-run option bypass, grep symlink escape, SSH-key import gate, ANSI arg-key injection, and the MCP-config / share-dir permission race. Also hardens auth JSON parsing.
1 parent edf9080 commit 33a2f8a

123 files changed

Lines changed: 4382 additions & 924 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ async def chdir(self, path: StrOrHostPath) -> None:
156156
"""Change the current working directory."""
157157
...
158158

159+
async def realpath(self, path: StrOrHostPath) -> HostPath:
160+
"""Resolve symlinks and return the real absolute path."""
161+
...
162+
159163
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
160164
"""Get the stat result for a path."""
161165
...
@@ -282,6 +286,10 @@ async def chdir(path: StrOrHostPath) -> None:
282286
await get_current_host().chdir(path)
283287

284288

289+
async def realpath(path: StrOrHostPath) -> HostPath:
290+
return await get_current_host().realpath(path)
291+
292+
285293
async def stat(path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
286294
return await get_current_host().stat(path, follow_symlinks=follow_symlinks)
287295

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ async def chdir(self, path: StrOrHostPath) -> None:
9999
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
100100
os.chdir(local_path)
101101

102+
async def realpath(self, path: StrOrHostPath) -> HostPath:
103+
"""Resolve symlinks and return the real path (follows symlinks)."""
104+
local = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
105+
resolved = await asyncio.to_thread(os.path.realpath, str(local))
106+
return HostPath.unsafe_from_local_path(Path(resolved))
107+
102108
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
103109
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
104110
st = await aiofiles.os.stat(local_path, follow_symlinks=follow_symlinks)
@@ -143,7 +149,7 @@ async def readtext(
143149
errors: Literal["strict", "ignore", "replace"] = "strict",
144150
) -> str:
145151
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
146-
async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:
152+
async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f:
147153
return await f.read()
148154

149155
async def readlines(
@@ -154,7 +160,7 @@ async def readlines(
154160
errors: Literal["strict", "ignore", "replace"] = "strict",
155161
) -> AsyncGenerator[str]:
156162
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
157-
async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:
163+
async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f:
158164
async for line in f:
159165
yield line
160166

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ def expanduser(self) -> HostPath:
118118
return home
119119
return home.joinpath(*parts[1:])
120120

121+
async def realpath(self) -> HostPath:
122+
"""Resolve symlinks and return the real absolute path."""
123+
return await pythinker_host.realpath(self)
124+
121125
async def stat(self, follow_symlinks: bool = True) -> pythinker_host.StatResult:
122126
"""Return an os.stat_result for the path."""
123127
return await pythinker_host.stat(self, follow_symlinks=follow_symlinks)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ async def chdir(self, path: StrOrHostPath) -> None:
179179
await self._sftp.chdir(str(path))
180180
self._cwd = await self._sftp.realpath(".")
181181

182+
async def realpath(self, path: StrOrHostPath) -> HostPath:
183+
"""Resolve symlinks and return the real path via SFTP realpath."""
184+
real = await self._sftp.realpath(str(path))
185+
return HostPath(real)
186+
182187
async def stat(
183188
self,
184189
path: StrOrHostPath,

packages/pythinker-review/tests/unit/test_security_intel.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ def test_intel_client_disables_implicit_redirects() -> None:
4848
from pythinker_review.security_intel.client import IntelHttpClient
4949

5050
client = IntelHttpClient()
51-
assert any(isinstance(h, _NoRedirectHandler) for h in client._opener.handlers)
51+
handlers = client._opener.handlers # pyright: ignore[reportAttributeAccessIssue]
52+
assert any(isinstance(h, _NoRedirectHandler) for h in handlers)
5253

5354

5455
def test_intel_cache_roundtrip(tmp_path: Path) -> None:

src/pythinker_code/__main__.py

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,60 +8,6 @@
88
if TYPE_CHECKING:
99
from typing import TextIO
1010

11-
ROOT_HELP = """Usage: pythinker [OPTIONS] COMMAND [ARGS]...
12-
13-
Pythinker, your next CLI agent.
14-
15-
Options:
16-
-h, --help Show this message and exit.
17-
-V, --version Show version and exit.
18-
--verbose Print verbose information.
19-
--debug Log debug information.
20-
-w, --work-dir DIRECTORY Working directory for the agent.
21-
--add-dir DIRECTORY Add an additional workspace directory.
22-
-S, -r, --session, --resume TEXT Resume a session.
23-
-C, --continue Continue the previous session.
24-
--config TEXT Config TOML/JSON string to load.
25-
--config-file FILE Config TOML/JSON file to load.
26-
-m, --model TEXT LLM model to use.
27-
--thinking / --no-thinking Enable or disable thinking mode.
28-
-y, --yolo, --yes, --auto-approve
29-
Dangerously skip permission approvals.
30-
--plan Start in plan mode.
31-
--auto Run in auto mode (no user present).
32-
-p, -c, --prompt, --command TEXT User prompt to the agent.
33-
--print Run in print mode.
34-
--acp Deprecated; use `pythinker acp`.
35-
--wire Run as Wire server.
36-
--quiet Print only the final assistant message.
37-
--agent [default|okabe] Builtin agent specification to use.
38-
--agent-file FILE Custom agent specification file.
39-
--mcp-config-file FILE MCP config file to load; repeatable.
40-
--mcp-config TEXT MCP config JSON to load; repeatable.
41-
--skills-dir DIRECTORY Custom skills directory; repeatable.
42-
--no-telemetry Disable anonymous telemetry & error reporting.
43-
44-
Commands:
45-
acp Run Pythinker CLI ACP server.
46-
term Run Toad TUI backed by Pythinker CLI ACP server.
47-
login Login with a model provider.
48-
logout Logout from a model provider.
49-
info Show version and protocol information.
50-
export Export session data.
51-
mcp Manage MCP server configurations.
52-
plugin Manage plugins.
53-
review Diff-focused code review (delegates to pythinker-review).
54-
secscan Diff-focused security review (delegates to pythinker-review).
55-
security-scan Repo-wide Pythinker Security Scan pipeline (Python-native).
56-
debug Failure/log root-cause analysis (delegates to pythinker-review).
57-
update Check for and install Pythinker CLI updates.
58-
vis Run Pythinker Agent Tracing Visualizer.
59-
web Run Pythinker CLI web interface.
60-
61-
Documentation: https://pythoughts-labs.github.io/pythinker-code/
62-
LLM friendly version: https://pythoughts-labs.github.io/pythinker-code/llms.txt
63-
"""
64-
6511

6612
def _prog_name() -> str:
6713
return Path(sys.argv[0]).name or "pythinker"
@@ -126,10 +72,6 @@ def main(argv: Sequence[str] | None = None) -> int | str | None:
12672
print(f"pythinker, version {get_version()} — by {ORGANIZATION}")
12773
return 0
12874

129-
if len(args) == 1 and args[0] in {"--help", "-h"}:
130-
print(ROOT_HELP, end="")
131-
return 0
132-
13375
from pythinker_code.telemetry.crash import install_crash_handlers, set_phase
13476
from pythinker_code.utils.proxy import normalize_proxy_env
13577

src/pythinker_code/acp/host.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ def getcwd(self) -> HostPath:
212212
async def chdir(self, path: StrOrHostPath) -> None:
213213
await self._fallback.chdir(path)
214214

215+
async def realpath(self, path: StrOrHostPath) -> HostPath:
216+
return await self._fallback.realpath(path)
217+
215218
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
216219
return await self._fallback.stat(path, follow_symlinks=follow_symlinks)
217220

src/pythinker_code/agentspec.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,13 @@ def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
129129
)
130130

131131

132-
def _load_agent_spec(agent_file: Path) -> AgentSpec:
132+
def _load_agent_spec(agent_file: Path, _visited: set[Path] | None = None) -> AgentSpec:
133+
resolved = agent_file.resolve()
134+
if _visited is None:
135+
_visited = set()
136+
if resolved in _visited:
137+
raise AgentSpecError(f"Cyclic agent extend chain detected at {agent_file}")
138+
_visited.add(resolved)
133139
if not agent_file.exists():
134140
raise AgentSpecError(f"Agent spec file not found: {agent_file}")
135141
if not agent_file.is_file():
@@ -160,7 +166,7 @@ def _load_agent_spec(agent_file: Path) -> AgentSpec:
160166
base_agent_file = DEFAULT_AGENT_FILE
161167
else:
162168
base_agent_file = (agent_file.parent / agent_spec.extend).absolute()
163-
base_agent_spec = _load_agent_spec(base_agent_file)
169+
base_agent_spec = _load_agent_spec(base_agent_file, _visited)
164170
if not isinstance(agent_spec.name, Inherit):
165171
base_agent_spec.name = agent_spec.name
166172
if not isinstance(agent_spec.system_prompt_path, Inherit):

src/pythinker_code/app.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -338,20 +338,6 @@ async def create(
338338
_cleanup_stale_foreground_subagents(runtime)
339339
_phase_timings_ms["init_ms"] = int((time.monotonic() - _phase_t) * 1000)
340340

341-
# Refresh plugin configs with fresh credentials (e.g. OAuth tokens)
342-
try:
343-
from pythinker_code.plugin.manager import (
344-
collect_host_values,
345-
get_plugins_dir,
346-
refresh_plugin_configs,
347-
)
348-
349-
host_values = collect_host_values(config, oauth)
350-
if host_values.get("api_key"):
351-
refresh_plugin_configs(get_plugins_dir(), host_values)
352-
except Exception:
353-
logger.debug("Failed to refresh plugin configs, skipping")
354-
355341
if agent_file is None:
356342
agent_file = DEFAULT_AGENT_FILE
357343
if startup_progress is not None:

src/pythinker_code/approval_runtime/runtime.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
from pythinker_code.wire.types import DisplayBlock
2424

2525

26+
_MAX_TERMINAL_RECORDS = 256
27+
28+
2629
class ApprovalCancelledError(Exception):
2730
"""Raised when a pending approval is cancelled by its source lifecycle."""
2831

@@ -143,6 +146,7 @@ def resolve(self, request_id: str, response: ApprovalResponseKind, feedback: str
143146
waiter.set_result((response, feedback))
144147
self._publish_event(ApprovalRuntimeEvent(kind="request_resolved", request=request))
145148
self._publish_wire_response(request_id, response, feedback)
149+
self._evict_terminal_overflow()
146150
return True
147151

148152
def _cancel_request(self, request_id: str, feedback: str = "") -> None:
@@ -161,6 +165,7 @@ def _cancel_request(self, request_id: str, feedback: str = "") -> None:
161165
waiter.set_exception(ApprovalCancelledError(request_id))
162166
self._publish_event(ApprovalRuntimeEvent(kind="request_resolved", request=request))
163167
self._publish_wire_response(request_id, "reject", feedback)
168+
self._evict_terminal_overflow()
164169

165170
def cancel_by_source(self, source_kind: ApprovalSourceKind, source_id: str) -> int:
166171
cancelled = 0
@@ -180,8 +185,18 @@ def cancel_by_source(self, source_kind: ApprovalSourceKind, source_id: str) -> i
180185
self._publish_event(ApprovalRuntimeEvent(kind="request_resolved", request=request))
181186
self._publish_wire_response(request_id, "reject")
182187
cancelled += 1
188+
self._evict_terminal_overflow()
183189
return cancelled
184190

191+
def _evict_terminal_overflow(self) -> None:
192+
terminal_ids = [rid for rid, r in self._requests.items() if r.status != "pending"]
193+
overflow = len(terminal_ids) - _MAX_TERMINAL_RECORDS
194+
if overflow <= 0:
195+
return
196+
# dict preserves insertion order; terminal_ids is already oldest-first
197+
for rid in terminal_ids[:overflow]:
198+
self._requests.pop(rid, None)
199+
185200
def list_pending(self) -> list[ApprovalRequestRecord]:
186201
pending = [request for request in self._requests.values() if request.status == "pending"]
187202
pending.sort(key=lambda request: request.created_at)

0 commit comments

Comments
 (0)