@miller-tech/uap 1.159.0 → 1.160.0

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.
@@ -0,0 +1,341 @@
1
+ """commitment-reserve enforcer: an all-in move with no way back is blocked
2
+ until a reserve exists ("never go full" -- commit hard, but hold something back).
3
+
4
+ The failure class this closes (all observed live on this project):
5
+ - a local model "fixed" gates by DELETING real implementation files into
6
+ stubs (guts-source incident) -- a wholesale overwrite with no backup;
7
+ - destructive git resets / cleans that vaporized uncommitted work the
8
+ session then could not reconstruct;
9
+ - bare force-pushes that rewrote remote history with no local reserve.
10
+
11
+ The rule is NOT "never do destructive things" -- it is "destructive moves are
12
+ allowed only once something is held in reserve". Every block message names the
13
+ reserve that unlocks it.
14
+
15
+ Matching is SEGMENT-ANCHORED: the command is split on ;/&/| into segments,
16
+ quoted spans are stripped, and a rule fires only when the segment's leading
17
+ verb is git/rm. A `grep "git reset --hard"`, an `echo`, or a commit message
18
+ that merely MENTIONS a destructive pattern never trips the gate.
19
+
20
+ Shell (Bash/run_bash) all-in moves, blocked without a reserve:
21
+ - `git reset --hard` reserve: `git stash` first (inline counts)
22
+ - forced push (`--force`, `-f`, `+refspec`) `--force-with-lease` is allowed
23
+ by THIS gate; note the project git-safety hook may still block it -- the
24
+ sanctioned fallback there is a merge commit, not a bigger hammer.
25
+ - `git clean -f...` / `--force` reserve: `git stash -u` first
26
+ - `git checkout/restore` wholesale discards (final arg `.`/`*`, incl.
27
+ `checkout HEAD -- .` and `restore --worktree .`)
28
+ - recursive+forced delete of a protected source root (src, test, tests,
29
+ lib, tools, scripts, or the policy-definition dir) -- scratch/derived
30
+ dirs (node_modules, dist, /tmp) stay allowed.
31
+
32
+ Unlocks (verified, not honor-system where possible):
33
+ - an inline reserve-CREATING stash anywhere in the command (`git stash`,
34
+ `git stash push/-u/--include-untracked`). Reserve-destroying or inert
35
+ stash subcommands (drop/clear/pop/list/show/apply/branch) do NOT unlock.
36
+ - `UAP_RESERVE_OK=1` ONLY as a leading env assignment of the destructive
37
+ segment itself (set it only AFTER creating a backup/stash). A mention
38
+ elsewhere in the command (echo, comment, quoted string) does not count.
39
+
40
+ Write-tool stub overwrites (the guts-source incident), blocked:
41
+ - overwriting an existing source file >= 4 KiB with content < 20% of its
42
+ size. Reserve: a same-day backup under `.uap-backups/<today>/` that is a
43
+ real regular file (not a symlink) of at least half the original's size --
44
+ an empty or unrelated same-named file does not count. Incremental Edit
45
+ operations are never touched by this gate.
46
+
47
+ Known accepted limits (heuristic gate, fail-open by design): absolute-path
48
+ rm targets, `find -delete`, and xargs pipelines are not modeled; the gate's
49
+ purpose is stopping reflexive all-in moves, not adversarial evasion.
50
+
51
+ Operator overrides: UAP_RESERVE_OK=1 or UAP_COMMITMENT_RESERVE_OFF=1 in the
52
+ environment. DELIVER_ACTIVE=1 sessions are exempt (the deliver harness keeps
53
+ its own snapshot/restore reserve).
54
+ """
55
+ import os
56
+ import re
57
+ import sys
58
+ from datetime import date
59
+ from pathlib import Path
60
+
61
+ sys.path.insert(0, str(Path(__file__).parent))
62
+ from _common import emit, parse_cli, repo_root, worktree_root # noqa: E402
63
+
64
+ BASH_OPS = {"Bash", "bash", "run_bash", "shell", "execute_command"}
65
+ WRITE_OPS = {"Write", "write", "write_file", "create_file"}
66
+
67
+ RESERVE_TOKEN = "UAP_RESERVE_OK=1"
68
+
69
+ # stash CREATE forms hold a reserve; drop/clear/pop destroy or consume one and
70
+ # list/show/apply/branch create nothing -- none of those unlock.
71
+ _STASH_CREATE = re.compile(
72
+ r"\bgit\s+stash\b(?!\s+(?:drop|clear|pop|list|show|apply|branch)\b)"
73
+ )
74
+
75
+ _QUOTED_SPAN = re.compile(r"'[^']*'|\"[^\"]*\"")
76
+ _SEGMENT_SPLIT = re.compile(r"[;|&\n]+")
77
+ _ENV_ASSIGN = re.compile(r"[A-Za-z_]\w*=\S*")
78
+ _WRAPPERS = {"sudo", "env", "command", "nohup"}
79
+ # git global flags that take a separate value token (skip flag + value when
80
+ # locating the subcommand: `git -C /x reset --hard`).
81
+ _GIT_VALUED_FLAGS = {"-C", "-c", "--git-dir", "--work-tree"}
82
+
83
+ # Losing these roots loses the work itself. Derived/scratch dirs
84
+ # (node_modules, dist, build, coverage, tmp) are reconstructible and
85
+ # deliberately NOT protected.
86
+ PROTECTED_ROOTS = {"src", "test", "tests", "lib", "policies", "tools", "scripts"}
87
+
88
+ # Deleting a SCRATCH dir nested under a protected root (rm -rf src/__pycache__)
89
+ # is targeted cleanup, not an all-in move -- the reconstructible-dir principle
90
+ # applies at any depth.
91
+ SCRATCH_NAMES = {
92
+ "__pycache__", "node_modules", "dist", "build", "coverage", ".cache",
93
+ "tmp", ".next", ".turbo", "generated", ".pytest_cache",
94
+ }
95
+
96
+ WHOLESALE_TARGETS = {".", "./", "*", "./*", "/", "..", "../", "~", "~/"}
97
+
98
+ MSG_PREFIX = "commitment-reserve: all-in move with no way back -- "
99
+ MSG_RESET = (
100
+ "`git reset --hard` discards every uncommitted change. Hold a reserve "
101
+ "first: `git stash` (an inline `git stash && ...` also counts), then retry."
102
+ )
103
+ MSG_PUSH = (
104
+ "forced push rewrites remote history with nothing held back. Prefer "
105
+ "`--force-with-lease`; if the git-safety hook blocks that too, use the "
106
+ "merge-commit fallback instead of forcing."
107
+ )
108
+ MSG_CLEAN = (
109
+ "`git clean --force` deletes untracked files wholesale. Hold a reserve "
110
+ "first: `git stash -u`, then retry."
111
+ )
112
+ MSG_DISCARD = (
113
+ "wholesale discard of the working tree. Restore specific files instead, "
114
+ "or `git stash` first so the discarded state stays recoverable."
115
+ )
116
+
117
+
118
+ def _segments(cmd: str) -> list[list[str]]:
119
+ """Split a command into per-segment token lists, with line continuations
120
+ joined and quoted spans stripped (so mentions inside strings never match)."""
121
+ flat = cmd.replace("\\\n", " ")
122
+ flat = _QUOTED_SPAN.sub(" ", flat)
123
+ return [seg.split() for seg in _SEGMENT_SPLIT.split(flat) if seg.strip()]
124
+
125
+
126
+ def _strip_prefix(tokens: list[str]) -> tuple[list[str], bool]:
127
+ """Drop leading env assignments / wrappers; report whether the reserve
128
+ marker led the segment."""
129
+ reserve = False
130
+ i = 0
131
+ while i < len(tokens):
132
+ t = tokens[i]
133
+ if _ENV_ASSIGN.fullmatch(t):
134
+ if t == RESERVE_TOKEN:
135
+ reserve = True
136
+ i += 1
137
+ continue
138
+ if t in _WRAPPERS:
139
+ i += 1
140
+ continue
141
+ break
142
+ return tokens[i:], reserve
143
+
144
+
145
+ def _git_subcommand(rest: list[str]) -> tuple[str, list[str]]:
146
+ """Locate the git subcommand, skipping global flags (incl. valued ones)."""
147
+ i = 0
148
+ while i < len(rest):
149
+ t = rest[i]
150
+ if t in _GIT_VALUED_FLAGS:
151
+ i += 2
152
+ continue
153
+ if t.startswith("-"):
154
+ i += 1
155
+ continue
156
+ return t, rest[i + 1 :]
157
+ return "", []
158
+
159
+
160
+ def _rf_flag(tokens: list[str]) -> bool:
161
+ flags = "".join(t.lstrip("-") for t in tokens if t.startswith("-") and not t.startswith("--"))
162
+ long_flags = {t for t in tokens if t.startswith("--")}
163
+ recursive = bool(re.search(r"[rR]", flags)) or "--recursive" in long_flags
164
+ forced = "f" in flags or "--force" in long_flags
165
+ return recursive and forced
166
+
167
+
168
+ def _check_git_segment(sub: str, rest: list[str]) -> str | None:
169
+ if sub == "reset" and "--hard" in rest:
170
+ return MSG_RESET
171
+ if sub == "push":
172
+ for t in rest:
173
+ if t in {"--force", "-f"}:
174
+ return MSG_PUSH
175
+ if t.startswith("+") and len(t) > 1:
176
+ return MSG_PUSH
177
+ if sub == "clean":
178
+ dry = any(
179
+ t == "--dry-run" or (re.fullmatch(r"-[A-Za-z]+", t) and "n" in t)
180
+ for t in rest
181
+ )
182
+ forced = any(t == "--force" or re.fullmatch(r"-[A-Za-z]*f[A-Za-z]*", t) for t in rest)
183
+ if forced and not dry:
184
+ return MSG_CLEAN
185
+ if sub in {"checkout", "restore"}:
186
+ if any(t in {"-f", "--force"} for t in rest):
187
+ return MSG_DISCARD
188
+ positional = [t for t in rest if t == "--" or not t.startswith("-")]
189
+ if any(t in WHOLESALE_TARGETS for t in positional):
190
+ return MSG_DISCARD
191
+ return None
192
+
193
+
194
+ def _check_rm_segment(rest: list[str]) -> str | None:
195
+ if not _rf_flag(rest):
196
+ return None
197
+ for t in rest:
198
+ if t.startswith("-"):
199
+ continue
200
+ if t in WHOLESALE_TARGETS or t.startswith(("..", "~")):
201
+ return t
202
+ norm = t[2:] if t.startswith("./") else t
203
+ if norm.rstrip("/").rsplit("/", 1)[-1] in SCRATCH_NAMES:
204
+ continue
205
+ root = norm.split("/", 1)[0]
206
+ if root in PROTECTED_ROOTS:
207
+ return t
208
+ return None
209
+
210
+
211
+ # Stub-overwrite thresholds: an existing source file this big replaced by
212
+ # content this much smaller is a guts-source overwrite, not an edit.
213
+ MIN_OLD_BYTES = 4096
214
+ SHRINK_RATIO = 0.20
215
+ BACKUP_MIN_RATIO = 0.5 # a real reserve is a copy, not a same-named stub
216
+
217
+ CODE_EXT = {
218
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs",
219
+ ".java", ".rb", ".c", ".h", ".cpp", ".hpp", ".cs", ".sh",
220
+ }
221
+
222
+
223
+ def _env_reserve() -> bool:
224
+ return (
225
+ os.environ.get("UAP_RESERVE_OK") == "1"
226
+ or os.environ.get("UAP_COMMITMENT_RESERVE_OFF") == "1"
227
+ or os.environ.get("DELIVER_ACTIVE") == "1"
228
+ )
229
+
230
+
231
+ def _backup_exists_today(target: Path, old_size: int) -> bool:
232
+ """A same-day backup under .uap-backups/<today>/ is the reserve that
233
+ unlocks a stub overwrite (the mandatory-file-backup flow). It must be a
234
+ real regular file of at least half the original's size -- an empty or
235
+ symlinked same-named file is not a reserve."""
236
+ today = date.today().isoformat()
237
+ floor = max(1, int(old_size * BACKUP_MIN_RATIO))
238
+ for root in {worktree_root(), repo_root()}:
239
+ day_dir = root / ".uap-backups" / today
240
+ if not day_dir.is_dir():
241
+ continue
242
+ candidates: list[Path] = []
243
+ try:
244
+ rel = target.relative_to(root)
245
+ mirrored = day_dir / rel
246
+ if mirrored.exists():
247
+ candidates.append(mirrored)
248
+ except ValueError:
249
+ pass
250
+ try:
251
+ candidates.extend(day_dir.rglob(target.name))
252
+ except (OSError, ValueError):
253
+ pass
254
+ for p in candidates:
255
+ try:
256
+ if p.name != target.name or p.is_symlink() or not p.is_file():
257
+ continue
258
+ if p.stat().st_size >= floor:
259
+ return True
260
+ except (OSError, ValueError):
261
+ continue
262
+ return False
263
+
264
+
265
+ def _check_bash(cmd: str) -> None:
266
+ stash_reserve = bool(_STASH_CREATE.search(_QUOTED_SPAN.sub(" ", cmd)))
267
+ for tokens in _segments(cmd):
268
+ tokens, seg_reserve = _strip_prefix(tokens)
269
+ if not tokens or seg_reserve or stash_reserve:
270
+ continue
271
+ verb = tokens[0].rsplit("/", 1)[-1]
272
+ if verb == "git":
273
+ sub, rest = _git_subcommand(tokens[1:])
274
+ why = _check_git_segment(sub, rest)
275
+ if why:
276
+ emit(False, MSG_PREFIX + why)
277
+ elif verb == "rm":
278
+ target = _check_rm_segment(tokens[1:])
279
+ if target:
280
+ emit(
281
+ False,
282
+ "commitment-reserve: recursive forced delete of a source "
283
+ f"root ('{target}') removes the work itself, not scratch. "
284
+ "Delete specific files, or create a backup/stash first.",
285
+ )
286
+ emit(True, "no all-in pattern")
287
+
288
+
289
+ def _check_write(args: dict) -> None:
290
+ raw_path = str(args.get("file_path") or args.get("path") or "")
291
+ if not raw_path:
292
+ emit(True, "no target path")
293
+ try:
294
+ target = Path(raw_path).resolve()
295
+ except (OSError, ValueError, RuntimeError):
296
+ emit(True, "unresolvable path")
297
+ if target.suffix.lower() not in CODE_EXT:
298
+ emit(True, "not a source file")
299
+ try:
300
+ old_size = target.stat().st_size
301
+ except (OSError, ValueError):
302
+ emit(True, "new file")
303
+ if old_size < MIN_OLD_BYTES:
304
+ emit(True, "existing file below stub-overwrite threshold")
305
+ content = args.get("content")
306
+ if content is None:
307
+ content = args.get("contents", args.get("text"))
308
+ if not isinstance(content, str):
309
+ emit(True, "no inline content to compare")
310
+ if len(content.encode("utf-8", "replace")) >= old_size * SHRINK_RATIO:
311
+ emit(True, "overwrite keeps comparable substance")
312
+ if _backup_exists_today(target, old_size):
313
+ emit(True, "reserve exists (.uap-backups backup for today)")
314
+ emit(
315
+ False,
316
+ "commitment-reserve: this overwrite replaces "
317
+ f"{target.name} ({old_size} bytes) with under 20% of its content -- that "
318
+ "is how real implementations get gutted into stubs. Edit incrementally, "
319
+ f"or back the file up to .uap-backups/{date.today().isoformat()}/ first "
320
+ "(that backup is the reserve that unlocks the overwrite).",
321
+ )
322
+
323
+
324
+ def main() -> None:
325
+ operation, args = parse_cli()
326
+ if not isinstance(args, dict):
327
+ emit(True, "non-dict args payload")
328
+ if _env_reserve():
329
+ emit(True, "reserve confirmed via environment")
330
+ if operation in BASH_OPS:
331
+ cmd = str(args.get("command") or "")
332
+ if not cmd:
333
+ emit(True, "no command payload")
334
+ _check_bash(cmd)
335
+ if operation in WRITE_OPS:
336
+ _check_write(args)
337
+ emit(True, "operation out of scope")
338
+
339
+
340
+ if __name__ == "__main__":
341
+ main()
@@ -0,0 +1,67 @@
1
+ # commitment-reserve
2
+
3
+ **Category**: safety
4
+ **Level**: REQUIRED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: safety, reversibility, backup, restraint, never-go-full
7
+
8
+ ## Rule
9
+
10
+ An **all-in move with no way back** is blocked until a reserve exists. Commit
11
+ hard to an approach — but never commit everything ("never go full": the ones
12
+ who hold something back are the ones who can still recover).
13
+
14
+ Matching is segment-anchored and quote-aware: a rule fires only when the
15
+ segment's leading verb is `git`/`rm`, so a `grep`, an `echo`, or a commit
16
+ message that merely *mentions* a destructive pattern never trips the gate.
17
+
18
+ Blocked without a reserve (`Bash`/`run_bash`):
19
+
20
+ - `git reset --hard` — reserve: `git stash` first (an inline
21
+ `git stash && …` in the same command also counts).
22
+ - Forced push (`--force`, `-f`, `+refspec`) — `--force-with-lease` is allowed
23
+ by this gate; if the project's git-safety hook blocks that too, the
24
+ sanctioned fallback is a merge commit, not a bigger hammer.
25
+ - `git clean -f…` / `--force` — reserve: `git stash -u` first. Dry runs
26
+ (`-n` / `--dry-run`) stay allowed.
27
+ - `git checkout`/`restore` wholesale discards — final arg `.`/`*`, including
28
+ `checkout HEAD -- .`, `restore --worktree .`, and bare `checkout -f`.
29
+ - Recursive **and** forced delete (`rm -rf` and flag-order variants) of a
30
+ protected source root (`src`, `test`, `tests`, `lib`, `tools`, `scripts`,
31
+ the policy-definition dir), of the whole tree (`.`, `*`, `/`), or of a
32
+ parent/home escape (`..`, `~`). Scratch/derived dirs stay allowed at any
33
+ depth (`node_modules`, `dist`, `src/__pycache__`, `src/generated`, …) —
34
+ they are reconstructible; source is not.
35
+
36
+ Blocked stub overwrites (`Write`):
37
+
38
+ - Overwriting an existing source file ≥ 4 KiB with content under 20% of its
39
+ size — the signature of a real implementation being gutted into a stub.
40
+ Reserve: back the file up to `.uap-backups/<today>/` first. The backup is
41
+ **verified**: a real regular file (not a symlink) of at least half the
42
+ original's size — an empty or unrelated same-named file does not count.
43
+ Incremental `Edit` operations are never touched.
44
+
45
+ Unlocks:
46
+
47
+ - An inline reserve-**creating** stash (`git stash`, `git stash push`,
48
+ `-u`/`--include-untracked`). Reserve-destroying or inert stash subcommands
49
+ (`drop`, `clear`, `pop`, `list`, `show`, `apply`, `branch`) do **not**
50
+ unlock.
51
+ - `UAP_RESERVE_OK=1` **only as a leading env assignment of the destructive
52
+ segment itself** (set it only after creating the backup/stash). A mention
53
+ elsewhere in the command does not count.
54
+ - A verified same-day `.uap-backups/` backup, for stub overwrites.
55
+
56
+ Operator overrides: `UAP_RESERVE_OK=1` / `UAP_COMMITMENT_RESERVE_OFF=1` in
57
+ the environment; `DELIVER_ACTIVE=1` sessions are exempt (the deliver harness
58
+ keeps its own snapshot reserve).
59
+
60
+ ## Why
61
+
62
+ Every catastrophic agent incident on this project was a full-commitment
63
+ failure: real implementation files deleted into stubs to satisfy gates,
64
+ destructive resets that vaporized uncommitted work, force-pushes with nothing
65
+ held back. The rule is not "never do destructive things" — it is "destructive
66
+ moves are allowed only once something is held in reserve", so the recovery
67
+ path always stays open.
@@ -0,0 +1,39 @@
1
+ # proportional-commitment
2
+
3
+ **Category**: custom
4
+ **Level**: RECOMMENDED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: judgment, proportionality, restraint
7
+
8
+ ## Rule
9
+
10
+ Effort and blast radius must scale with the task. Commit hard to an approach,
11
+ but never commit **everything** -- always hold a reserve of judgment and a way
12
+ back:
13
+
14
+ 1. **Smallest viable intervention first.** Do not rewrite a whole file when an
15
+ edit suffices; do not restructure a module to fix a line; do not regenerate
16
+ what can be patched.
17
+ 2. **No orchestration for small work.** Do not spawn subagents, decompose into
18
+ epics, or fan out parallel workers for a task whose diff is small. The
19
+ overhead must be justified by the work, not by enthusiasm.
20
+ 3. **No destructive reset as a first resort.** `git reset --hard`, `rm -rf`,
21
+ force-push, and wholesale overwrites are last resorts taken only after the
22
+ incremental path has demonstrably failed -- and only with a backup or branch
23
+ point held in reserve.
24
+ 4. **Keep one alternative alive.** Before committing to any expensive path
25
+ (long build, large refactor, multi-agent run), be able to state the fallback
26
+ you will use if it fails. If there is no fallback, the commitment is too big.
27
+ 5. **Stop doubling down.** If the same approach has failed twice, the third
28
+ attempt must be a different approach, not a louder version of the same one.
29
+
30
+ ## Why
31
+
32
+ Total commitment is a trap: an agent that goes all-in on one approach, one
33
+ rewrite, or one orchestration pattern surrenders the judgment needed to step
34
+ back out ("hold something back" -- the Tropic Thunder rule). Every major
35
+ incident in this project's history is a full-commitment failure: thousands of
36
+ identical subagent spawns, real implementation files deleted into stubs,
37
+ monolithic sessions overflowing their context window, gates saturated until
38
+ the remedy for a blocked action was itself blocked. Proportional commitment
39
+ keeps the recovery path open.