pcli-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Gates a tool call: guardrails (hard deny) -> remembered grants -> ask.
|
|
2
|
+
|
|
3
|
+
The `ask` callback is intentionally decoupled from Textual so this module
|
|
4
|
+
stays testable without a running App; tui/screens/permission_modal.py
|
|
5
|
+
supplies the real implementation via `push_screen_wait`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections import deque
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from pcli.permissions.guardrails import GuardrailsConfig
|
|
16
|
+
from pcli.permissions.policy import PermissionPolicy
|
|
17
|
+
from pcli.session.audit import append_audit_entry
|
|
18
|
+
from pcli.session.models import PermissionGrant, Session
|
|
19
|
+
|
|
20
|
+
PermissionDecision = Literal["allow", "deny"]
|
|
21
|
+
RememberScope = Literal["once", "session", "always"]
|
|
22
|
+
DecisionMechanism = Literal[
|
|
23
|
+
"rate_limit",
|
|
24
|
+
"guardrail_command",
|
|
25
|
+
"guardrail_path",
|
|
26
|
+
"guardrail_python_module",
|
|
27
|
+
"default_allow",
|
|
28
|
+
"remembered_policy",
|
|
29
|
+
"no_ui_fail_closed",
|
|
30
|
+
"interactive",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
AskCallback = Callable[[str, dict[str, Any], str], Awaitable[tuple[PermissionDecision, RememberScope | None]]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PermissionManager:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
guardrails: GuardrailsConfig | None = None,
|
|
41
|
+
policy: PermissionPolicy | None = None,
|
|
42
|
+
audit_enabled: bool = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.guardrails = guardrails or GuardrailsConfig.load()
|
|
45
|
+
self.policy = policy or PermissionPolicy()
|
|
46
|
+
self._audit_enabled = audit_enabled
|
|
47
|
+
self._recent_tool_call_times: deque[float] = deque()
|
|
48
|
+
|
|
49
|
+
def _within_rate_limit(self) -> bool:
|
|
50
|
+
"""guardrails.max_tool_calls_per_minute as a sliding 60s window,
|
|
51
|
+
shared across every tool call this manager gates (including a
|
|
52
|
+
subagent's, since it's handed the same PermissionManager instance) —
|
|
53
|
+
a global rate cap independent of any single turn's own tool-call
|
|
54
|
+
count (see max_tool_calls_per_turn in agent/loop.py)."""
|
|
55
|
+
limit = self.guardrails.max_tool_calls_per_minute
|
|
56
|
+
if limit <= 0:
|
|
57
|
+
return True
|
|
58
|
+
now = time.monotonic()
|
|
59
|
+
cutoff = now - 60.0
|
|
60
|
+
while self._recent_tool_call_times and self._recent_tool_call_times[0] < cutoff:
|
|
61
|
+
self._recent_tool_call_times.popleft()
|
|
62
|
+
if len(self._recent_tool_call_times) >= limit:
|
|
63
|
+
return False
|
|
64
|
+
self._recent_tool_call_times.append(now)
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
async def check(
|
|
68
|
+
self,
|
|
69
|
+
tool_name: str,
|
|
70
|
+
arguments: dict[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
command: str | None = None,
|
|
73
|
+
path: str | None = None,
|
|
74
|
+
python_module: str | None = None,
|
|
75
|
+
ask: AskCallback | None = None,
|
|
76
|
+
risk_description: str = "",
|
|
77
|
+
default_allow: bool = False,
|
|
78
|
+
session: Session | None = None,
|
|
79
|
+
) -> PermissionDecision:
|
|
80
|
+
"""Thin wrapper over check_with_reason() for callers that only need
|
|
81
|
+
the decision, not why — kept so the (many) existing call sites
|
|
82
|
+
don't need to unpack a tuple."""
|
|
83
|
+
decision, _reason = await self.check_with_reason(
|
|
84
|
+
tool_name,
|
|
85
|
+
arguments,
|
|
86
|
+
command=command,
|
|
87
|
+
path=path,
|
|
88
|
+
python_module=python_module,
|
|
89
|
+
ask=ask,
|
|
90
|
+
risk_description=risk_description,
|
|
91
|
+
default_allow=default_allow,
|
|
92
|
+
session=session,
|
|
93
|
+
)
|
|
94
|
+
return decision
|
|
95
|
+
|
|
96
|
+
async def check_with_reason(
|
|
97
|
+
self,
|
|
98
|
+
tool_name: str,
|
|
99
|
+
arguments: dict[str, Any],
|
|
100
|
+
*,
|
|
101
|
+
command: str | None = None,
|
|
102
|
+
path: str | None = None,
|
|
103
|
+
python_module: str | None = None,
|
|
104
|
+
ask: AskCallback | None = None,
|
|
105
|
+
risk_description: str = "",
|
|
106
|
+
default_allow: bool = False,
|
|
107
|
+
session: Session | None = None,
|
|
108
|
+
) -> tuple[PermissionDecision, str | None]:
|
|
109
|
+
"""Same decision logic as check(), but also returns a human-readable
|
|
110
|
+
reason for a "deny" — None for "allow", and also None for a plain
|
|
111
|
+
interactive "no" with nothing more specific to say than the user's
|
|
112
|
+
own judgment call. AgentLoop uses this (not check()) so a denied
|
|
113
|
+
tool call gives the model something to actually diagnose instead of
|
|
114
|
+
a bare "Permission denied.", which is otherwise toothless for this
|
|
115
|
+
exact class of failure despite the "Recovering from a failed tool
|
|
116
|
+
call" system-prompt guidance telling it to diagnose before retrying.
|
|
117
|
+
|
|
118
|
+
`default_allow=True` is for tools that don't need a user prompt
|
|
119
|
+
(e.g. read_file) but must still respect the hard guardrails below.
|
|
120
|
+
|
|
121
|
+
`session`, if given, gets a PermissionGrant record appended whenever
|
|
122
|
+
a "session" or "always" grant is remembered — a historical audit
|
|
123
|
+
trail that travels with session export/import. It's independent of
|
|
124
|
+
enforcement: "always" grants are enforced via self.policy (persisted
|
|
125
|
+
separately in permissions.json), and are deliberately not re-applied
|
|
126
|
+
from a session's own history on import, to avoid double-recording
|
|
127
|
+
the same grant into permissions.json.
|
|
128
|
+
|
|
129
|
+
When self._audit_enabled and session is given, also appends one
|
|
130
|
+
hash-chained AuditEntry (session/audit.py) per call recording the
|
|
131
|
+
decision, its reason, and which mechanism decided it - skipped only
|
|
132
|
+
for the default_allow/no-guardrail-hit case, since nothing was
|
|
133
|
+
actually decided there (see _decide's own docstring)."""
|
|
134
|
+
decision, reason, mechanism = await self._decide(
|
|
135
|
+
tool_name,
|
|
136
|
+
arguments,
|
|
137
|
+
command=command,
|
|
138
|
+
path=path,
|
|
139
|
+
python_module=python_module,
|
|
140
|
+
ask=ask,
|
|
141
|
+
risk_description=risk_description,
|
|
142
|
+
default_allow=default_allow,
|
|
143
|
+
session=session,
|
|
144
|
+
)
|
|
145
|
+
if self._audit_enabled and session is not None and mechanism != "default_allow":
|
|
146
|
+
append_audit_entry(
|
|
147
|
+
session,
|
|
148
|
+
kind="permission_decision",
|
|
149
|
+
summary=f"{tool_name} {decision}" + (f" ({mechanism})" if mechanism else ""),
|
|
150
|
+
detail={
|
|
151
|
+
"tool_name": tool_name,
|
|
152
|
+
"risk_description": risk_description,
|
|
153
|
+
"decision": decision,
|
|
154
|
+
"reason": reason,
|
|
155
|
+
"mechanism": mechanism,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
return decision, reason
|
|
159
|
+
|
|
160
|
+
async def _decide(
|
|
161
|
+
self,
|
|
162
|
+
tool_name: str,
|
|
163
|
+
arguments: dict[str, Any],
|
|
164
|
+
*,
|
|
165
|
+
command: str | None,
|
|
166
|
+
path: str | None,
|
|
167
|
+
python_module: str | None,
|
|
168
|
+
ask: AskCallback | None,
|
|
169
|
+
risk_description: str,
|
|
170
|
+
default_allow: bool,
|
|
171
|
+
session: Session | None,
|
|
172
|
+
) -> tuple[PermissionDecision, str | None, DecisionMechanism]:
|
|
173
|
+
"""The actual decision logic, unchanged from before check_with_reason
|
|
174
|
+
was split in two - only the return shape grew a third element
|
|
175
|
+
(`mechanism`, for check_with_reason's own audit recording). Every
|
|
176
|
+
early return here corresponds to one DecisionMechanism value."""
|
|
177
|
+
if not self._within_rate_limit():
|
|
178
|
+
return "deny", "rate limit exceeded (max_tool_calls_per_minute)", "rate_limit"
|
|
179
|
+
|
|
180
|
+
if command is not None:
|
|
181
|
+
result = self.guardrails.evaluate_command(command)
|
|
182
|
+
if not result.allowed:
|
|
183
|
+
return "deny", result.reason, "guardrail_command"
|
|
184
|
+
|
|
185
|
+
if path is not None:
|
|
186
|
+
result = self.guardrails.evaluate_path(path)
|
|
187
|
+
if not result.allowed:
|
|
188
|
+
return "deny", result.reason, "guardrail_path"
|
|
189
|
+
|
|
190
|
+
if python_module is not None:
|
|
191
|
+
result = self.guardrails.evaluate_python_module(python_module)
|
|
192
|
+
if not result.allowed:
|
|
193
|
+
return "deny", result.reason, "guardrail_python_module"
|
|
194
|
+
|
|
195
|
+
if default_allow:
|
|
196
|
+
return "allow", None, "default_allow"
|
|
197
|
+
|
|
198
|
+
existing = self.policy.check(tool_name)
|
|
199
|
+
if existing is not None:
|
|
200
|
+
reason = "previously denied and remembered" if existing == "deny" else None
|
|
201
|
+
return existing, reason, "remembered_policy"
|
|
202
|
+
|
|
203
|
+
if ask is None:
|
|
204
|
+
# No UI available to ask through -> fail closed.
|
|
205
|
+
return "deny", "no UI available to request approval", "no_ui_fail_closed"
|
|
206
|
+
|
|
207
|
+
decision, remember_scope = await ask(tool_name, arguments, risk_description)
|
|
208
|
+
if remember_scope is not None and remember_scope != "once":
|
|
209
|
+
self.policy.remember(tool_name, scope=remember_scope, decision=decision)
|
|
210
|
+
if session is not None:
|
|
211
|
+
session.permission_grants.append(
|
|
212
|
+
PermissionGrant(tool_name=tool_name, scope=remember_scope, decision=decision)
|
|
213
|
+
)
|
|
214
|
+
reason = "denied by the user" if decision == "deny" else None
|
|
215
|
+
return decision, reason, "interactive"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tracks 'always allow/deny' grants (persisted to disk) and per-session
|
|
2
|
+
grants (in-memory only for the life of the process)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from pcli.config.paths import permissions_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StoredGrant(BaseModel):
|
|
17
|
+
tool_name: str
|
|
18
|
+
argument_pattern: str | None = None
|
|
19
|
+
decision: Literal["allow", "deny"] = "allow"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PermissionPolicy:
|
|
23
|
+
def __init__(self, *, persist_path: Path | None = None) -> None:
|
|
24
|
+
self._persist_path = persist_path or permissions_file()
|
|
25
|
+
self._always_grants: list[StoredGrant] = self._load()
|
|
26
|
+
self._session_grants: list[StoredGrant] = []
|
|
27
|
+
|
|
28
|
+
def _load(self) -> list[StoredGrant]:
|
|
29
|
+
if not self._persist_path.exists():
|
|
30
|
+
return []
|
|
31
|
+
raw = json.loads(self._persist_path.read_text(encoding="utf-8"))
|
|
32
|
+
return [StoredGrant.model_validate(g) for g in raw]
|
|
33
|
+
|
|
34
|
+
def _save(self) -> None:
|
|
35
|
+
self._persist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
content = json.dumps([g.model_dump() for g in self._always_grants], indent=2)
|
|
37
|
+
tmp_path = self._persist_path.with_suffix(".tmp")
|
|
38
|
+
tmp_path.write_text(content, encoding="utf-8")
|
|
39
|
+
os.replace(tmp_path, self._persist_path)
|
|
40
|
+
|
|
41
|
+
def remember(
|
|
42
|
+
self,
|
|
43
|
+
tool_name: str,
|
|
44
|
+
*,
|
|
45
|
+
scope: Literal["session", "always"],
|
|
46
|
+
decision: Literal["allow", "deny"] = "allow",
|
|
47
|
+
argument_pattern: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
grant = StoredGrant(
|
|
50
|
+
tool_name=tool_name, argument_pattern=argument_pattern, decision=decision
|
|
51
|
+
)
|
|
52
|
+
if scope == "always":
|
|
53
|
+
self._always_grants.append(grant)
|
|
54
|
+
self._save()
|
|
55
|
+
else:
|
|
56
|
+
self._session_grants.append(grant)
|
|
57
|
+
|
|
58
|
+
def check(
|
|
59
|
+
self, tool_name: str, argument_pattern: str | None = None
|
|
60
|
+
) -> Literal["allow", "deny"] | None:
|
|
61
|
+
for grant in (*self._session_grants, *self._always_grants):
|
|
62
|
+
if grant.tool_name != tool_name:
|
|
63
|
+
continue
|
|
64
|
+
if grant.argument_pattern is not None and grant.argument_pattern != argument_pattern:
|
|
65
|
+
continue
|
|
66
|
+
return grant.decision
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def session_grants_snapshot(self) -> list[StoredGrant]:
|
|
70
|
+
return list(self._session_grants)
|
pcli/sandbox/__init__.py
ADDED
|
File without changes
|
pcli/sandbox/base.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Common interface both sandbox backends implement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ExecRequest:
|
|
12
|
+
command: list[str] | str
|
|
13
|
+
"""A list is executed directly (argv, no shell); a str is run through the
|
|
14
|
+
platform shell (needed for pipes/redirection in ad hoc shell commands)."""
|
|
15
|
+
cwd: Path
|
|
16
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
timeout_s: float = 30.0
|
|
18
|
+
stdin: str | None = None
|
|
19
|
+
network: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ExecResult:
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
exit_code: int
|
|
27
|
+
timed_out: bool
|
|
28
|
+
backend_used: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SandboxCapabilities:
|
|
33
|
+
supports_network_isolation: bool
|
|
34
|
+
supports_memory_limit: bool
|
|
35
|
+
supports_cpu_limit: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Sandbox(ABC):
|
|
39
|
+
name: str = "base"
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def execute(self, request: ExecRequest) -> ExecResult: ...
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def capabilities(self) -> SandboxCapabilities: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SandboxSecurityError(Exception):
|
|
49
|
+
"""Raised when a request would escape the sandbox's containment (e.g. a
|
|
50
|
+
cwd outside the allowed roots). Distinct from a command simply failing."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Docker-backed sandbox: one-shot `docker run --rm` per invocation, network
|
|
2
|
+
off by default, bind-mounted working directory, resource limits.
|
|
3
|
+
|
|
4
|
+
Shells out to the `docker` CLI rather than depending on the `docker` SDK, so
|
|
5
|
+
this module has no hard dependency beyond the `docker` binary being on PATH.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
from pcli.sandbox.base import ExecRequest, ExecResult, Sandbox, SandboxCapabilities
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _truncate(data: bytes, max_bytes: int) -> str:
|
|
16
|
+
if len(data) <= max_bytes:
|
|
17
|
+
return data.decode(errors="replace")
|
|
18
|
+
return data[:max_bytes].decode(errors="replace") + "\n[...output truncated...]"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DockerSandbox(Sandbox):
|
|
22
|
+
name = "docker"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
image: str = "python:3.12-slim",
|
|
28
|
+
memory: str = "512m",
|
|
29
|
+
cpus: str = "1",
|
|
30
|
+
pids_limit: int = 256,
|
|
31
|
+
max_output_bytes: int = 2_000_000,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._image = image
|
|
34
|
+
self._memory = memory
|
|
35
|
+
self._cpus = cpus
|
|
36
|
+
self._pids_limit = pids_limit
|
|
37
|
+
self._max_output_bytes = max_output_bytes
|
|
38
|
+
|
|
39
|
+
def capabilities(self) -> SandboxCapabilities:
|
|
40
|
+
return SandboxCapabilities(
|
|
41
|
+
supports_network_isolation=True, supports_memory_limit=True, supports_cpu_limit=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def execute(self, request: ExecRequest) -> ExecResult:
|
|
45
|
+
cwd = request.cwd.expanduser().resolve()
|
|
46
|
+
docker_args = [
|
|
47
|
+
"docker",
|
|
48
|
+
"run",
|
|
49
|
+
"--rm",
|
|
50
|
+
"-i",
|
|
51
|
+
"--network",
|
|
52
|
+
"bridge" if request.network else "none",
|
|
53
|
+
"--memory",
|
|
54
|
+
self._memory,
|
|
55
|
+
"--cpus",
|
|
56
|
+
self._cpus,
|
|
57
|
+
"--pids-limit",
|
|
58
|
+
str(self._pids_limit),
|
|
59
|
+
"-v",
|
|
60
|
+
f"{cwd}:/workspace:rw",
|
|
61
|
+
"-w",
|
|
62
|
+
"/workspace",
|
|
63
|
+
]
|
|
64
|
+
for key, value in request.env.items():
|
|
65
|
+
docker_args += ["-e", f"{key}={value}"]
|
|
66
|
+
docker_args.append(self._image)
|
|
67
|
+
if isinstance(request.command, str):
|
|
68
|
+
docker_args += ["sh", "-c", request.command]
|
|
69
|
+
else:
|
|
70
|
+
docker_args += list(request.command)
|
|
71
|
+
|
|
72
|
+
proc = await asyncio.create_subprocess_exec(
|
|
73
|
+
*docker_args,
|
|
74
|
+
stdin=asyncio.subprocess.PIPE if request.stdin is not None else asyncio.subprocess.DEVNULL,
|
|
75
|
+
stdout=asyncio.subprocess.PIPE,
|
|
76
|
+
stderr=asyncio.subprocess.PIPE,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
timed_out = False
|
|
80
|
+
stdin_bytes = request.stdin.encode() if request.stdin is not None else None
|
|
81
|
+
try:
|
|
82
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
83
|
+
proc.communicate(stdin_bytes), timeout=request.timeout_s
|
|
84
|
+
)
|
|
85
|
+
except TimeoutError:
|
|
86
|
+
timed_out = True
|
|
87
|
+
proc.kill()
|
|
88
|
+
try:
|
|
89
|
+
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
90
|
+
except TimeoutError:
|
|
91
|
+
pass
|
|
92
|
+
stdout_bytes, stderr_bytes = b"", b"[pcli] command timed out and was killed"
|
|
93
|
+
except asyncio.CancelledError:
|
|
94
|
+
# See the identical comment in subprocess_backend.py: this fires
|
|
95
|
+
# on caller-initiated cancellation (e.g. Esc+Esc), not our own
|
|
96
|
+
# wait_for's timeout — without killing here, the `docker run`
|
|
97
|
+
# process (and likely its container) is left running.
|
|
98
|
+
proc.kill()
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
return ExecResult(
|
|
102
|
+
stdout=_truncate(stdout_bytes, self._max_output_bytes),
|
|
103
|
+
stderr=_truncate(stderr_bytes, self._max_output_bytes),
|
|
104
|
+
exit_code=proc.returncode if proc.returncode is not None else -1,
|
|
105
|
+
timed_out=timed_out,
|
|
106
|
+
backend_used=self.name,
|
|
107
|
+
)
|
pcli/sandbox/limits.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Cross-platform resource-limiting helpers.
|
|
2
|
+
|
|
3
|
+
POSIX gets real rlimits via preexec_fn. Windows has no equivalent stdlib API
|
|
4
|
+
(no `resource` module) — it relies solely on the wall-clock timeout plus
|
|
5
|
+
psutil-based process-tree cleanup, which works identically on both platforms
|
|
6
|
+
and is the actual safety net in RestrictedSubprocessSandbox either way.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
import psutil
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_posix() -> bool:
|
|
18
|
+
return sys.platform != "win32"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_posix_preexec_fn(
|
|
22
|
+
*, cpu_seconds: int | None, memory_bytes: int | None
|
|
23
|
+
) -> Callable[[], None] | None:
|
|
24
|
+
if not is_posix():
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
def _preexec() -> None:
|
|
28
|
+
import resource
|
|
29
|
+
|
|
30
|
+
# No os.setsid() here: the caller already passes start_new_session=True
|
|
31
|
+
# to Popen/create_subprocess_exec, which calls setsid() itself right
|
|
32
|
+
# after forking, before running this preexec_fn. Calling it again on
|
|
33
|
+
# a process that's already a session leader raises EPERM, which
|
|
34
|
+
# crashes the whole preexec_fn (surfaces as "Exception occurred in
|
|
35
|
+
# preexec_fn.") and every single shell command would fail.
|
|
36
|
+
if cpu_seconds is not None:
|
|
37
|
+
try:
|
|
38
|
+
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
|
|
39
|
+
except (ValueError, OSError):
|
|
40
|
+
pass
|
|
41
|
+
if memory_bytes is not None:
|
|
42
|
+
try:
|
|
43
|
+
resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
|
|
44
|
+
except (ValueError, OSError):
|
|
45
|
+
pass # not supported on every POSIX platform (notably macOS)
|
|
46
|
+
|
|
47
|
+
return _preexec
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def kill_process_tree(pid: int) -> None:
|
|
51
|
+
try:
|
|
52
|
+
parent = psutil.Process(pid)
|
|
53
|
+
except psutil.NoSuchProcess:
|
|
54
|
+
return
|
|
55
|
+
for child in parent.children(recursive=True):
|
|
56
|
+
try:
|
|
57
|
+
child.kill()
|
|
58
|
+
except psutil.NoSuchProcess:
|
|
59
|
+
pass
|
|
60
|
+
try:
|
|
61
|
+
parent.kill()
|
|
62
|
+
except psutil.NoSuchProcess:
|
|
63
|
+
pass
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""No-op sandbox backend: runs commands directly, with none of
|
|
2
|
+
RestrictedSubprocessSandbox's containment (no cwd jail, no env scrubbing, no
|
|
3
|
+
resource limits) or Docker's isolation.
|
|
4
|
+
|
|
5
|
+
Selected via sandbox_backend = "none" — an explicit, documented opt-out for
|
|
6
|
+
users who already run pcli somewhere they trust fully (e.g. inside their own
|
|
7
|
+
disposable container/VM), never the default. Guardrails and the permission
|
|
8
|
+
system still gate tool calls as usual; this only removes the *execution*
|
|
9
|
+
containment layer underneath them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from pcli.sandbox.base import ExecRequest, ExecResult, Sandbox, SandboxCapabilities
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _truncate(data: bytes, max_bytes: int) -> str:
|
|
21
|
+
if len(data) <= max_bytes:
|
|
22
|
+
return data.decode(errors="replace")
|
|
23
|
+
return data[:max_bytes].decode(errors="replace") + "\n[...output truncated...]"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NullSandbox(Sandbox):
|
|
27
|
+
name = "none"
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, max_output_bytes: int = 2_000_000) -> None:
|
|
30
|
+
self._max_output_bytes = max_output_bytes
|
|
31
|
+
|
|
32
|
+
def capabilities(self) -> SandboxCapabilities:
|
|
33
|
+
return SandboxCapabilities(
|
|
34
|
+
supports_network_isolation=False,
|
|
35
|
+
supports_memory_limit=False,
|
|
36
|
+
supports_cpu_limit=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def execute(self, request: ExecRequest) -> ExecResult:
|
|
40
|
+
# Full inherited environment (unlike RestrictedSubprocessSandbox's
|
|
41
|
+
# allowlist scrub) plus any extra vars the caller asked for layered
|
|
42
|
+
# on top — "no sandbox" means no restriction, not no environment.
|
|
43
|
+
env = {**os.environ, **request.env}
|
|
44
|
+
|
|
45
|
+
if isinstance(request.command, str):
|
|
46
|
+
proc = await asyncio.create_subprocess_shell(
|
|
47
|
+
request.command,
|
|
48
|
+
cwd=str(request.cwd),
|
|
49
|
+
env=env,
|
|
50
|
+
stdin=asyncio.subprocess.PIPE
|
|
51
|
+
if request.stdin is not None
|
|
52
|
+
else asyncio.subprocess.DEVNULL,
|
|
53
|
+
stdout=asyncio.subprocess.PIPE,
|
|
54
|
+
stderr=asyncio.subprocess.PIPE,
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
proc = await asyncio.create_subprocess_exec(
|
|
58
|
+
*request.command,
|
|
59
|
+
cwd=str(request.cwd),
|
|
60
|
+
env=env,
|
|
61
|
+
stdin=asyncio.subprocess.PIPE
|
|
62
|
+
if request.stdin is not None
|
|
63
|
+
else asyncio.subprocess.DEVNULL,
|
|
64
|
+
stdout=asyncio.subprocess.PIPE,
|
|
65
|
+
stderr=asyncio.subprocess.PIPE,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
timed_out = False
|
|
69
|
+
stdin_bytes = request.stdin.encode() if request.stdin is not None else None
|
|
70
|
+
try:
|
|
71
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
72
|
+
proc.communicate(stdin_bytes), timeout=request.timeout_s
|
|
73
|
+
)
|
|
74
|
+
except TimeoutError:
|
|
75
|
+
timed_out = True
|
|
76
|
+
try:
|
|
77
|
+
proc.kill()
|
|
78
|
+
except ProcessLookupError:
|
|
79
|
+
pass
|
|
80
|
+
try:
|
|
81
|
+
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
82
|
+
except TimeoutError:
|
|
83
|
+
pass
|
|
84
|
+
stdout_bytes, stderr_bytes = b"", b"[pcli] command timed out and was killed"
|
|
85
|
+
|
|
86
|
+
return ExecResult(
|
|
87
|
+
stdout=_truncate(stdout_bytes, self._max_output_bytes),
|
|
88
|
+
stderr=_truncate(stderr_bytes, self._max_output_bytes),
|
|
89
|
+
exit_code=proc.returncode if proc.returncode is not None else -1,
|
|
90
|
+
timed_out=timed_out,
|
|
91
|
+
backend_used=self.name,
|
|
92
|
+
)
|
pcli/sandbox/selector.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Chooses a Sandbox backend once at startup: Docker if reachable, else the
|
|
2
|
+
restricted-subprocess fallback. The chosen backend is surfaced in the status
|
|
3
|
+
bar so the user always knows the isolation level in effect."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pcli.sandbox.base import Sandbox
|
|
12
|
+
from pcli.sandbox.docker_backend import DockerSandbox
|
|
13
|
+
from pcli.sandbox.null_backend import NullSandbox
|
|
14
|
+
from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def probe_docker_available(*, timeout_s: float = 1.5) -> bool:
|
|
18
|
+
"""True only for a reachable Docker daemon running Linux containers.
|
|
19
|
+
DockerSandbox shells out to Linux-only assumptions (`python3`, a
|
|
20
|
+
`/workspace` bind mount, `--pids-limit` - a cgroups-only flag) that
|
|
21
|
+
error out immediately against a daemon in Windows-container mode
|
|
22
|
+
(Docker Desktop's other mode, and what GitHub's windows-latest runners
|
|
23
|
+
default to) rather than just failing to start. Checking the daemon's
|
|
24
|
+
OSType here means that setup falls back to RestrictedSubprocessSandbox
|
|
25
|
+
automatically instead of every tool call failing with a cryptic Docker
|
|
26
|
+
CLI error."""
|
|
27
|
+
if shutil.which("docker") is None:
|
|
28
|
+
return False
|
|
29
|
+
try:
|
|
30
|
+
proc = await asyncio.create_subprocess_exec(
|
|
31
|
+
"docker",
|
|
32
|
+
"info",
|
|
33
|
+
"--format",
|
|
34
|
+
"{{.OSType}}",
|
|
35
|
+
stdout=asyncio.subprocess.PIPE,
|
|
36
|
+
stderr=asyncio.subprocess.PIPE,
|
|
37
|
+
)
|
|
38
|
+
stdout, _stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
|
|
39
|
+
return proc.returncode == 0 and stdout.decode().strip() == "linux"
|
|
40
|
+
except (TimeoutError, OSError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def select_sandbox(
|
|
45
|
+
*,
|
|
46
|
+
backend_override: str = "auto",
|
|
47
|
+
allowed_roots: list[Path] | None = None,
|
|
48
|
+
cpu_limit_s: int | None = 30,
|
|
49
|
+
memory_limit_bytes: int | None = None,
|
|
50
|
+
) -> Sandbox:
|
|
51
|
+
"""cpu_limit_s/memory_limit_bytes only affect RestrictedSubprocessSandbox
|
|
52
|
+
(POSIX only) - see Settings.sandbox_cpu_limit_s/sandbox_memory_limit_bytes
|
|
53
|
+
for why memory_limit_bytes defaults to None (RLIMIT_AS's virtual-vs-actual
|
|
54
|
+
memory mismatch breaks ordinary Go-based CLI tool calls, not just
|
|
55
|
+
runaway ones)."""
|
|
56
|
+
if backend_override == "docker":
|
|
57
|
+
return DockerSandbox()
|
|
58
|
+
if backend_override == "subprocess":
|
|
59
|
+
return RestrictedSubprocessSandbox(
|
|
60
|
+
allowed_roots=allowed_roots, cpu_seconds=cpu_limit_s, memory_bytes=memory_limit_bytes
|
|
61
|
+
)
|
|
62
|
+
if backend_override == "none":
|
|
63
|
+
return NullSandbox()
|
|
64
|
+
if backend_override not in ("auto", ""):
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Unknown sandbox_backend '{backend_override}' — valid values are 'auto', 'docker', "
|
|
67
|
+
"'subprocess', or 'none'. Set sandbox_backend in config.toml or via "
|
|
68
|
+
"PCLI_SANDBOX_BACKEND."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if await probe_docker_available():
|
|
72
|
+
return DockerSandbox()
|
|
73
|
+
return RestrictedSubprocessSandbox(
|
|
74
|
+
allowed_roots=allowed_roots, cpu_seconds=cpu_limit_s, memory_bytes=memory_limit_bytes
|
|
75
|
+
)
|