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.
Files changed (119) hide show
  1. {chad_code-1.11.0 → chad_code-1.12.0}/PKG-INFO +1 -1
  2. {chad_code-1.11.0 → chad_code-1.12.0}/pyproject.toml +1 -1
  3. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/__init__.py +1 -1
  4. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/agent.py +27 -1
  5. chad_code-1.12.0/src/chad/checkpoint.py +150 -0
  6. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/levers.py +44 -0
  7. chad_code-1.12.0/src/chad/seatbelt.py +255 -0
  8. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/tools.py +46 -5
  9. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/tui.py +30 -1
  10. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/PKG-INFO +1 -1
  11. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/SOURCES.txt +4 -0
  12. chad_code-1.12.0/tests/test_checkpoint.py +132 -0
  13. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lever_bite.py +71 -0
  14. chad_code-1.12.0/tests/test_seatbelt.py +336 -0
  15. {chad_code-1.11.0 → chad_code-1.12.0}/LICENSE +0 -0
  16. {chad_code-1.11.0 → chad_code-1.12.0}/README.md +0 -0
  17. {chad_code-1.11.0 → chad_code-1.12.0}/setup.cfg +0 -0
  18. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/ambient.py +0 -0
  19. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/atif.py +0 -0
  20. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/base_engine.py +0 -0
  21. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/bench.py +0 -0
  22. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/cli.py +0 -0
  23. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/compaction.py +0 -0
  24. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/completion_engine.py +0 -0
  25. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/config.py +0 -0
  26. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/diag.py +0 -0
  27. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/engine.py +0 -0
  28. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/guardrails.py +0 -0
  29. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/ignore.py +0 -0
  30. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/lsp.py +0 -0
  31. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/lspclient.py +0 -0
  32. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/lspservers.py +0 -0
  33. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mcp.py +0 -0
  34. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mcp_oauth.py +0 -0
  35. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mlx_fastpath.py +0 -0
  36. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mlx_moe_fused.py +0 -0
  37. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/mlx_qsdpa.py +0 -0
  38. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/LICENSE +0 -0
  39. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/__init__.py +0 -0
  40. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/alignment.py +0 -0
  41. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/attention.py +0 -0
  42. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/audio.py +0 -0
  43. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/cache.py +0 -0
  44. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/conformer.py +0 -0
  45. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/ctc.py +0 -0
  46. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/parakeet.py +0 -0
  47. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/rnnt.py +0 -0
  48. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/tokenizer.py +0 -0
  49. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/parakeet/utils.py +0 -0
  50. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/profiles.py +0 -0
  51. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/prompt.py +0 -0
  52. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/prove.py +0 -0
  53. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/render.py +0 -0
  54. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/repomap.py +0 -0
  55. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/serve.py +0 -0
  56. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/session.py +0 -0
  57. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/skills.py +0 -0
  58. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/speech.py +0 -0
  59. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/symbols.py +0 -0
  60. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/syntaxgate.py +0 -0
  61. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/toolcall_parse.py +0 -0
  62. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad/validate.py +0 -0
  63. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/dependency_links.txt +0 -0
  64. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/entry_points.txt +0 -0
  65. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/requires.txt +0 -0
  66. {chad_code-1.11.0 → chad_code-1.12.0}/src/chad_code.egg-info/top_level.txt +0 -0
  67. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_agent.py +0 -0
  68. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_agent_e2e.py +0 -0
  69. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_agent_guards.py +0 -0
  70. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_ambient.py +0 -0
  71. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_atif.py +0 -0
  72. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_bench.py +0 -0
  73. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_cli.py +0 -0
  74. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_compact_notice.py +0 -0
  75. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_compaction.py +0 -0
  76. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_completion_engine.py +0 -0
  77. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_config.py +0 -0
  78. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_confirm_preview.py +0 -0
  79. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_done_audit.py +0 -0
  80. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_drift_warn.py +0 -0
  81. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_edit.py +0 -0
  82. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_edit_corruption.py +0 -0
  83. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_engine.py +0 -0
  84. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_engine_kvquant.py +0 -0
  85. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_engine_pld_hybrid.py +0 -0
  86. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_feel_pack.py +0 -0
  87. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_garble_invariant.py +0 -0
  88. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_gate.py +0 -0
  89. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_ignore.py +0 -0
  90. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_intent.py +0 -0
  91. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lever_instrumentation.py +0 -0
  92. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_levers.py +0 -0
  93. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_log_redaction.py +0 -0
  94. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lsp.py +0 -0
  95. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lsp_live.py +0 -0
  96. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_lspclient.py +0 -0
  97. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mcp.py +0 -0
  98. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mcp_oauth.py +0 -0
  99. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mlx_fastpath.py +0 -0
  100. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mlx_moe_fused.py +0 -0
  101. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_mlx_qsdpa.py +0 -0
  102. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_plan_review.py +0 -0
  103. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_prove.py +0 -0
  104. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_render.py +0 -0
  105. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_replace_lines.py +0 -0
  106. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_repomap.py +0 -0
  107. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_repomap_polyglot.py +0 -0
  108. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_serve.py +0 -0
  109. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_session.py +0 -0
  110. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_skills.py +0 -0
  111. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_speech.py +0 -0
  112. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_speech_tui.py +0 -0
  113. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_subagent.py +0 -0
  114. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_symbols.py +0 -0
  115. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_syntaxgate.py +0 -0
  116. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_toolcall_parse.py +0 -0
  117. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_tools.py +0 -0
  118. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_tui.py +0 -0
  119. {chad_code-1.11.0 → chad_code-1.12.0}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 1.11.0
3
+ Version: 1.12.0
4
4
  Summary: Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/nathansutton/chad
@@ -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.11.0"
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.11.0"
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 ambient, atif, compaction, config, guardrails, levers, session, syntaxgate
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(command, shell=True, stdout=subprocess.PIPE,
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(command, shell=True, stdout=subprocess.PIPE,
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 —