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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. 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)
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
+ )
@@ -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
+ )