sliceagent 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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/policy.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Permission policy — authorization for tool calls.
|
|
2
|
+
|
|
3
|
+
An ordered chain of small policies. Each inspects (name, args) and returns a
|
|
4
|
+
ToolDecision to DENY, or None to abstain (defer to the next policy). First denial
|
|
5
|
+
wins; if every policy abstains, the call is allowed. The chain is a plain callable,
|
|
6
|
+
so it drops straight into hooks.PermissionHook(policy) — the loop and the moat are
|
|
7
|
+
untouched.
|
|
8
|
+
|
|
9
|
+
This is AUTHORIZATION (what's allowed at all). It's distinct from SAFE EXECUTION
|
|
10
|
+
(workspace path confinement + the sandbox), which lives in tools.py / sandbox.py.
|
|
11
|
+
Defense in depth: the policy denies catastrophic commands early with a clear reason;
|
|
12
|
+
the ToolHost still confines paths even if a policy abstains.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shlex
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
from .agents import READ_ONLY_TOOLS # single source of truth for the known read-only surface
|
|
22
|
+
from .hooks import ToolDecision
|
|
23
|
+
|
|
24
|
+
ALLOW = ToolDecision(True)
|
|
25
|
+
|
|
26
|
+
WRITE_TOOLS = frozenset(("edit_file", "append_to_file", "str_replace"))
|
|
27
|
+
# every tool that runs a model-authored shell command/body — the catastrophic denylist must gate ALL of
|
|
28
|
+
# them, not just run_command/execute_code (proc_start/terminal_open/terminal_send execute shell verbatim too).
|
|
29
|
+
EXEC_TOOLS = frozenset(("run_command", "execute_code", "proc_start", "terminal_open", "terminal_send"))
|
|
30
|
+
|
|
31
|
+
# Patterns that are almost never legitimate inside a coding-agent workspace.
|
|
32
|
+
# Kept deliberately narrow so normal dev commands (pytest, pip, npm, git add/commit,
|
|
33
|
+
# rm of a workspace file, mkdir, mv) pass untouched.
|
|
34
|
+
# INVARIANT: every pattern is compiled case-INSENSITIVE (the comprehension below). The floor must not be
|
|
35
|
+
# defeated by casing — on a case-insensitive filesystem (macOS default) `SHUTDOWN` / `/ETC/PASSWD` resolve
|
|
36
|
+
# to the real command/path, so a case-sensitive denylist is a genuine bypass. A pattern needing DOTALL
|
|
37
|
+
# carries an inline `(?s)`.
|
|
38
|
+
# SCOPE: this denylist is a BEST-EFFORT defense-in-depth SPEED BUMP, not a complete sandbox. A regex cannot
|
|
39
|
+
# catch every shell encoding (globs, $(...), subshells, var-expansion, exotic wrappers); chasing each is a
|
|
40
|
+
# losing arms race. The BINDING guards are the sandbox (network=none fail-closed, cwd-confine, secret-scrub)
|
|
41
|
+
# and the permission modes (baby-sitter/teenager CONFIRM every command by default). Patterns here catch the
|
|
42
|
+
# obvious/common forms so an unattended (let-it-go) run still has a floor.
|
|
43
|
+
_DANGEROUS_SRC: list[tuple[str, str]] = [
|
|
44
|
+
(r"(?s):\s*\(\s*\)\s*\{.*\|.*&", "fork bomb"),
|
|
45
|
+
(r"\bsudo\b", "privilege escalation (sudo)"),
|
|
46
|
+
(r"\b(shutdown|reboot|halt|poweroff)\b", "system power control"),
|
|
47
|
+
(r"\b(mkfs|wipefs)\b", "filesystem format"),
|
|
48
|
+
(r"\bdd\b[^\n]*\bof=/dev/", "raw write to a device"),
|
|
49
|
+
(r">\s*/dev/(sd|nvme|disk|hd)", "raw write to a device"),
|
|
50
|
+
# any RECURSIVE rm targeting / ~ $HOME /* .. (workspace-relative rm is fine). Catches -rf, split "-r -f",
|
|
51
|
+
# and long-form --recursive — the recursive flag (short -...r... or --recursive) anywhere before the target.
|
|
52
|
+
(r"\brm\b(?=[^|;&\n]*(?:\s-[a-z]*r|\s--recursive))"
|
|
53
|
+
r"[^|;&\n]*\s(?:/|~|\$HOME|/\*|\.\.)(?=[\s/*'\"]|$)", "recursive delete of / ~ or parent"),
|
|
54
|
+
# chmod/chown on / — flags are OPTIONAL (plain `chmod 755 /` / `chown nobody /` are just as catastrophic
|
|
55
|
+
# as the -R forms), and long-form flags (--recursive) count too. The target must be the bare root.
|
|
56
|
+
(r"\bchmod\b\s+(?:-{1,2}[a-z]+\s+)*[0-7]{3,4}\s+/(?:[\s/*'\"]|$)", "chmod on /"),
|
|
57
|
+
(r"\bchown\b\s+[^\n]*\s/(?:[\s/*'\"]|$)", "chown on /"),
|
|
58
|
+
# remote code piped straight into a shell
|
|
59
|
+
(r"\b(curl|wget|fetch)\b[^|\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|python\d?)\b", "remote script piped to a shell"),
|
|
60
|
+
# writes to / reads of sensitive locations
|
|
61
|
+
(r">>?\s*/etc/", "write to /etc"),
|
|
62
|
+
# credential files + their common glob/prefix forms (cat /etc/pass*, /etc/shadow, /etc/sudoers.d, …)
|
|
63
|
+
(r"/etc/(?:passwd|shadow|gshadow|sudoers|pass[\w*]*|shad[\w*]*|sudoer[\w*]*)", "access to system credential files"),
|
|
64
|
+
(r"(\.ssh/|id_rsa|id_ed25519|\.aws/credentials|\.netrc)", "access to private keys/credentials"),
|
|
65
|
+
(r"\bgit\b[^\n]*\bpush\b[^\n]*(--force\b|--force-with-lease\b|\s-f\b)", "force push"),
|
|
66
|
+
]
|
|
67
|
+
# Compile ALL case-insensitively in one place — uniform, so a future pattern can't silently reintroduce a
|
|
68
|
+
# casing bypass (the round-3 bug: shutdown/mkfs/dd/etc had no IGNORECASE while rm/chmod/curl did).
|
|
69
|
+
_DANGEROUS: list[tuple[re.Pattern, str]] = [(re.compile(p, re.IGNORECASE), why) for p, why in _DANGEROUS_SRC]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# DENY-BY-DEFAULT reader allowlist for readonly/ask modes. Keying on a mutator NAME set let unknown
|
|
73
|
+
# plugin/MCP tools (and even known mutating builtins absent from the small WRITE/EXEC sets — terminal_*,
|
|
74
|
+
# proc_*, world_set, update_plan, …) slip past these safety modes. Allow ONLY known-safe readers; treat
|
|
75
|
+
# everything else (including any unknown tool) as a mutation. ask_user is interactive, not a mutation.
|
|
76
|
+
_READERS = frozenset(READ_ONLY_TOOLS) | {"ask_user"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_only(name: str, args: dict) -> Optional[ToolDecision]:
|
|
80
|
+
"""Deny anything that is not a KNOWN read-only tool (deny-by-default — an unknown tool may mutate)."""
|
|
81
|
+
if name not in _READERS:
|
|
82
|
+
return ToolDecision(False, "read-only mode: only read/list/search tools are allowed")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ask_mutations(name: str, args: dict) -> Optional[ToolDecision]:
|
|
87
|
+
"""Confirm any tool that is not a KNOWN read-only tool (an unknown tool may modify state / run code)."""
|
|
88
|
+
if name not in _READERS:
|
|
89
|
+
return ToolDecision(False, f"{name} is not a known read-only tool (may modify state or run code)",
|
|
90
|
+
ask=True)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Provably read-only shell verbs — a `run_command` whose verb is one of these (and which contains no
|
|
95
|
+
# shell metacharacter that could chain/redirect/substitute) reports state without changing it, so teenager
|
|
96
|
+
# mode runs it without a confirm prompt. Deny-by-default: an unknown verb still asks. The catastrophic floor
|
|
97
|
+
# (no_dangerous_commands) runs BEFORE this, so e.g. `cat /etc/passwd` is still denied.
|
|
98
|
+
_RO_VERBS = frozenset((
|
|
99
|
+
"ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "env", "printenv", "date",
|
|
100
|
+
"whoami", "hostname", "uname", "id", "groups", "tree", "stat", "file", "du", "df", "realpath",
|
|
101
|
+
"dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "uniq", "nl", "cut", "column",
|
|
102
|
+
"cksum", "md5", "md5sum", "sha1sum", "sha256sum", "true",
|
|
103
|
+
))
|
|
104
|
+
# git subcommands with NO mutating form (excludes branch/tag/config/remote/stash/reflog, which can write).
|
|
105
|
+
_RO_GIT_SUB = frozenset((
|
|
106
|
+
"status", "log", "diff", "show", "rev-parse", "ls-files", "ls-tree", "describe", "blame",
|
|
107
|
+
"shortlog", "rev-list", "cat-file", "for-each-ref", "name-rev", "whatchanged", "count-objects",
|
|
108
|
+
"var", "ls-remote", "grep",
|
|
109
|
+
))
|
|
110
|
+
# find ACTIONS that run/delete (anything else is a read-only traversal)
|
|
111
|
+
_FIND_MUTATORS = frozenset(("-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprintf", "-fprint", "-fls", "-fprint0"))
|
|
112
|
+
_SHELL_META = re.compile(r"[;&|<>`\n]|\$\(|\$\{|\breturn\b")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_readonly_command(cmd: str) -> bool:
|
|
116
|
+
"""True only when `cmd` PROVABLY just reads/reports state — a tiny verb allowlist with no shell
|
|
117
|
+
metacharacters. Conservative by design: anything unrecognized returns False (→ still confirmed)."""
|
|
118
|
+
cmd = (cmd or "").strip()
|
|
119
|
+
if not cmd or _SHELL_META.search(cmd):
|
|
120
|
+
return False
|
|
121
|
+
try:
|
|
122
|
+
toks = shlex.split(cmd)
|
|
123
|
+
except ValueError:
|
|
124
|
+
return False
|
|
125
|
+
if not toks:
|
|
126
|
+
return False
|
|
127
|
+
verb = os.path.basename(toks[0])
|
|
128
|
+
if verb == "git":
|
|
129
|
+
sub = next((t for t in toks[1:] if not t.startswith("-")), "")
|
|
130
|
+
return sub in _RO_GIT_SUB
|
|
131
|
+
if verb == "find":
|
|
132
|
+
return not any(t in _FIND_MUTATORS for t in toks)
|
|
133
|
+
return verb in _RO_VERBS
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ask_commands(name: str, args: dict) -> Optional[ToolDecision]:
|
|
137
|
+
"""'teenager' middle ground: auto-allow known file EDITS + reads, but confirm anything that RUNS a
|
|
138
|
+
command (EXEC tools) or is an unknown tool that might. Edits flow; running code pauses for a yes —
|
|
139
|
+
EXCEPT a provably read-only `run_command` (git status, ls, cat, …), which reports state without
|
|
140
|
+
changing it and so runs without a prompt (kills the confirm-hang on 'which repo am I in')."""
|
|
141
|
+
if name in _READERS or name in WRITE_TOOLS:
|
|
142
|
+
return None
|
|
143
|
+
if name == "run_command" and _is_readonly_command(str(args.get("command") or "")):
|
|
144
|
+
return None
|
|
145
|
+
return ToolDecision(False, f"{name} runs a command — confirm before it executes", ask=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def no_dangerous_commands(name: str, args: dict) -> Optional[ToolDecision]:
|
|
149
|
+
"""Deny shell commands (or execute_code bodies) matching a narrow catastrophic list.
|
|
150
|
+
Scanning arbitrary Python is best-effort — it catches obvious run('rm -rf /')-style
|
|
151
|
+
bodies; the sandbox (cwd confine, secret scrub, timeout) is the real guardrail."""
|
|
152
|
+
if name not in EXEC_TOOLS:
|
|
153
|
+
return None
|
|
154
|
+
cmd = str(args.get("command") or args.get("code") or args.get("input") or "") # input = terminal_send line
|
|
155
|
+
for pat, reason in _DANGEROUS:
|
|
156
|
+
if pat.search(cmd):
|
|
157
|
+
return ToolDecision(False, f"blocked dangerous command: {reason}")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PolicyChain:
|
|
162
|
+
"""An ordered list of `policy(name, args) -> ToolDecision | None`. Callable so it
|
|
163
|
+
plugs directly into hooks.PermissionHook. First denial wins; all-abstain → ALLOW."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, *policies: Callable[[str, dict], Optional[ToolDecision]]):
|
|
166
|
+
self.policies = policies
|
|
167
|
+
|
|
168
|
+
def __call__(self, name: str, args: dict) -> ToolDecision:
|
|
169
|
+
for p in self.policies:
|
|
170
|
+
d = p(name, args)
|
|
171
|
+
if d is not None and (not d.allow or d.ask): # deny OR ask short-circuits
|
|
172
|
+
return d
|
|
173
|
+
return ALLOW
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Three USER-FACING modes, all sharing the catastrophic-command floor (no fully-unrestricted UI mode).
|
|
177
|
+
# `allow`/`readonly` remain as LEGACY/eval escapes, not advertised. Friendly + legacy names both resolve.
|
|
178
|
+
USER_MODES = ("baby-sitter", "teenager", "let-it-go")
|
|
179
|
+
_MODE_ALIASES = {
|
|
180
|
+
"baby-sitter": "babysitter", "babysitter": "babysitter", "baby": "babysitter", "ask": "babysitter",
|
|
181
|
+
"teenager": "teenager", "teen": "teenager",
|
|
182
|
+
"let-it-go": "letitgo", "letitgo": "letitgo", "letgo": "letitgo", "yolo": "letitgo", "guard": "letitgo",
|
|
183
|
+
"allow": "allow", "readonly": "readonly", # legacy escapes
|
|
184
|
+
}
|
|
185
|
+
_MODE_LABELS = {"babysitter": "baby-sitter", "teenager": "teenager", "letitgo": "let-it-go",
|
|
186
|
+
"allow": "allow", "readonly": "readonly"}
|
|
187
|
+
# canonical → True if the mode confirms (needs an interactive resolver; downgrade to let-it-go when headless)
|
|
188
|
+
CONFIRMS = {"babysitter": True, "teenager": True, "letitgo": False, "allow": False, "readonly": False}
|
|
189
|
+
|
|
190
|
+
# Legacy names still RESOLVE (back-compat for old configs/eval) but their connotation differs from the new
|
|
191
|
+
# modes — warn LOUDLY so a name like `guard` can't SILENTLY downgrade safety (it now means let-it-go = auto).
|
|
192
|
+
_LEGACY_WARN = {
|
|
193
|
+
"guard": "AGENT_POLICY=guard is legacy and now maps to 'let-it-go' (auto-runs everything except "
|
|
194
|
+
"catastrophic commands). For confirmations use 'baby-sitter' or 'teenager'.",
|
|
195
|
+
"ask": "AGENT_POLICY=ask is legacy → use 'baby-sitter' (confirm every edit + command).",
|
|
196
|
+
"allow": "AGENT_POLICY=allow is a legacy permissive eval mode (NO catastrophic floor) — for normal use "
|
|
197
|
+
"pick baby-sitter / teenager / let-it-go.",
|
|
198
|
+
"readonly": "AGENT_POLICY=readonly is a legacy mode (no writes/exec).",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def legacy_warning(name: str) -> Optional[str]:
|
|
203
|
+
"""A loud deprecation note when a LEGACY mode name is used, so a safety-connoting name like `guard` can't
|
|
204
|
+
silently resolve to a more permissive mode. None for the current friendly names."""
|
|
205
|
+
return _LEGACY_WARN.get((name or "").strip().lower().replace("_", "-").replace(" ", "-"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_policy_mode(name: str) -> Optional[str]:
|
|
209
|
+
"""Friendly/legacy mode name → canonical key, or None if unrecognized (the caller warns + defaults)."""
|
|
210
|
+
return _MODE_ALIASES.get((name or "").strip().lower().replace("_", "-").replace(" ", "-"))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def policy_label(canonical: str) -> str:
|
|
214
|
+
"""Canonical key → friendly display name for the toolbar/help."""
|
|
215
|
+
return _MODE_LABELS.get(canonical, canonical)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def make_policy(mode: str = "teenager") -> PolicyChain:
|
|
219
|
+
"""Three modes, ALL with the catastrophic-command floor:
|
|
220
|
+
baby-sitter — confirm every edit + command; teenager — auto edits, confirm commands;
|
|
221
|
+
let-it-go — auto everything except catastrophic. (legacy: allow=permissive, readonly=no writes.)"""
|
|
222
|
+
canonical = resolve_policy_mode(mode)
|
|
223
|
+
if canonical == "babysitter":
|
|
224
|
+
return PolicyChain(no_dangerous_commands, ask_mutations) # catastrophic→deny, every write/exec→ask
|
|
225
|
+
if canonical == "teenager":
|
|
226
|
+
return PolicyChain(no_dangerous_commands, ask_commands) # catastrophic→deny, commands→ask, edits auto
|
|
227
|
+
if canonical == "letitgo":
|
|
228
|
+
return PolicyChain(no_dangerous_commands) # catastrophic→deny, everything else auto
|
|
229
|
+
if canonical == "allow":
|
|
230
|
+
return PolicyChain() # LEGACY: fully permissive (eval)
|
|
231
|
+
if canonical == "readonly":
|
|
232
|
+
return PolicyChain(read_only) # LEGACY: no writes/exec
|
|
233
|
+
# #28: a typo'd mode must NOT silently fall back to a weaker policy than intended.
|
|
234
|
+
raise ValueError(f"unknown policy mode {mode!r} (expected one of {USER_MODES})")
|
sliceagent/procman.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""procman — background / long-running processes for the agent (the gap the one-shot
|
|
2
|
+
``Sandbox.run`` can't fill).
|
|
3
|
+
|
|
4
|
+
``Sandbox.run`` blocks and returns only on exit, so two whole classes of work are
|
|
5
|
+
inexpressible: (1) "start a server, then probe it" (the server never exits), and (2)
|
|
6
|
+
multi-minute builds that overrun the run timeout and come back as exit 124. ``ProcManager``
|
|
7
|
+
keeps live children in a registry keyed by a short handle (``p1``, ``p2``, …) so the agent
|
|
8
|
+
can start a process, keep it alive across turns, ``poll`` / ``tail`` / ``wait``, then ``kill``.
|
|
9
|
+
|
|
10
|
+
Local subprocess backend (the eval path); cwd-confined and secret-env-scrubbed exactly like
|
|
11
|
+
``LocalSandbox``. Output streams to a temp LOGFILE (not a pipe) so ``tail``/``wait`` can read it
|
|
12
|
+
AFTER the call returns — a ``Popen`` pipe would deadlock once its OS buffer fills. Children run
|
|
13
|
+
in their own process group (``start_new_session=True``) so ``kill`` takes down the whole tree
|
|
14
|
+
(a server that forks workers, a build that spawns sub-makes). ``PYTHONUNBUFFERED`` is forced so
|
|
15
|
+
Python children flush to the logfile promptly instead of after exit.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
|
|
24
|
+
from .sandbox import _scrub_env
|
|
25
|
+
|
|
26
|
+
_TAIL_CHARS = 4000 # cap a tail read so a chatty process can't flood the slice
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Proc:
|
|
30
|
+
__slots__ = ("handle", "cmd", "popen", "log_path", "log_fh")
|
|
31
|
+
|
|
32
|
+
def __init__(self, handle: str, cmd: str, popen, log_path: str, log_fh):
|
|
33
|
+
self.handle = handle
|
|
34
|
+
self.cmd = cmd
|
|
35
|
+
self.popen = popen
|
|
36
|
+
self.log_path = log_path
|
|
37
|
+
self.log_fh = log_fh
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProcManager:
|
|
41
|
+
"""Registry of live background processes. Not threadsafe (the agent loop is single-threaded)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, scrub_secrets: bool = True):
|
|
44
|
+
self.scrub_secrets = scrub_secrets
|
|
45
|
+
self._procs: dict[str, _Proc] = {}
|
|
46
|
+
self._n = 0
|
|
47
|
+
|
|
48
|
+
# ── lifecycle ──────────────────────────────────────────────────────────
|
|
49
|
+
def start(self, command: str, *, cwd: str) -> str:
|
|
50
|
+
"""Launch `command` in the background; return a handle. Non-blocking."""
|
|
51
|
+
self._n += 1
|
|
52
|
+
handle = f"p{self._n}"
|
|
53
|
+
fd, log_path = tempfile.mkstemp(prefix=f".sliceagent-{handle}-", suffix=".log")
|
|
54
|
+
log_fh = os.fdopen(fd, "wb")
|
|
55
|
+
env = _scrub_env() if self.scrub_secrets else dict(os.environ)
|
|
56
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
57
|
+
try:
|
|
58
|
+
popen = self._spawn_proc(command, cwd, env, log_fh)
|
|
59
|
+
except BaseException: # spawn failed (bad cwd, exec error, container-seam failure) — release the fd + temp file
|
|
60
|
+
try:
|
|
61
|
+
log_fh.close()
|
|
62
|
+
finally:
|
|
63
|
+
try:
|
|
64
|
+
os.unlink(log_path)
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
raise
|
|
68
|
+
self._procs[handle] = _Proc(handle, command, popen, log_path, log_fh)
|
|
69
|
+
return handle
|
|
70
|
+
|
|
71
|
+
def _spawn_proc(self, command, cwd, env, log_fh):
|
|
72
|
+
"""Launch the background process. OVERRIDABLE SEAM: a container variant relaunches via
|
|
73
|
+
`docker exec` so the process runs INSIDE the task container, streaming to this host logfile."""
|
|
74
|
+
return subprocess.Popen(
|
|
75
|
+
command, shell=True, cwd=cwd, env=env,
|
|
76
|
+
stdin=subprocess.DEVNULL, stdout=log_fh, stderr=subprocess.STDOUT,
|
|
77
|
+
start_new_session=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def poll(self, handle: str) -> str:
|
|
81
|
+
p = self._get(handle)
|
|
82
|
+
rc = p.popen.poll()
|
|
83
|
+
if rc is not None and p.log_fh is not None: # self-exited proc: release the write fd now (don't leak it to cleanup)
|
|
84
|
+
try:
|
|
85
|
+
p.log_fh.close()
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
pass
|
|
88
|
+
p.log_fh = None
|
|
89
|
+
return "running" if rc is None else f"exited {rc}"
|
|
90
|
+
|
|
91
|
+
def tail(self, handle: str, lines: int = 40) -> str:
|
|
92
|
+
p = self._get(handle)
|
|
93
|
+
body = self._read_log(p, lines)
|
|
94
|
+
return f"[{handle} {self.poll(handle)}]\n{body}"
|
|
95
|
+
|
|
96
|
+
def wait(self, handle: str, timeout: float) -> str:
|
|
97
|
+
p = self._get(handle)
|
|
98
|
+
try:
|
|
99
|
+
rc = p.popen.wait(timeout=timeout)
|
|
100
|
+
status = f"exited {rc}"
|
|
101
|
+
self.poll(handle) # release the write fd on self-exit (mirror poll/kill) — else wait() leaks one fd/proc
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
status = f"running (still alive after {timeout:g}s)"
|
|
104
|
+
return f"[{handle} {status}]\n{self._read_log(p, 40)}"
|
|
105
|
+
|
|
106
|
+
def kill(self, handle: str) -> str:
|
|
107
|
+
p = self._get(handle)
|
|
108
|
+
if p.popen.poll() is None:
|
|
109
|
+
self._signal_group(p, signal.SIGTERM)
|
|
110
|
+
try:
|
|
111
|
+
p.popen.wait(timeout=3)
|
|
112
|
+
except subprocess.TimeoutExpired:
|
|
113
|
+
self._signal_group(p, signal.SIGKILL)
|
|
114
|
+
try:
|
|
115
|
+
p.popen.wait(timeout=2)
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
pass
|
|
118
|
+
status = self.poll(handle)
|
|
119
|
+
# Release the open FD now (the process is dead, so nothing more writes the log) — a long session that
|
|
120
|
+
# starts/kills many procs would otherwise leak one fd per cycle, marching toward EMFILE. Keep the
|
|
121
|
+
# registry entry + on-disk log so proc_poll/proc_tail still work after a kill (the confirm-after-kill
|
|
122
|
+
# UX); cleanup() unlinks the temp file at session end.
|
|
123
|
+
if p.log_fh is not None:
|
|
124
|
+
try:
|
|
125
|
+
p.log_fh.close()
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
pass
|
|
128
|
+
p.log_fh = None
|
|
129
|
+
return f"killed {handle} ({status})"
|
|
130
|
+
|
|
131
|
+
def list(self) -> str:
|
|
132
|
+
if not self._procs:
|
|
133
|
+
return "(no background processes)"
|
|
134
|
+
return "\n".join(f"{h}: {self.poll(h)} — {p.cmd}" for h, p in self._procs.items())
|
|
135
|
+
|
|
136
|
+
def cleanup(self) -> None:
|
|
137
|
+
"""Kill every live child and remove its logfile. Call at session end; never raises."""
|
|
138
|
+
for h in list(self._procs):
|
|
139
|
+
try:
|
|
140
|
+
self.kill(h)
|
|
141
|
+
except Exception: # noqa: BLE001 — best-effort teardown
|
|
142
|
+
pass
|
|
143
|
+
p = self._procs.pop(h, None)
|
|
144
|
+
if not p:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
p.log_fh.close()
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
pass
|
|
150
|
+
try:
|
|
151
|
+
os.unlink(p.log_path)
|
|
152
|
+
except OSError:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
# ── internals ──────────────────────────────────────────────────────────
|
|
156
|
+
def _get(self, handle: str) -> _Proc:
|
|
157
|
+
p = self._procs.get(handle)
|
|
158
|
+
if p is None:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"unknown process handle {handle!r}. Live: {', '.join(self._procs) or '(none)'}")
|
|
161
|
+
return p
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _read_log(p: _Proc, lines: int) -> str:
|
|
165
|
+
try:
|
|
166
|
+
p.log_fh.flush()
|
|
167
|
+
except Exception: # noqa: BLE001
|
|
168
|
+
pass
|
|
169
|
+
try:
|
|
170
|
+
with open(p.log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
171
|
+
data = f.read()
|
|
172
|
+
except OSError:
|
|
173
|
+
data = ""
|
|
174
|
+
tail = "\n".join(data.splitlines()[-max(1, lines):])
|
|
175
|
+
if len(tail) > _TAIL_CHARS:
|
|
176
|
+
tail = "…[earlier output elided]…\n" + tail[-_TAIL_CHARS:]
|
|
177
|
+
return tail or "(no output yet)"
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _signal_group(p: _Proc, sig: int) -> None:
|
|
181
|
+
try:
|
|
182
|
+
os.killpg(os.getpgid(p.popen.pid), sig)
|
|
183
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
184
|
+
try:
|
|
185
|
+
p.popen.send_signal(sig)
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|