pcode 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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/runtime.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Application events and the offline fixture runtime; no terminal imports."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Message:
|
|
8
|
+
markdown: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ToolStarted:
|
|
13
|
+
name: str
|
|
14
|
+
detail: str
|
|
15
|
+
call_id: str
|
|
16
|
+
command: str = ""
|
|
17
|
+
arguments: str | None = None
|
|
18
|
+
run_id: str = ""
|
|
19
|
+
started_at: str = ""
|
|
20
|
+
process_id: str = ""
|
|
21
|
+
parent_call_id: str = ""
|
|
22
|
+
activity: str = ""
|
|
23
|
+
# What the model said it is for. Kept beside `command` rather than folded
|
|
24
|
+
# into it: `command` is rendered as a shell invocation, so it has to stay
|
|
25
|
+
# literal enough to copy and run.
|
|
26
|
+
purpose: str = ""
|
|
27
|
+
# "background" or "foreground" for command tools, "" for everything else:
|
|
28
|
+
# whether the model asked for a job handle or waited on the command.
|
|
29
|
+
execution: str = ""
|
|
30
|
+
# A delegation's sub-agent and its assignment (its stated purpose when it
|
|
31
|
+
# gave one), "" for every other tool.
|
|
32
|
+
# Sessions saved before these existed restore without them.
|
|
33
|
+
agent: str = ""
|
|
34
|
+
task: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ToolSummary:
|
|
39
|
+
name: str
|
|
40
|
+
detail: str
|
|
41
|
+
failed: bool = False
|
|
42
|
+
call_id: str = ""
|
|
43
|
+
elapsed_seconds: float | None = None
|
|
44
|
+
error: str = ""
|
|
45
|
+
command: str = ""
|
|
46
|
+
result: str | None = None
|
|
47
|
+
run_id: str = ""
|
|
48
|
+
outcome: str = ""
|
|
49
|
+
process_id: str = ""
|
|
50
|
+
parent_call_id: str = ""
|
|
51
|
+
purpose: str = ""
|
|
52
|
+
# Background completion notices share the normal command-result rendering.
|
|
53
|
+
execution: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class JobFinished(ToolSummary):
|
|
58
|
+
"""A delayed command completion, not another tool invocation."""
|
|
59
|
+
|
|
60
|
+
execution: str = "background"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class EditCompleted:
|
|
65
|
+
"""Sanitized historical change; raw file snapshots are never journaled."""
|
|
66
|
+
|
|
67
|
+
call_id: str
|
|
68
|
+
path: str
|
|
69
|
+
operation: str
|
|
70
|
+
patch: str = ""
|
|
71
|
+
added: int = 0
|
|
72
|
+
removed: int = 0
|
|
73
|
+
truncated: bool = False
|
|
74
|
+
omitted: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class EditPreview:
|
|
79
|
+
"""Proposed content only, never a committed change or a journal entry."""
|
|
80
|
+
|
|
81
|
+
call_id: str
|
|
82
|
+
path: str = ""
|
|
83
|
+
text: str = ""
|
|
84
|
+
# "edit" carries +/- lines for a file; "code" carries a sandboxed snippet
|
|
85
|
+
# that has not run yet. Both are pending tool arguments, never results.
|
|
86
|
+
kind: str = "edit"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class CommandOutput:
|
|
91
|
+
"""Transient sanitized snapshot, never saved in the transcript journal."""
|
|
92
|
+
|
|
93
|
+
call_id: str
|
|
94
|
+
command: str
|
|
95
|
+
output: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class TextDelta:
|
|
100
|
+
text: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class ThinkingDelta:
|
|
105
|
+
"""Provider-exposed reasoning text, persisted even when hidden."""
|
|
106
|
+
|
|
107
|
+
text: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Thinking:
|
|
112
|
+
"""Completion marker for one readable thinking block (never a signature)."""
|
|
113
|
+
|
|
114
|
+
text: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class CacheBust:
|
|
119
|
+
"""An observed cache collapse, not a diagnosis of what changed the prompt."""
|
|
120
|
+
|
|
121
|
+
text: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class RunStatus:
|
|
126
|
+
text: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class PlanUpdated:
|
|
131
|
+
"""Authoritative Harness plan snapshot, not a parsed tool summary."""
|
|
132
|
+
|
|
133
|
+
items: list[dict]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class PlanPreview:
|
|
138
|
+
"""Display-only streamed plan; None restores the authoritative snapshot."""
|
|
139
|
+
|
|
140
|
+
items: list[dict] | None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class ChildPlan:
|
|
145
|
+
"""Display-only snapshot of a running sub-agent's plan, keyed by its delegate call.
|
|
146
|
+
|
|
147
|
+
Never journaled: the child's plan lives only as long as its run, and the
|
|
148
|
+
conversation's own plan is `PlanUpdated`.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
call_id: str
|
|
152
|
+
items: list[dict]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class ChildText:
|
|
157
|
+
"""A running sub-agent's own prose or reasoning, keyed by its delegate call.
|
|
158
|
+
|
|
159
|
+
Display-only and never journaled: it feeds the worker viewer, never the
|
|
160
|
+
conversation's transcript. `start` opens a new part; otherwise the text
|
|
161
|
+
continues the previous part of the same kind.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
call_id: str
|
|
165
|
+
text: str
|
|
166
|
+
thinking: bool = False
|
|
167
|
+
start: bool = False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
Event = (
|
|
171
|
+
Message
|
|
172
|
+
| ToolStarted
|
|
173
|
+
| ToolSummary
|
|
174
|
+
| TextDelta
|
|
175
|
+
| ThinkingDelta
|
|
176
|
+
| Thinking
|
|
177
|
+
| RunStatus
|
|
178
|
+
| CacheBust
|
|
179
|
+
| PlanUpdated
|
|
180
|
+
| PlanPreview
|
|
181
|
+
| ChildPlan
|
|
182
|
+
| ChildText
|
|
183
|
+
| CommandOutput
|
|
184
|
+
| EditCompleted
|
|
185
|
+
| EditPreview
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PreviewRuntime:
|
|
190
|
+
def __init__(self) -> None:
|
|
191
|
+
self.turns = 0
|
|
192
|
+
|
|
193
|
+
def reset(self) -> None:
|
|
194
|
+
self.turns = 0
|
|
195
|
+
|
|
196
|
+
def reply(self, prompt: str) -> tuple[Event, ...]:
|
|
197
|
+
self.turns += 1
|
|
198
|
+
return (
|
|
199
|
+
Message(
|
|
200
|
+
"This is a **local UI preview**, not a connected model. "
|
|
201
|
+
f"Your {len(prompt.splitlines())}-line message arrived intact.\n\n"
|
|
202
|
+
"Try `/theme-preview` for a sample coding response, "
|
|
203
|
+
"`/theme light` to change the palette, "
|
|
204
|
+
"or type `/` to browse commands. Nothing is sent anywhere."
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def demo(self) -> tuple[Event, ...]:
|
|
209
|
+
self.turns += 1
|
|
210
|
+
return (
|
|
211
|
+
Message("Here’s a **sample coding response**. Tool activity below is fictional."),
|
|
212
|
+
ToolSummary("Read", "example/greeting.py · 8 lines · preview only"),
|
|
213
|
+
ToolSummary("Edit", "example/greeting.py · +2 −1 · preview only"),
|
|
214
|
+
Message(
|
|
215
|
+
"## Theme preview\n\n"
|
|
216
|
+
"The greeting now handles an empty name with `greet(name)`. "
|
|
217
|
+
"Here is **bold**, *italic*, and a "
|
|
218
|
+
"[Rich documentation link](https://rich.readthedocs.io/).\n\n"
|
|
219
|
+
"> Quotes, inline code, and links follow the selected color style.\n\n"
|
|
220
|
+
"```python\n"
|
|
221
|
+
"def greet(name: str) -> str:\n"
|
|
222
|
+
' name = name.strip() or "world"\n'
|
|
223
|
+
' return f"Hello, {name}!"\n'
|
|
224
|
+
"```\n\n"
|
|
225
|
+
"```diff\n"
|
|
226
|
+
'- return f"Hello, {name}!"\n'
|
|
227
|
+
'+ name = name.strip() or "world"\n'
|
|
228
|
+
'+ return f"Hello, {name}!"\n'
|
|
229
|
+
"```\n\n"
|
|
230
|
+
"| Input | Result |\n"
|
|
231
|
+
"| --- | --- |\n"
|
|
232
|
+
"| `Ada` | Hello, Ada! |\n"
|
|
233
|
+
"| empty | Hello, world! |\n"
|
|
234
|
+
"| `世界` | Hello, 世界! |\n\n"
|
|
235
|
+
"- Markdown, code, and this table reflow when the terminal is resized.\n"
|
|
236
|
+
"- Try `/theme light` or `/theme dark`, "
|
|
237
|
+
"then `/theme-preview` again to compare syntax colors.\n"
|
|
238
|
+
"- `/syntax monokai` restyles code and chrome for the palette in use, "
|
|
239
|
+
"`/syntax terminal` hands every color back to the terminal; "
|
|
240
|
+
"the gallery below samples every style.\n"
|
|
241
|
+
"- Use terminal/tmux scrollback to compare previous output.\n\n"
|
|
242
|
+
"**No files were read or changed, and no tests were executed.**"
|
|
243
|
+
),
|
|
244
|
+
)
|
pcode/sandbox.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""One write/read policy for the file tools and the model's shell commands.
|
|
2
|
+
|
|
3
|
+
The bundled `sandbox` extension enforces it twice: a tool hook refuses file-tool
|
|
4
|
+
writes (and reads of credential files) the policy rejects, and every `shell`
|
|
5
|
+
command's job supervisor runs under an OS sandbox generated from the same
|
|
6
|
+
policy (Seatbelt on macOS, bubblewrap on Linux), so the two cannot drift.
|
|
7
|
+
|
|
8
|
+
Writes are allowed under the write roots: the workspace, its repository's main
|
|
9
|
+
checkout (which holds every `.worktrees/` sibling), temp, cache and package
|
|
10
|
+
directories, entries in `sandbox.json`, and session grants from `/allow-writes`.
|
|
11
|
+
pcode's config directory, any `.pcode/` directory and any `.git/hooks/` stay
|
|
12
|
+
read-only even inside a root, because writing there would let the model
|
|
13
|
+
disable the extension or run code outside the sandbox later. Only a root
|
|
14
|
+
granted at or below one of those paths reopens it. Reads are open except a deny
|
|
15
|
+
list of credential files.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import glob
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import posixpath
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from pcode.preferences import config_dir
|
|
34
|
+
|
|
35
|
+
# Process-wide so a grant survives `/reload`, which re-imports the extension.
|
|
36
|
+
SESSION_GRANTS: list[Path] = []
|
|
37
|
+
|
|
38
|
+
DEFAULT_DENY_READ = (
|
|
39
|
+
"~/.ssh/id_*",
|
|
40
|
+
"~/.aws",
|
|
41
|
+
"~/.gnupg",
|
|
42
|
+
"~/.netrc",
|
|
43
|
+
"~/.config/gh/hosts.yml",
|
|
44
|
+
"~/.docker/config.json",
|
|
45
|
+
"~/.codex/auth.json",
|
|
46
|
+
"~/.claude/.credentials.json",
|
|
47
|
+
"~/.local/share/uv/credentials",
|
|
48
|
+
)
|
|
49
|
+
CACHE_DIRS = ("~/.cache", "~/Library/Caches", "~/.npm")
|
|
50
|
+
# Directory names that stay read-only wherever they appear: `.pcode` holds
|
|
51
|
+
# project extensions (one named `sandbox` would replace this one) and
|
|
52
|
+
# preferences; git hooks run later, outside any sandbox.
|
|
53
|
+
GUARDED = ((".pcode",), (".git", "hooks"))
|
|
54
|
+
_GUARDED_REGEXES = (r"/\.pcode(/|$)", r"/\.git/hooks(/|$)")
|
|
55
|
+
_DEVICE_WRITES = (
|
|
56
|
+
'(literal "/dev/null")',
|
|
57
|
+
'(literal "/dev/zero")',
|
|
58
|
+
'(literal "/dev/dtracehelper")',
|
|
59
|
+
# Opening a new pseudo-terminal writes /dev/ptmx; without it `script`,
|
|
60
|
+
# `expect` and Python's `pty.openpty()` fail ("out of pty devices").
|
|
61
|
+
'(literal "/dev/ptmx")',
|
|
62
|
+
'(regex #"^/dev/tty")',
|
|
63
|
+
'(regex #"^/dev/fd/")',
|
|
64
|
+
)
|
|
65
|
+
_GLOB = re.compile(r"[*?\[]")
|
|
66
|
+
_REGEX_SPECIAL = set(".^$+(){}|\\")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def config_path() -> Path:
|
|
70
|
+
return config_dir() / "sandbox.json"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_config() -> dict:
|
|
74
|
+
"""`sandbox.json`, or {} when absent. A malformed file raises ValueError."""
|
|
75
|
+
path = config_path()
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
return {}
|
|
80
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
81
|
+
raise ValueError(f"{path}: {error}") from error
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
raise ValueError(f"{path}: expected a JSON object")
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def add_global_grant(path: Path) -> None:
|
|
88
|
+
"""Append `path` to `sandbox.json`'s write list, for every future session."""
|
|
89
|
+
data = load_config()
|
|
90
|
+
write = [str(entry) for entry in data.get("write", [])]
|
|
91
|
+
if str(path) not in write:
|
|
92
|
+
write.append(str(path))
|
|
93
|
+
data["write"] = write
|
|
94
|
+
target = config_path()
|
|
95
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
pending = target.with_suffix(".tmp")
|
|
97
|
+
pending.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
98
|
+
pending.replace(target)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def real(path: str | Path, base: Path | None = None) -> Path:
|
|
102
|
+
"""Absolute, `~`-expanded, symlink-resolved; relative paths join `base`."""
|
|
103
|
+
candidate = Path(path).expanduser()
|
|
104
|
+
if not candidate.is_absolute() and base is not None:
|
|
105
|
+
candidate = base / candidate
|
|
106
|
+
return Path(os.path.realpath(candidate))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def tool_target(path: str, workspace: Path) -> Path:
|
|
110
|
+
"""Where a file tool's `path` leads, resolved the way Harness's file tools do.
|
|
111
|
+
|
|
112
|
+
Harness joins and normalizes as text first (`Workspace.resolve`: `link/..`
|
|
113
|
+
collapses before any symlink is followed, and `~` is not expanded), then
|
|
114
|
+
follows symlinks. `real` follows `link` before applying `..`, which can name
|
|
115
|
+
a different file than the tool touches.
|
|
116
|
+
"""
|
|
117
|
+
joined = posixpath.normpath(posixpath.join(str(workspace), path))
|
|
118
|
+
return Path(os.path.realpath(joined))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def within(path: Path, root: Path) -> bool:
|
|
122
|
+
return path == root or path.is_relative_to(root)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def base_roots(workspace: Path) -> list[Path]:
|
|
126
|
+
"""The roots every session starts with. Computed once: it shells out to git.
|
|
127
|
+
|
|
128
|
+
Under a remote profile the main checkout is not one: its `.git/config`
|
|
129
|
+
would let the model choose code that git runs outside the sandbox (in the
|
|
130
|
+
host, the listener, and the owner's next git command). Only what a
|
|
131
|
+
commit in the session's own worktree needs is writable (`remote_git`).
|
|
132
|
+
"""
|
|
133
|
+
from pcode import remote_profile
|
|
134
|
+
from pcode.worktree import repo_scope
|
|
135
|
+
|
|
136
|
+
workspace = real(workspace)
|
|
137
|
+
if remote_profile.active() is not None:
|
|
138
|
+
roots = [workspace, *remote_git(workspace)[0]]
|
|
139
|
+
else:
|
|
140
|
+
roots = [workspace, real(repo_scope(workspace))]
|
|
141
|
+
temp = real(tempfile.gettempdir())
|
|
142
|
+
roots += [temp, real("/tmp"), real("/var/tmp")]
|
|
143
|
+
# macOS keeps a per-user cache dir (`.../C`) beside `$TMPDIR` (`.../T`).
|
|
144
|
+
if temp.parent.is_relative_to("/private/var/folders"):
|
|
145
|
+
roots.append(temp.parent)
|
|
146
|
+
roots += [real(entry) for entry in CACHE_DIRS]
|
|
147
|
+
roots += package_stores()
|
|
148
|
+
return _unique(roots)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def remote_git(workspace: Path) -> tuple[list[Path], list[Path]]:
|
|
152
|
+
"""(writable, protected) git paths for a remote session's worktree.
|
|
153
|
+
|
|
154
|
+
Writable: the object store, the worktree's own admin directory, and its
|
|
155
|
+
branch's ref and reflog (with their lock files). Protected: what tells
|
|
156
|
+
git where the repository is (the worktree's `.git` file, `commondir`,
|
|
157
|
+
`gitdir`) and the per-worktree config, which could name commands to run.
|
|
158
|
+
"""
|
|
159
|
+
from pcode.worktree import describe
|
|
160
|
+
|
|
161
|
+
tree = describe(workspace)
|
|
162
|
+
if tree is None:
|
|
163
|
+
return [], []
|
|
164
|
+
|
|
165
|
+
def git_path(*args: str) -> Path:
|
|
166
|
+
result = subprocess.run(
|
|
167
|
+
["git", "-C", str(tree.path), "rev-parse", "--path-format=absolute", *args],
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
check=True,
|
|
171
|
+
)
|
|
172
|
+
return real(result.stdout.strip())
|
|
173
|
+
|
|
174
|
+
common, admin = git_path("--git-common-dir"), git_path("--git-dir")
|
|
175
|
+
writable = [common / "objects", admin]
|
|
176
|
+
if tree.branch and not tree.branch.startswith("("):
|
|
177
|
+
for base in (common / "refs" / "heads", common / "logs" / "refs" / "heads"):
|
|
178
|
+
ref = base / tree.branch
|
|
179
|
+
writable += [ref, ref.with_name(ref.name + ".lock")]
|
|
180
|
+
protected = [
|
|
181
|
+
real(tree.path) / ".git",
|
|
182
|
+
admin / "commondir",
|
|
183
|
+
admin / "gitdir",
|
|
184
|
+
admin / "config.worktree",
|
|
185
|
+
]
|
|
186
|
+
return writable, protected
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def profile_protected(base: Iterable[Path]) -> list[Path]:
|
|
190
|
+
"""The git paths a remote session may not write, for the worktree among `base`."""
|
|
191
|
+
for root in base:
|
|
192
|
+
if (root / ".git").exists():
|
|
193
|
+
return remote_git(root)[1]
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def package_stores() -> list[Path]:
|
|
198
|
+
"""Go's and Cargo's download stores, where builds fetch dependencies.
|
|
199
|
+
|
|
200
|
+
Directories of executables on PATH (`~/go/bin`, `~/.cargo/bin`,
|
|
201
|
+
`~/.local/bin`, the Homebrew prefix) and uv's data dir (pcode's own tool
|
|
202
|
+
venv, managed Pythons) stay out: code planted there runs later outside
|
|
203
|
+
the sandbox.
|
|
204
|
+
"""
|
|
205
|
+
gopath = Path(_env_dir("GOPATH", "~/go"))
|
|
206
|
+
cargo = Path(_env_dir("CARGO_HOME", "~/.cargo"))
|
|
207
|
+
stores = [
|
|
208
|
+
_env_dir("GOMODCACHE", str(gopath / "pkg" / "mod")),
|
|
209
|
+
gopath / "pkg" / "sumdb",
|
|
210
|
+
cargo / "registry",
|
|
211
|
+
cargo / "git",
|
|
212
|
+
]
|
|
213
|
+
return [real(store) for store in stores]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _env_dir(name: str, default: str) -> str:
|
|
217
|
+
"""An env var naming a directory (the first, for a list like GOPATH).
|
|
218
|
+
|
|
219
|
+
Ignored unless absolute, as Go does: a relative path would resolve
|
|
220
|
+
against pcode's directory rather than the tool's.
|
|
221
|
+
"""
|
|
222
|
+
value = os.path.expanduser(os.environ.get(name, "").split(os.pathsep)[0])
|
|
223
|
+
return value if os.path.isabs(value) else default
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def glob_regex(pattern: str) -> str:
|
|
227
|
+
"""A path glob as an anchored regex matching it and anything beneath it.
|
|
228
|
+
|
|
229
|
+
`*` and `?` stay within one path component. The same text is valid in
|
|
230
|
+
Python and in Seatbelt's `(regex #"...")`.
|
|
231
|
+
"""
|
|
232
|
+
out = []
|
|
233
|
+
for char in pattern:
|
|
234
|
+
if char == "*":
|
|
235
|
+
out.append("[^/]*")
|
|
236
|
+
elif char == "?":
|
|
237
|
+
out.append("[^/]")
|
|
238
|
+
elif char in _REGEX_SPECIAL:
|
|
239
|
+
out.append("\\" + char)
|
|
240
|
+
else:
|
|
241
|
+
out.append(char)
|
|
242
|
+
return "^" + "".join(out) + "(/|$)"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _expand_pattern(pattern: str) -> str:
|
|
246
|
+
"""`~` expanded and the literal directory part symlink-resolved."""
|
|
247
|
+
pattern = os.path.expanduser(pattern)
|
|
248
|
+
match = _GLOB.search(pattern)
|
|
249
|
+
if match is None:
|
|
250
|
+
return str(real(pattern))
|
|
251
|
+
head, _, tail = pattern[: match.start()].rpartition("/")
|
|
252
|
+
return str(real(head or "/")).rstrip("/") + "/" + tail + pattern[match.start() :]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class Policy:
|
|
257
|
+
write: list[Path]
|
|
258
|
+
protected: list[Path]
|
|
259
|
+
deny_read: list[str]
|
|
260
|
+
# Mach services a sandboxed command may not look up (macOS only).
|
|
261
|
+
deny_services: list[str] = field(default_factory=list)
|
|
262
|
+
# Directories whose Unix sockets a sandboxed command may not connect to:
|
|
263
|
+
# session hosts take commands (shell mode among them) over theirs.
|
|
264
|
+
deny_connect: list[Path] = field(default_factory=list)
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def build(
|
|
268
|
+
cls,
|
|
269
|
+
base: Iterable[Path],
|
|
270
|
+
config: dict | None = None,
|
|
271
|
+
grants: Iterable[Path] = (),
|
|
272
|
+
) -> Policy:
|
|
273
|
+
from pcode import remote_profile
|
|
274
|
+
|
|
275
|
+
config = load_config() if config is None else config
|
|
276
|
+
home = config_dir()
|
|
277
|
+
deny = config.get("deny_read")
|
|
278
|
+
if deny is None:
|
|
279
|
+
deny = [
|
|
280
|
+
*DEFAULT_DENY_READ,
|
|
281
|
+
str(home / "credentials.json"),
|
|
282
|
+
str(home / "mcp-credentials.json"),
|
|
283
|
+
]
|
|
284
|
+
services: list[str] = []
|
|
285
|
+
sockets: list[Path] = []
|
|
286
|
+
protected = [real(home)]
|
|
287
|
+
# A remote host's additions apply whatever sandbox.json says.
|
|
288
|
+
if (profile := remote_profile.active()) is not None:
|
|
289
|
+
from pcode.host_protocol import host_dir
|
|
290
|
+
|
|
291
|
+
hosts = str(host_dir())
|
|
292
|
+
deny = [*deny, *profile.deny_read, hosts, "~/Library/Keychains"]
|
|
293
|
+
services = list(remote_profile.KEYCHAIN_SERVICES)
|
|
294
|
+
sockets = [real(path) for path in (*profile.deny_read, hosts)]
|
|
295
|
+
protected += profile_protected(base)
|
|
296
|
+
return cls(
|
|
297
|
+
write=_unique([*base, *(real(entry) for entry in config.get("write", [])), *grants]),
|
|
298
|
+
protected=_unique(protected),
|
|
299
|
+
deny_read=[_expand_pattern(str(entry)) for entry in deny],
|
|
300
|
+
deny_services=services,
|
|
301
|
+
deny_connect=sockets,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# -- decisions -----------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def guards(self, path: Path) -> list[Path]:
|
|
307
|
+
"""The read-only zones `path` falls in."""
|
|
308
|
+
found = [zone for zone in self.protected if within(path, zone)]
|
|
309
|
+
parts = path.parts
|
|
310
|
+
for names in GUARDED:
|
|
311
|
+
for index in range(len(parts) - len(names) + 1):
|
|
312
|
+
if parts[index : index + len(names)] == names:
|
|
313
|
+
found.append(Path(*parts[: index + len(names)]))
|
|
314
|
+
return found
|
|
315
|
+
|
|
316
|
+
def can_write(self, path: Path) -> bool:
|
|
317
|
+
roots = [root for root in self.write if within(path, root)]
|
|
318
|
+
if not roots:
|
|
319
|
+
return False
|
|
320
|
+
# A guarded zone is writable only through a root granted inside it.
|
|
321
|
+
return all(any(within(root, zone) for root in roots) for zone in self.guards(path))
|
|
322
|
+
|
|
323
|
+
def can_read(self, path: Path) -> bool:
|
|
324
|
+
text = str(path)
|
|
325
|
+
return not any(re.match(glob_regex(pattern), text) for pattern in self.deny_read)
|
|
326
|
+
|
|
327
|
+
def explain(self) -> str:
|
|
328
|
+
roots = ", ".join(str(root) for root in self.write)
|
|
329
|
+
return f"Writable: {roots}."
|
|
330
|
+
|
|
331
|
+
# -- OS sandboxes --------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
def seatbelt_profile(self, extra_write: Sequence[Path] = ()) -> str:
|
|
334
|
+
roots = [*self.write, *(real(path) for path in extra_write)]
|
|
335
|
+
reopened = [root for root in roots if self.guards(root)]
|
|
336
|
+
lines = [
|
|
337
|
+
"(version 1)",
|
|
338
|
+
"(allow default)",
|
|
339
|
+
"(deny file-write*)",
|
|
340
|
+
_rule("allow file-write*", [*map(_filter, roots), *_DEVICE_WRITES]),
|
|
341
|
+
_rule(
|
|
342
|
+
"deny file-write*",
|
|
343
|
+
[
|
|
344
|
+
*(f"(subpath {_quote(zone)})" for zone in self.protected),
|
|
345
|
+
*(f'(regex #"{regex}")' for regex in _GUARDED_REGEXES),
|
|
346
|
+
],
|
|
347
|
+
),
|
|
348
|
+
]
|
|
349
|
+
if reopened:
|
|
350
|
+
lines.append(_rule("allow file-write*", [_filter(root) for root in reopened]))
|
|
351
|
+
if self.deny_read:
|
|
352
|
+
lines.append(
|
|
353
|
+
_rule(
|
|
354
|
+
"deny file-read*",
|
|
355
|
+
[f'(regex #"{glob_regex(pattern)}")' for pattern in self.deny_read],
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
if self.deny_connect:
|
|
359
|
+
lines.append(
|
|
360
|
+
_rule(
|
|
361
|
+
"deny network-outbound",
|
|
362
|
+
[
|
|
363
|
+
f"(remote unix-socket (subpath {_quote(path)}))"
|
|
364
|
+
for path in self.deny_connect
|
|
365
|
+
],
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
if self.deny_services:
|
|
369
|
+
lines.append(
|
|
370
|
+
_rule(
|
|
371
|
+
"deny mach-lookup",
|
|
372
|
+
[f"(global-name {_quote(name)})" for name in self.deny_services],
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
return "\n".join(lines)
|
|
376
|
+
|
|
377
|
+
def bwrap_args(self, extra_write: Sequence[Path] = ()) -> list[str]:
|
|
378
|
+
"""Linux equivalent. Guarded names are only protected where they exist now."""
|
|
379
|
+
roots = [path for path in (*self.write, *map(real, extra_write)) if path.exists()]
|
|
380
|
+
args = ["--ro-bind", "/", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc"]
|
|
381
|
+
for root in roots:
|
|
382
|
+
args += ["--bind", str(root), str(root)]
|
|
383
|
+
zones = [zone for zone in self.protected if zone.exists()]
|
|
384
|
+
for root in roots:
|
|
385
|
+
if root.is_dir():
|
|
386
|
+
zones += [zone for names in GUARDED if (zone := root.joinpath(*names)).exists()]
|
|
387
|
+
for zone in zones:
|
|
388
|
+
args += ["--ro-bind", str(zone), str(zone)]
|
|
389
|
+
for root in roots:
|
|
390
|
+
if self.guards(root):
|
|
391
|
+
args += ["--bind", str(root), str(root)]
|
|
392
|
+
# A tmpfs over a socket directory hides its sockets, so none can be reached.
|
|
393
|
+
for path in self.deny_connect:
|
|
394
|
+
if path.is_dir():
|
|
395
|
+
args += ["--tmpfs", str(path)]
|
|
396
|
+
for pattern in self.deny_read:
|
|
397
|
+
for match in sorted(glob.glob(pattern)):
|
|
398
|
+
if os.path.isdir(match):
|
|
399
|
+
args += ["--tmpfs", match]
|
|
400
|
+
else:
|
|
401
|
+
args += ["--ro-bind", "/dev/null", match]
|
|
402
|
+
return args
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def backend() -> str | None:
|
|
406
|
+
"""Which OS sandbox this machine offers, or None."""
|
|
407
|
+
if sys.platform == "darwin" and shutil.which("sandbox-exec"):
|
|
408
|
+
return "seatbelt"
|
|
409
|
+
if sys.platform.startswith("linux") and _bwrap():
|
|
410
|
+
return "bwrap"
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _bwrap() -> str | None:
|
|
415
|
+
"""The distribution's bwrap first: Ubuntu 24.04+ lets only `/usr/bin/bwrap`
|
|
416
|
+
create namespaces, and a Homebrew copy earlier on PATH would shadow it."""
|
|
417
|
+
if os.access("/usr/bin/bwrap", os.X_OK):
|
|
418
|
+
return "/usr/bin/bwrap"
|
|
419
|
+
return shutil.which("bwrap")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def command_prefix(policy: Policy, job_directory: Path) -> list[str]:
|
|
423
|
+
"""The argv that runs a job supervisor inside the sandbox.
|
|
424
|
+
|
|
425
|
+
The job directory is writable so the supervisor can publish output and
|
|
426
|
+
status; nothing else beyond the policy is.
|
|
427
|
+
"""
|
|
428
|
+
kind = backend()
|
|
429
|
+
if kind == "seatbelt":
|
|
430
|
+
return [
|
|
431
|
+
shutil.which("sandbox-exec") or "sandbox-exec",
|
|
432
|
+
"-p",
|
|
433
|
+
policy.seatbelt_profile([job_directory]),
|
|
434
|
+
]
|
|
435
|
+
if kind == "bwrap":
|
|
436
|
+
return [_bwrap() or "bwrap", *policy.bwrap_args([job_directory])]
|
|
437
|
+
raise RuntimeError("No OS sandbox is available (sandbox-exec on macOS, bwrap on Linux).")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _filter(path: Path) -> str:
|
|
441
|
+
kind = "literal" if path.is_file() else "subpath"
|
|
442
|
+
return f"({kind} {_quote(path)})"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _quote(path: Path | str) -> str:
|
|
446
|
+
return '"' + str(path).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _rule(head: str, filters: list[str]) -> str:
|
|
450
|
+
return f"({head} " + " ".join(filters) + ")"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _unique(paths: Iterable[Path]) -> list[Path]:
|
|
454
|
+
return list(dict.fromkeys(paths))
|