chad-code 1.11.0__tar.gz → 1.12.0__tar.gz
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.
- {chad_code-1.11.0 → chad_code-1.12.0}/PKG-INFO +1 -1
- {chad_code-1.11.0 → chad_code-1.12.0}/pyproject.toml +1 -1
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/__init__.py +1 -1
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/agent.py +27 -1
- chad_code-1.12.0/src/chad/checkpoint.py +150 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/levers.py +44 -0
- chad_code-1.12.0/src/chad/seatbelt.py +255 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/tools.py +46 -5
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/tui.py +30 -1
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/PKG-INFO +1 -1
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/SOURCES.txt +4 -0
- chad_code-1.12.0/tests/test_checkpoint.py +132 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lever_bite.py +71 -0
- chad_code-1.12.0/tests/test_seatbelt.py +336 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/LICENSE +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/README.md +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/setup.cfg +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/ambient.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/atif.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/base_engine.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/bench.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/cli.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/compaction.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/completion_engine.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/config.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/diag.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/engine.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/guardrails.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/ignore.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/lsp.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/lspclient.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/lspservers.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mcp.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mcp_oauth.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mlx_fastpath.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mlx_moe_fused.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mlx_qsdpa.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/LICENSE +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/__init__.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/alignment.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/attention.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/audio.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/cache.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/conformer.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/ctc.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/parakeet.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/rnnt.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/tokenizer.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/utils.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/profiles.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/prompt.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/prove.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/render.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/repomap.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/serve.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/session.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/skills.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/speech.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/symbols.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/syntaxgate.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/toolcall_parse.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/validate.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/dependency_links.txt +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/entry_points.txt +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/requires.txt +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/top_level.txt +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_agent.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_agent_e2e.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_agent_guards.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_ambient.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_atif.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_bench.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_cli.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_compact_notice.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_compaction.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_completion_engine.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_config.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_confirm_preview.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_done_audit.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_drift_warn.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_edit.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_edit_corruption.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_engine.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_engine_kvquant.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_engine_pld_hybrid.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_feel_pack.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_garble_invariant.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_gate.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_ignore.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_intent.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lever_instrumentation.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_levers.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_log_redaction.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lsp.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lsp_live.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lspclient.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mcp.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mcp_oauth.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mlx_fastpath.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mlx_moe_fused.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mlx_qsdpa.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_plan_review.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_prove.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_render.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_replace_lines.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_repomap.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_repomap_polyglot.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_serve.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_session.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_skills.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_speech.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_speech_tui.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_subagent.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_symbols.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_syntaxgate.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_toolcall_parse.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_tools.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_tui.py +0 -0
- {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_validate.py +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# import name, and command name are independent. `uvx chad-code` runs the alias
|
|
5
5
|
# script added under [project.scripts].
|
|
6
6
|
name = "chad-code"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.12.0"
|
|
8
8
|
description = "Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -4,7 +4,7 @@ A flat collection of cooperating modules behind one console script (``chad``):
|
|
|
4
4
|
the inference engine, the tool layer, the agent loop, and the terminal UI.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "1.
|
|
7
|
+
__version__ = "1.12.0"
|
|
8
8
|
|
|
9
9
|
# chad sets no MLX_* runtime vars. MLX_METAL_FAST_SYNCH, MLX_MAX_OPS_PER_BUFFER
|
|
10
10
|
# and MLX_MAX_MB_PER_BUFFER were each measured end-to-end on the 35B and every
|
|
@@ -14,7 +14,18 @@ import re
|
|
|
14
14
|
import sys
|
|
15
15
|
import time
|
|
16
16
|
|
|
17
|
-
from . import
|
|
17
|
+
from . import (
|
|
18
|
+
ambient,
|
|
19
|
+
atif,
|
|
20
|
+
checkpoint,
|
|
21
|
+
compaction,
|
|
22
|
+
config,
|
|
23
|
+
guardrails,
|
|
24
|
+
levers,
|
|
25
|
+
seatbelt,
|
|
26
|
+
session,
|
|
27
|
+
syntaxgate,
|
|
28
|
+
)
|
|
18
29
|
from .base_engine import BackendError, BaseEngine
|
|
19
30
|
from .diag import args_preview, log, redact, result_preview
|
|
20
31
|
from .prompt import build_subagent_prompt, build_system_prompt, classify_intent
|
|
@@ -1971,12 +1982,27 @@ class Agent:
|
|
|
1971
1982
|
else:
|
|
1972
1983
|
_t0 = time.perf_counter()
|
|
1973
1984
|
self.tool_dispatches += 1
|
|
1985
|
+
# A snapshot happens AFTER approval, immediately before the tool
|
|
1986
|
+
# runs — a denied edit must not leave a checkpoint claiming it ran.
|
|
1987
|
+
if (name in AUTO_EDIT_TOOLS
|
|
1988
|
+
and levers.enabled("edit_checkpoint")):
|
|
1989
|
+
_ref = checkpoint.snapshot(
|
|
1990
|
+
os.getcwd(), f"before {name} {args.get('path', '')}".strip())
|
|
1991
|
+
if _ref:
|
|
1992
|
+
levers.fired("edit_checkpoint", step=step, ref=_ref)
|
|
1993
|
+
# Seatbelt context is scoped to exactly this dispatch: `active`
|
|
1994
|
+
# reflects the mode of the agent actually executing, so a
|
|
1995
|
+
# sub-agent's yolo loop is confined inside a normal-mode parent
|
|
1996
|
+
# turn, and the context can't leak to the `!cmd` passthrough.
|
|
1997
|
+
seatbelt.set_context(self.mode == "yolo", os.getcwd())
|
|
1974
1998
|
try:
|
|
1975
1999
|
result = fn(args, self._should_stop)
|
|
1976
2000
|
if plan_write and result.startswith("[wrote"):
|
|
1977
2001
|
self.last_plan_path = os.path.abspath(args["path"])
|
|
1978
2002
|
except Exception as e: # noqa: BLE001 - surface tool errors to model
|
|
1979
2003
|
result = f"[tool error: {type(e).__name__}: {e}]"
|
|
2004
|
+
finally:
|
|
2005
|
+
seatbelt.set_context(False, None)
|
|
1980
2006
|
_tool_s = time.perf_counter() - _t0
|
|
1981
2007
|
# Backstop: bound the prefill from any tool, AND from the step as a
|
|
1982
2008
|
# whole — several calls in one step stack into one prefill, so later
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Shadow-git checkpoints for file edits (lever: edit_checkpoint).
|
|
2
|
+
|
|
3
|
+
Auto-approved edits are justified as "a diff you can read and revert" — this
|
|
4
|
+
module supplies the revert. Before each file-mutating tool lands, the workspace
|
|
5
|
+
is committed to a *shadow* git repository under ~/.chad/checkpoints/<hash>. The
|
|
6
|
+
user's own .git is never opened, never written, never required to exist: the
|
|
7
|
+
shadow repo has its own GIT_DIR and treats the workspace purely as a work tree.
|
|
8
|
+
/undo and /restore in the TUI check files back out of it.
|
|
9
|
+
|
|
10
|
+
Failure policy: never raise. An edit must not die because the checkpoint
|
|
11
|
+
machinery hiccuped — snapshot() returns None on any failure and the tool call
|
|
12
|
+
proceeds unprotected (logged, and visible in `chad levers` firing counts as the
|
|
13
|
+
absence of a fire).
|
|
14
|
+
|
|
15
|
+
Restore policy (deliberately conservative): `git restore --source=<ref>` puts
|
|
16
|
+
every snapshotted file back to its snapshotted content. Files *created after*
|
|
17
|
+
the snapshot are left in place — deleting files is exactly the blast radius this
|
|
18
|
+
lever exists to contain, so the restore path never does it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("chad")
|
|
28
|
+
|
|
29
|
+
_GIT_TIMEOUT_S = 120 # first snapshot of a big tree is seconds; never hang a turn
|
|
30
|
+
|
|
31
|
+
# Junk that snapshots must not swallow when the workspace has no .gitignore of its
|
|
32
|
+
# own (a non-git project): written to the shadow repo's info/exclude, which
|
|
33
|
+
# composes with any workspace .gitignore rather than replacing it.
|
|
34
|
+
_DEFAULT_EXCLUDES = (".venv/\nvenv/\nnode_modules/\n__pycache__/\n*.pyc\n"
|
|
35
|
+
".mypy_cache/\n.ruff_cache/\n.pytest_cache/\n.DS_Store\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _history_root() -> str:
|
|
39
|
+
# CHAD_CHECKPOINT_DIR: test/e2e override so suites never write real home state.
|
|
40
|
+
# NOT ~/.chad/history — that name is taken by the TUI's prompt-history FILE.
|
|
41
|
+
env = os.environ.get("CHAD_CHECKPOINT_DIR")
|
|
42
|
+
if env:
|
|
43
|
+
return env
|
|
44
|
+
return os.path.join(os.path.expanduser("~"), ".chad", "checkpoints")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def shadow_dir(workspace: str) -> str:
|
|
48
|
+
ws = os.path.realpath(workspace)
|
|
49
|
+
tag = hashlib.sha1(ws.encode()).hexdigest()[:16]
|
|
50
|
+
return os.path.join(_history_root(), tag, "shadow.git")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _git(workspace: str, *args: str) -> subprocess.CompletedProcess:
|
|
54
|
+
"""Run git against the shadow repo with the workspace as work tree. Identity
|
|
55
|
+
and signing come from -c flags so the user's global config is never consulted
|
|
56
|
+
for authorship and never mutated."""
|
|
57
|
+
cmd = ["git", "--git-dir", shadow_dir(workspace), "--work-tree",
|
|
58
|
+
os.path.realpath(workspace),
|
|
59
|
+
"-c", "user.email=chad@localhost", "-c", "user.name=chad",
|
|
60
|
+
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null",
|
|
61
|
+
*args]
|
|
62
|
+
return subprocess.run(cmd, capture_output=True, text=True,
|
|
63
|
+
timeout=_GIT_TIMEOUT_S, check=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ensure_shadow(workspace: str) -> bool:
|
|
67
|
+
sd = shadow_dir(workspace)
|
|
68
|
+
if os.path.isdir(sd):
|
|
69
|
+
return True
|
|
70
|
+
try:
|
|
71
|
+
os.makedirs(os.path.dirname(sd), exist_ok=True)
|
|
72
|
+
r = subprocess.run(["git", "init", "-q", "--bare", sd],
|
|
73
|
+
capture_output=True, text=True,
|
|
74
|
+
timeout=_GIT_TIMEOUT_S, check=False)
|
|
75
|
+
if r.returncode != 0:
|
|
76
|
+
log.warning("CHECKPOINT shadow init failed: %s", r.stderr.strip())
|
|
77
|
+
return False
|
|
78
|
+
info = os.path.join(sd, "info")
|
|
79
|
+
os.makedirs(info, exist_ok=True)
|
|
80
|
+
with open(os.path.join(info, "exclude"), "w", encoding="utf-8") as fh:
|
|
81
|
+
fh.write(_DEFAULT_EXCLUDES)
|
|
82
|
+
return True
|
|
83
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
84
|
+
log.warning("CHECKPOINT shadow init failed: %s", e)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def snapshot(workspace: str, label: str) -> Optional[str]:
|
|
89
|
+
"""Commit the workspace state to the shadow repo; return the short hash, or
|
|
90
|
+
None if no checkpoint exists after the attempt. An unchanged tree is not a
|
|
91
|
+
failure — the previous snapshot already covers it, so its hash is returned."""
|
|
92
|
+
try:
|
|
93
|
+
if not _ensure_shadow(workspace):
|
|
94
|
+
return None
|
|
95
|
+
add = _git(workspace, "add", "-A")
|
|
96
|
+
if add.returncode != 0:
|
|
97
|
+
log.warning("CHECKPOINT add failed: %s", add.stderr.strip())
|
|
98
|
+
return None
|
|
99
|
+
commit = _git(workspace, "commit", "-q", "-m", label)
|
|
100
|
+
head = _git(workspace, "rev-parse", "--short", "HEAD")
|
|
101
|
+
if head.returncode != 0:
|
|
102
|
+
# Nothing staged AND no prior snapshot — an empty workspace's first
|
|
103
|
+
# checkpoint. Record the empty state anyway so the timeline exists.
|
|
104
|
+
commit = _git(workspace, "commit", "-q", "--allow-empty", "-m", label)
|
|
105
|
+
head = _git(workspace, "rev-parse", "--short", "HEAD")
|
|
106
|
+
if head.returncode != 0:
|
|
107
|
+
log.warning("CHECKPOINT commit failed: %s", commit.stderr.strip())
|
|
108
|
+
return None
|
|
109
|
+
return head.stdout.strip()
|
|
110
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
111
|
+
log.warning("CHECKPOINT snapshot failed: %s", e)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def snapshots(workspace: str, limit: int = 10) -> list:
|
|
116
|
+
"""Newest-first [(short_hash, iso_time, label)] — empty on any failure."""
|
|
117
|
+
try:
|
|
118
|
+
r = _git(workspace, "log", f"-{limit}", "--format=%h%x09%cI%x09%s")
|
|
119
|
+
if r.returncode != 0:
|
|
120
|
+
return []
|
|
121
|
+
rows = []
|
|
122
|
+
for line in r.stdout.splitlines():
|
|
123
|
+
parts = line.split("\t", 2)
|
|
124
|
+
if len(parts) == 3:
|
|
125
|
+
rows.append((parts[0], parts[1], parts[2]))
|
|
126
|
+
return rows
|
|
127
|
+
except (OSError, subprocess.SubprocessError):
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def restore(workspace: str, ref: str = "HEAD") -> str:
|
|
132
|
+
"""Check snapshotted files back out at `ref`. Returns a human-readable
|
|
133
|
+
one-liner (also used verbatim by the TUI)."""
|
|
134
|
+
try:
|
|
135
|
+
ok = _git(workspace, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
136
|
+
if ok.returncode != 0:
|
|
137
|
+
have = snapshots(workspace, limit=1)
|
|
138
|
+
return (f"no checkpoint named {ref!r}" if have else
|
|
139
|
+
"no checkpoints exist for this workspace yet — snapshots are "
|
|
140
|
+
"taken before file edits when the edit_checkpoint lever is on")
|
|
141
|
+
changed = _git(workspace, "diff", "--name-only", ref)
|
|
142
|
+
n = len([ln for ln in changed.stdout.splitlines() if ln.strip()])
|
|
143
|
+
r = _git(workspace, "restore", "--worktree", f"--source={ref}", "--", ".")
|
|
144
|
+
if r.returncode != 0:
|
|
145
|
+
return f"restore failed: {r.stderr.strip() or 'unknown git error'}"
|
|
146
|
+
label = _git(workspace, "log", "-1", "--format=%s", ref).stdout.strip()
|
|
147
|
+
return (f"restored {n} file(s) to checkpoint {ref} ({label}). Files created "
|
|
148
|
+
f"since that snapshot were left in place.")
|
|
149
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
150
|
+
return f"restore failed: {e}"
|
|
@@ -514,6 +514,50 @@ LEVERS: dict[str, Lever] = {
|
|
|
514
514
|
"only a server already warmed by find_refs/hover/disambiguation is "
|
|
515
515
|
"consulted; absent one, silence.",
|
|
516
516
|
"ctxengine"),
|
|
517
|
+
|
|
518
|
+
# --- group "safety": blast-radius containment for unattended mutation. ---------
|
|
519
|
+
"yolo_seatbelt": Lever(
|
|
520
|
+
"In yolo mode on macOS, each bash command's shell child runs under a "
|
|
521
|
+
"Seatbelt profile (sandbox-exec) that denies file writes outside the "
|
|
522
|
+
"workspace, temp dirs, tool caches, and ~/.chad — reads, network, and exec "
|
|
523
|
+
"stay open, and ~/.chad/checkpoints (the undo history) is carved back out "
|
|
524
|
+
"so a sandboxed command cannot delete it. The model process is never "
|
|
525
|
+
"sandboxed, only the spawned shell. The startup probe proves the profile "
|
|
526
|
+
"ENFORCES (an allowed write must land, a denied write must not) before any "
|
|
527
|
+
"confinement is claimed; either failure runs unconfined with a loud log. "
|
|
528
|
+
"Fires when a denial is detected in command output: each fire is a write "
|
|
529
|
+
"the command-pattern denylist did not catch.",
|
|
530
|
+
"safety"),
|
|
531
|
+
"edit_checkpoint": Lever(
|
|
532
|
+
"Before a file-mutating tool lands, commit a workspace snapshot to a "
|
|
533
|
+
"shadow git repo under ~/.chad/checkpoints (the project's own .git is "
|
|
534
|
+
"never touched or required); /undo and /restore in the TUI revert from "
|
|
535
|
+
"it. Fires once per snapshot taken and once per restore performed.",
|
|
536
|
+
"safety"),
|
|
537
|
+
"seatbelt_protect_git": Lever(
|
|
538
|
+
"Opt-in tier on top of yolo_seatbelt: the workspace's git metadata "
|
|
539
|
+
"(.git, and a worktree's external gitdir + common dir) is write-DENIED "
|
|
540
|
+
"inside the otherwise-writable workspace, so an unreviewed command "
|
|
541
|
+
"cannot destroy project history (`rm -rf .git`) — and with "
|
|
542
|
+
"edit_checkpoint on, the undo snapshots stay tamper-proof from inside "
|
|
543
|
+
"the sandbox too. The cost is real: every .git-writing git command "
|
|
544
|
+
"(commit, add, checkout) EPERMs — sized at <=2.65% of 91,910 real "
|
|
545
|
+
"session commands, concentrated in 14% of sessions — which is why this "
|
|
546
|
+
"is its own lever, not part of the base profile. Inert unless "
|
|
547
|
+
"yolo_seatbelt is also enabled; a denial it causes surfaces through "
|
|
548
|
+
"yolo_seatbelt's own firing (the profile cannot say which rule bit).",
|
|
549
|
+
"safety", fires=PASSIVE),
|
|
550
|
+
"bash_env_guard": Lever(
|
|
551
|
+
"Spawned bash children get a FILTERED copy of the environment: variable "
|
|
552
|
+
"names shaped like credentials (…_TOKEN, …_SECRET, …_PASSWORD, "
|
|
553
|
+
"…_API_KEY, …_ACCESS_KEY(_ID), …_PRIVATE_KEY, …_CREDENTIALS) are "
|
|
554
|
+
"dropped, closing the gap where a filesystem sandbox still hands every "
|
|
555
|
+
"command the operator's cloud keys. Name-pattern only — values are "
|
|
556
|
+
"never read or logged. OFF (the default) inherits the parent env "
|
|
557
|
+
"untouched, today's behavior; commands that legitimately need a "
|
|
558
|
+
"credential need the lever off. Fires per spawn that actually dropped "
|
|
559
|
+
"something, with the count.",
|
|
560
|
+
"safety"),
|
|
517
561
|
}
|
|
518
562
|
|
|
519
563
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""macOS Seatbelt confinement for yolo-mode bash commands (lever: yolo_seatbelt).
|
|
2
|
+
|
|
3
|
+
The destructive-command denylist in guardrails.py is pattern-matching, not a
|
|
4
|
+
boundary — it says so itself. This module is the boundary: in yolo mode (the one
|
|
5
|
+
mode where a shell command runs with no human in the loop), the spawned shell is
|
|
6
|
+
wrapped in `sandbox-exec` with a profile that denies file writes everywhere except
|
|
7
|
+
the workspace, temp dirs, caches, and chad's own state dir. Reads and network stay
|
|
8
|
+
open; the failure mode this closes is "an unreviewed command wrote outside the
|
|
9
|
+
project".
|
|
10
|
+
|
|
11
|
+
Only the *spawned shell child* is ever sandboxed. The chad process itself must
|
|
12
|
+
never enter the sandbox: the model runs in-process on Metal, and GPU access from
|
|
13
|
+
inside a Seatbelt profile is not something to gamble a session on. tool_bash asks
|
|
14
|
+
`wrap_argv()` for an argv at spawn time; everything else here is profile plumbing.
|
|
15
|
+
|
|
16
|
+
The active/workspace context is set by the agent immediately before each tool
|
|
17
|
+
dispatch and cleared after, so nested agents (a sub-agent's yolo loop inside a
|
|
18
|
+
normal-mode parent turn) and the `!cmd` passthrough each get exactly the
|
|
19
|
+
confinement of the agent/mode that is actually executing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shlex
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from . import levers
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("chad")
|
|
34
|
+
|
|
35
|
+
SANDBOX_EXEC = "/usr/bin/sandbox-exec"
|
|
36
|
+
|
|
37
|
+
# One-shot enforcement probe. Seatbelt profiles don't nest: when chad itself runs
|
|
38
|
+
# inside a sandbox (CI, a harness container, another agent's shell tool),
|
|
39
|
+
# sandbox-exec fails to apply and would turn EVERY yolo bash call into an error.
|
|
40
|
+
# Probe once per process; on failure run unconfined (logged) rather than broken.
|
|
41
|
+
_probe_result: Optional[bool] = None
|
|
42
|
+
|
|
43
|
+
# The marker a denied write leaves in command output (EPERM strerror). Used by
|
|
44
|
+
# tool_bash to detect that the profile bit and to append an explanation the model
|
|
45
|
+
# can act on instead of a bare permission error.
|
|
46
|
+
DENIAL_MARKER = "Operation not permitted"
|
|
47
|
+
DENIAL_NOTE = ("\n[seatbelt: a write outside the workspace was denied — yolo mode "
|
|
48
|
+
"confines file writes to the project directory, temp dirs, and "
|
|
49
|
+
"caches. Work inside the project, or ask the user to run this "
|
|
50
|
+
"command themselves.]")
|
|
51
|
+
|
|
52
|
+
_ctx: dict = {"active": False, "workspace": None}
|
|
53
|
+
|
|
54
|
+
# profile path per workspace realpath — the profile embeds absolute paths, so it is
|
|
55
|
+
# only reusable for the same workspace.
|
|
56
|
+
_profiles: dict = {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_context(active: bool, workspace: Optional[str]) -> None:
|
|
60
|
+
"""Called by the agent around each tool dispatch. `active` is 'the executing
|
|
61
|
+
agent is in yolo mode'; whether wrapping actually happens still depends on the
|
|
62
|
+
lever and platform (see wrap_argv)."""
|
|
63
|
+
_ctx["active"] = active
|
|
64
|
+
_ctx["workspace"] = workspace
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def available() -> bool:
|
|
68
|
+
return sys.platform == "darwin" and os.path.exists(SANDBOX_EXEC)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def probe() -> bool:
|
|
72
|
+
"""Does a sandbox applied from this process actually ENFORCE? Cached per process.
|
|
73
|
+
|
|
74
|
+
Running `sandbox-exec` successfully proves nothing about confinement: a profile
|
|
75
|
+
that fails open (OS version drift, a malformed rule) would leave every yolo
|
|
76
|
+
command reported as confined while writing anywhere it likes — a boundary that
|
|
77
|
+
lies is worse than no boundary. So the probe exercises both directions of a
|
|
78
|
+
real deny/allow profile against a throwaway directory pair: the allowed write
|
|
79
|
+
must land AND the denied write must not. A denied write that lands fails the
|
|
80
|
+
probe loudly; either failure means yolo bash runs unconfined (and says so)
|
|
81
|
+
rather than confined-in-name-only."""
|
|
82
|
+
global _probe_result
|
|
83
|
+
if _probe_result is not None:
|
|
84
|
+
return _probe_result
|
|
85
|
+
if not available():
|
|
86
|
+
_probe_result = False
|
|
87
|
+
return False
|
|
88
|
+
_probe_result = False
|
|
89
|
+
leaked = False
|
|
90
|
+
try:
|
|
91
|
+
with tempfile.TemporaryDirectory(prefix="chad-sb-probe-") as tmp:
|
|
92
|
+
# Seatbelt matches the KERNEL's view of a path: $TMPDIR is a symlink
|
|
93
|
+
# (/var/folders -> /private/var/folders), and an allow rule written
|
|
94
|
+
# against the symlinked spelling silently denies everything under it.
|
|
95
|
+
root = os.path.realpath(tmp)
|
|
96
|
+
allowed = os.path.join(root, "allowed")
|
|
97
|
+
denied = os.path.join(root, "denied")
|
|
98
|
+
os.mkdir(allowed)
|
|
99
|
+
os.mkdir(denied)
|
|
100
|
+
prof = ("(version 1)(allow default)(deny file-write*)"
|
|
101
|
+
f'(allow file-write* (subpath "{_scheme_str(allowed)}"))')
|
|
102
|
+
cmd = (f"printf ok > {shlex.quote(os.path.join(allowed, 'w'))}; "
|
|
103
|
+
f"printf no > {shlex.quote(os.path.join(denied, 'w'))}")
|
|
104
|
+
subprocess.run([SANDBOX_EXEC, "-p", prof, "/bin/sh", "-c", cmd],
|
|
105
|
+
capture_output=True, timeout=10, check=False)
|
|
106
|
+
leaked = os.path.isfile(os.path.join(denied, "w"))
|
|
107
|
+
_probe_result = (not leaked
|
|
108
|
+
and os.path.isfile(os.path.join(allowed, "w")))
|
|
109
|
+
except (OSError, subprocess.SubprocessError):
|
|
110
|
+
_probe_result = False
|
|
111
|
+
if leaked:
|
|
112
|
+
log.error("SEATBELT profile FAILED to enforce (a write that must be denied "
|
|
113
|
+
"landed) — refusing a confinement that would only be claimed; "
|
|
114
|
+
"yolo bash runs unconfined")
|
|
115
|
+
elif not _probe_result:
|
|
116
|
+
log.warning("SEATBELT unavailable here (nested sandbox or missing "
|
|
117
|
+
"support) — yolo bash runs unconfined")
|
|
118
|
+
return _probe_result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _scheme_str(path: str) -> str:
|
|
122
|
+
"""Escape a path for embedding in a Scheme string literal. Paths are embedded
|
|
123
|
+
directly (no -D params): quotes and backslashes in a path must not be able to
|
|
124
|
+
terminate the string and smuggle profile syntax."""
|
|
125
|
+
return path.replace("\\", "\\\\").replace('"', '\\"')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _worktree_gitdirs(workspace: str) -> list:
|
|
129
|
+
"""If the workspace is a `git worktree`, its `.git` is a FILE pointing at an
|
|
130
|
+
external gitdir — and every git command writes there (index, locks) and to the
|
|
131
|
+
shared common dir (objects, refs). Without these two carve-outs the sandbox
|
|
132
|
+
kills all git activity in a worktree checkout."""
|
|
133
|
+
dirs = []
|
|
134
|
+
dotgit = os.path.join(workspace, ".git")
|
|
135
|
+
if not os.path.isfile(dotgit):
|
|
136
|
+
return dirs
|
|
137
|
+
try:
|
|
138
|
+
with open(dotgit, encoding="utf-8", errors="replace") as fh:
|
|
139
|
+
m = re.match(r"gitdir:\s*(.+)", fh.read().strip())
|
|
140
|
+
if not m:
|
|
141
|
+
return dirs
|
|
142
|
+
gitdir = os.path.normpath(os.path.join(workspace, m.group(1).strip()))
|
|
143
|
+
if os.path.isdir(gitdir):
|
|
144
|
+
dirs.append(gitdir)
|
|
145
|
+
common = os.path.join(gitdir, "commondir")
|
|
146
|
+
if os.path.isfile(common):
|
|
147
|
+
with open(common, encoding="utf-8", errors="replace") as fh:
|
|
148
|
+
rel = fh.read().strip()
|
|
149
|
+
commondir = os.path.normpath(os.path.join(gitdir, rel))
|
|
150
|
+
if os.path.isdir(commondir):
|
|
151
|
+
dirs.append(commondir)
|
|
152
|
+
except OSError:
|
|
153
|
+
return dirs
|
|
154
|
+
return dirs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def profile_text(workspace: str) -> str:
|
|
158
|
+
"""Deny-writes-outside-allowlist profile. `(allow default)` keeps reads,
|
|
159
|
+
network, exec, signals open — v1 confines exactly one thing: where files can
|
|
160
|
+
be written. The allowlist is every place real commands legitimately write:
|
|
161
|
+
the workspace, POSIX and macOS temp roots, tool caches (uv/pip live under
|
|
162
|
+
~/Library/Caches on macOS, not ~/.cache), and chad's state dir.
|
|
163
|
+
|
|
164
|
+
Two carve-backs ride AFTER the allow block (the last matching Seatbelt rule
|
|
165
|
+
wins), because a writable root is not uniformly expendable:
|
|
166
|
+
|
|
167
|
+
* `~/.chad/checkpoints` — the shadow-repo undo history. It sits under the
|
|
168
|
+
allowed `~/.chad` subpath, so without its own deny a sandboxed command
|
|
169
|
+
could delete the very snapshots a user would reach for after a bad turn.
|
|
170
|
+
Only chad's own (never-sandboxed) process legitimately writes there, so
|
|
171
|
+
this deny is unconditional and costs real commands nothing.
|
|
172
|
+
* the workspace's git metadata (`.git`, and for a worktree checkout its
|
|
173
|
+
external gitdir + shared common dir) — gated by the `seatbelt_protect_git`
|
|
174
|
+
lever, its own opt-in tier rather than part of the base profile: it makes
|
|
175
|
+
project history un-destroyable from inside the sandbox (`rm -rf .git`
|
|
176
|
+
EPERMs), but any git command that writes — commit, add, checkout — fails
|
|
177
|
+
with it. Sized statically against 91,910 real session commands: at most
|
|
178
|
+
2.65% would EPERM (an upper bound — a clone/init into a subdirectory makes
|
|
179
|
+
a NESTED .git, which stays writable), concentrated in 14% of sessions.
|
|
180
|
+
With the tier off, worktree gitdirs stay on the ALLOW list, since every
|
|
181
|
+
git command writes through them."""
|
|
182
|
+
home = os.path.expanduser("~")
|
|
183
|
+
ws = os.path.realpath(workspace)
|
|
184
|
+
protect_git = levers.enabled("seatbelt_protect_git")
|
|
185
|
+
gitdirs = _worktree_gitdirs(ws)
|
|
186
|
+
write_paths = [ws]
|
|
187
|
+
if workspace != ws:
|
|
188
|
+
write_paths.append(workspace)
|
|
189
|
+
if not protect_git:
|
|
190
|
+
write_paths += gitdirs
|
|
191
|
+
write_paths += [
|
|
192
|
+
"/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp",
|
|
193
|
+
"/private/var/folders", # $TMPDIR lives here on macOS
|
|
194
|
+
os.path.join(home, ".chad"),
|
|
195
|
+
os.path.join(home, ".cache"),
|
|
196
|
+
os.path.join(home, "Library", "Caches"),
|
|
197
|
+
os.path.join(home, ".local", "share", "uv"),
|
|
198
|
+
os.path.join(home, ".npm"),
|
|
199
|
+
]
|
|
200
|
+
lines = ["(version 1)", "(allow default)", "(deny file-write*)",
|
|
201
|
+
"(allow file-write*"]
|
|
202
|
+
for p in write_paths:
|
|
203
|
+
lines.append(f' (subpath "{_scheme_str(p)}")')
|
|
204
|
+
lines.append(f' (literal "{_scheme_str(os.path.join(home, ".gitconfig"))}")')
|
|
205
|
+
for dev in ("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/fd"):
|
|
206
|
+
lines.append(f' (literal "{dev}")')
|
|
207
|
+
lines.append(' (regex #"^/dev/tty")')
|
|
208
|
+
lines.append(")")
|
|
209
|
+
deny_paths = [os.path.join(home, ".chad", "checkpoints")]
|
|
210
|
+
if protect_git:
|
|
211
|
+
deny_paths.append(os.path.join(ws, ".git"))
|
|
212
|
+
if workspace != ws:
|
|
213
|
+
deny_paths.append(os.path.join(workspace, ".git"))
|
|
214
|
+
deny_paths += gitdirs
|
|
215
|
+
lines.append("(deny file-write*")
|
|
216
|
+
for p in deny_paths:
|
|
217
|
+
lines.append(f' (subpath "{_scheme_str(p)}")')
|
|
218
|
+
lines.append(")")
|
|
219
|
+
return "\n".join(lines) + "\n"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _profile_path(workspace: str) -> str:
|
|
223
|
+
# Keyed on the git-protection tier as well as the workspace: the profile body
|
|
224
|
+
# differs, and lever state can change between calls within one process.
|
|
225
|
+
ws = os.path.realpath(workspace)
|
|
226
|
+
key = (ws, levers.enabled("seatbelt_protect_git"))
|
|
227
|
+
path = _profiles.get(key)
|
|
228
|
+
if path and os.path.exists(path):
|
|
229
|
+
return path
|
|
230
|
+
fd, path = tempfile.mkstemp(prefix="chad-seatbelt-", suffix=".sb")
|
|
231
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
232
|
+
fh.write(profile_text(ws))
|
|
233
|
+
_profiles[key] = path
|
|
234
|
+
log.info("SEATBELT profile for %s -> %s", ws, path)
|
|
235
|
+
return path
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def wrap_argv(command: str) -> Optional[list]:
|
|
239
|
+
"""The argv to spawn `command` sandboxed, or None to run it unconfined.
|
|
240
|
+
None whenever the lever is off, the executing agent is not in yolo mode, or
|
|
241
|
+
the platform can't do Seatbelt — tool_bash falls through to its normal
|
|
242
|
+
shell=True spawn, byte-identical behavior to before this module existed."""
|
|
243
|
+
if not levers.enabled("yolo_seatbelt"):
|
|
244
|
+
return None
|
|
245
|
+
if not _ctx["active"] or not probe():
|
|
246
|
+
return None
|
|
247
|
+
workspace = _ctx["workspace"] or os.getcwd()
|
|
248
|
+
try:
|
|
249
|
+
profile = _profile_path(workspace)
|
|
250
|
+
except OSError as e:
|
|
251
|
+
# No profile means no confinement; in yolo that is the unsafe direction,
|
|
252
|
+
# but killing every bash call is worse — log loudly and run unconfined.
|
|
253
|
+
log.warning("SEATBELT profile write failed (%s) — running unconfined", e)
|
|
254
|
+
return None
|
|
255
|
+
return [SANDBOX_EXEC, "-f", profile, "/bin/sh", "-c", command]
|
|
@@ -21,7 +21,7 @@ import threading
|
|
|
21
21
|
import time
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
-
from . import config, levers, lsp, repomap, symbols, syntaxgate
|
|
24
|
+
from . import config, levers, lsp, repomap, seatbelt, symbols, syntaxgate
|
|
25
25
|
from .ignore import IGNORE_DIRS, slash_wrapped
|
|
26
26
|
|
|
27
27
|
# Directories never worth walking: huge, generated, or VCS internals. The canonical set
|
|
@@ -276,13 +276,39 @@ def _bash_backgrounded(d: "_BgDrainer", timeout: int) -> str | None:
|
|
|
276
276
|
return head + "\n" + _bash_headtail(partial) if partial else head
|
|
277
277
|
|
|
278
278
|
|
|
279
|
+
# Environment variable names shaped like credentials, dropped from spawned shell
|
|
280
|
+
# children when the bash_env_guard lever is on. Name-pattern only: values are never
|
|
281
|
+
# inspected (a value test would itself be a secret-handling liability), and the
|
|
282
|
+
# pattern is anchored at the end so AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, and
|
|
283
|
+
# DB_PASSWORD match while PATH, TOKENIZERS_PARALLELISM, and friends pass through.
|
|
284
|
+
_ENV_SECRET_RE = re.compile(
|
|
285
|
+
r"(?i)(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|API_?KEY|"
|
|
286
|
+
r"ACCESS_KEY(_ID)?|SECRET_KEY|PRIVATE_KEY)$")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _bash_env() -> dict | None:
|
|
290
|
+
"""The environment for a spawned bash child: None (inherit the parent env
|
|
291
|
+
untouched — the default contract) unless the guard lever is on, in which case a
|
|
292
|
+
copy with credential-shaped names removed. A command that legitimately needs a
|
|
293
|
+
credential sees a clear absence, not a corrupted value."""
|
|
294
|
+
if not levers.enabled("bash_env_guard"):
|
|
295
|
+
return None
|
|
296
|
+
env = {k: v for k, v in os.environ.items() if not _ENV_SECRET_RE.search(k)}
|
|
297
|
+
dropped = len(os.environ) - len(env)
|
|
298
|
+
if dropped:
|
|
299
|
+
levers.fired("bash_env_guard", dropped=dropped)
|
|
300
|
+
return env
|
|
301
|
+
|
|
302
|
+
|
|
279
303
|
def _bash_auto_background(command: str, timeout: int, should_stop) -> str:
|
|
280
304
|
"""tool_bash's timeout path when auto-background is armed. Mirrors the foreground
|
|
281
305
|
loop exactly, except that a timeout hands the command off instead of killing it."""
|
|
306
|
+
argv = seatbelt.wrap_argv(command)
|
|
282
307
|
try:
|
|
283
|
-
p = subprocess.Popen(
|
|
308
|
+
p = subprocess.Popen(argv if argv is not None else command,
|
|
309
|
+
shell=argv is None, stdout=subprocess.PIPE,
|
|
284
310
|
stderr=subprocess.STDOUT, text=True, errors="replace",
|
|
285
|
-
start_new_session=True)
|
|
311
|
+
start_new_session=True, env=_bash_env())
|
|
286
312
|
except OSError as e:
|
|
287
313
|
return f"[failed to launch: {e}]"
|
|
288
314
|
d = _BgDrainer(p)
|
|
@@ -308,20 +334,23 @@ def _bash_auto_background(command: str, timeout: int, should_stop) -> str:
|
|
|
308
334
|
p.poll()
|
|
309
335
|
if p.returncode not in (0, None):
|
|
310
336
|
out = f"[exit {p.returncode}]\n{out}"
|
|
337
|
+
out = _seatbelt_note(argv, out)
|
|
311
338
|
return _bash_headtail(out) if out else "[no output]"
|
|
312
339
|
|
|
313
340
|
|
|
314
341
|
def tool_bash(command: str, timeout: int = 120, should_stop=None) -> str:
|
|
315
342
|
if levers.enabled("bash_auto_background"):
|
|
316
343
|
return _bash_auto_background(command, timeout, should_stop)
|
|
344
|
+
argv = seatbelt.wrap_argv(command)
|
|
317
345
|
try:
|
|
318
346
|
# errors="replace": text mode decodes strictly by default, so binary bytes in the
|
|
319
347
|
# output (hexdump, `cat` on an archive) killed the reader thread mid-communicate —
|
|
320
348
|
# the command's whole output AND its exit code vanished into a "[no output]" that
|
|
321
349
|
# read as a quiet success (and could spoof the verify gate).
|
|
322
|
-
p = subprocess.Popen(
|
|
350
|
+
p = subprocess.Popen(argv if argv is not None else command,
|
|
351
|
+
shell=argv is None, stdout=subprocess.PIPE,
|
|
323
352
|
stderr=subprocess.STDOUT, text=True, errors="replace",
|
|
324
|
-
start_new_session=True)
|
|
353
|
+
start_new_session=True, env=_bash_env())
|
|
325
354
|
except OSError as e:
|
|
326
355
|
return f"[failed to launch: {e}]"
|
|
327
356
|
# Drain output on a helper thread (so large output can't deadlock the pipe)
|
|
@@ -344,9 +373,21 @@ def tool_bash(command: str, timeout: int = 120, should_stop=None) -> str:
|
|
|
344
373
|
p.poll() # reconcile: a reader-thread death skips communicate()'s internal wait
|
|
345
374
|
if p.returncode not in (0, None):
|
|
346
375
|
out = f"[exit {p.returncode}]\n{out}"
|
|
376
|
+
out = _seatbelt_note(argv, out)
|
|
347
377
|
return _bash_headtail(out) if out else "[no output]"
|
|
348
378
|
|
|
349
379
|
|
|
380
|
+
def _seatbelt_note(argv, out: str) -> str:
|
|
381
|
+
"""When a sandboxed command's output shows a write denial, say what happened
|
|
382
|
+
and what to do — a bare 'Operation not permitted' reads as a broken tool, and
|
|
383
|
+
a model that can't see the boundary will retry into it. Each detected denial
|
|
384
|
+
is exactly the event the lever exists to produce, so it fires here."""
|
|
385
|
+
if argv is None or seatbelt.DENIAL_MARKER not in out:
|
|
386
|
+
return out
|
|
387
|
+
levers.fired("yolo_seatbelt", denial=True)
|
|
388
|
+
return out + seatbelt.DENIAL_NOTE
|
|
389
|
+
|
|
390
|
+
|
|
350
391
|
def _bash_killed(reason: str, partial: str | None) -> str:
|
|
351
392
|
"""Result for a bash command chad killed (timeout or interrupt). The output the
|
|
352
393
|
process wrote before it died is already drained into `partial` by the join above —
|