@miller-tech/uap 1.128.0 → 1.128.2

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.
@@ -3,31 +3,314 @@
3
3
  *
4
4
  * Captures the project state before the convergence loop runs so deliver can
5
5
  * roll back if it ends up worse (by the real gates' measure) than it started.
6
- * Excludes heavy/derived trees (.git, node_modules) they aren't part of the
7
- * task's source and would make snapshots slow.
6
+ * Excludes heavy/derived DIRECTORIES (.git, node_modules, target, .venv, …):
7
+ * they aren't part of the task's source and would make snapshots slow. The
8
+ * contract is symmetric — an excluded directory is neither snapshotted nor
9
+ * touched by restore, at any depth. Files sharing an excluded name (e.g. a
10
+ * script called `build`) are snapshotted normally.
11
+ *
12
+ * Hardening (2026-07-08, after the project-i incident where a 424 GB Rust
13
+ * `target/` tree was copied into a 61 GB RAM tmpfs):
14
+ * - snapshots land on real disk (~/.cache/uap/snapshots, UAP_SNAPSHOT_DIR
15
+ * overrides — absolute paths only), never os.tmpdir(), which is RAM-backed
16
+ * on many Linux setups;
17
+ * - the source tree is size-guarded before copying (UAP_SNAPSHOT_MAX_MB,
18
+ * default 4096) — over the cap, snapshotTree returns null and the caller
19
+ * degrades to "no rollback this run" instead of exhausting the machine;
20
+ * - snapshotTree never throws: any failure cleans up its partial copy and
21
+ * returns null;
22
+ * - restoreTree validates the snapshot before touching the project, restores
23
+ * copy-first (prune extras, then merge back) so an interrupted restore
24
+ * never leaves a gutted tree, and marks the snapshot preserve-on-failure
25
+ * so the reaper won't collect the only good copy;
26
+ * - each snapshot carries a pid marker; stale snapshots from dead processes
27
+ * (crashes, SIGKILL, ENOSPC mid-copy) are reaped before a new one is taken.
28
+ */
29
+ import { cpSync, rmSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync, lstatSync, statSync, } from 'fs';
30
+ import { join, basename, isAbsolute, resolve } from 'path';
31
+ import { tmpdir, homedir } from 'os';
32
+ const EXCLUDE = new Set([
33
+ // VCS / UAP-internal
34
+ '.git',
35
+ '.worktrees',
36
+ '.uap-deliver',
37
+ '.uap-backups',
38
+ // JS
39
+ 'node_modules',
40
+ 'dist',
41
+ 'build',
42
+ 'out',
43
+ 'coverage',
44
+ '.next',
45
+ '.nuxt',
46
+ '.svelte-kit',
47
+ '.turbo',
48
+ '.cache',
49
+ '.parcel-cache',
50
+ // Rust / JVM / Go
51
+ 'target',
52
+ '.gradle',
53
+ 'vendor',
54
+ // Python
55
+ '.venv',
56
+ 'venv',
57
+ '__pycache__',
58
+ '.pytest_cache',
59
+ '.mypy_cache',
60
+ '.ruff_cache',
61
+ '.tox',
62
+ ]);
63
+ /** Marker written inside each snapshot so the reaper can tell live from stale. */
64
+ const META_FILE = '.uap-snap.json';
65
+ const SNAP_PREFIX = 'uap-snap-';
66
+ /** Age past which a marker-less (legacy/partial) snapshot is considered stale. */
67
+ const LEGACY_STALE_MS = 24 * 60 * 60 * 1000;
68
+ /** Backstop against pid reuse pinning a dead snapshot forever. */
69
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
70
+ const DEFAULT_MAX_MB = 4096;
71
+ /** True for a directory whose name marks it as derived/heavy (dir-only contract). */
72
+ function isExcludedDir(path, name) {
73
+ if (!EXCLUDE.has(name))
74
+ return false;
75
+ try {
76
+ return lstatSync(path).isDirectory();
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /** Disk-backed base directory for snapshots (never os.tmpdir — often RAM tmpfs). */
83
+ export function snapshotBaseDir() {
84
+ const raw = process.env.UAP_SNAPSHOT_DIR?.trim();
85
+ // Relative/garbage overrides silently landing inside the project tree would
86
+ // snapshot into the thing being snapshotted — absolute paths only.
87
+ const base = raw && isAbsolute(raw) ? resolve(raw) : join(homedir(), '.cache', 'uap', 'snapshots');
88
+ mkdirSync(base, { recursive: true });
89
+ return base;
90
+ }
91
+ function maxSnapshotBytes() {
92
+ const mb = Number(process.env.UAP_SNAPSHOT_MAX_MB);
93
+ return (Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_MAX_MB) * 1024 * 1024;
94
+ }
95
+ /**
96
+ * Sum file sizes under root (skipping excluded dirs and symlinks), bailing out
97
+ * as soon as the running total exceeds `limit` so huge trees cost one early
98
+ * partial walk, not a full traversal.
99
+ */
100
+ function treeSizeExceeds(root, limit) {
101
+ let total = 0;
102
+ const stack = [root];
103
+ while (stack.length > 0) {
104
+ const dir = stack.pop();
105
+ let entries;
106
+ try {
107
+ entries = readdirSync(dir);
108
+ }
109
+ catch {
110
+ continue; // unreadable dir — the copy will surface real problems
111
+ }
112
+ for (const name of entries) {
113
+ const path = join(dir, name);
114
+ let st;
115
+ try {
116
+ st = lstatSync(path);
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ if (st.isSymbolicLink())
122
+ continue;
123
+ if (st.isDirectory()) {
124
+ if (!EXCLUDE.has(name))
125
+ stack.push(path);
126
+ }
127
+ else {
128
+ total += st.size;
129
+ if (total > limit)
130
+ return true;
131
+ }
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+ function pidAlive(pid) {
137
+ try {
138
+ process.kill(pid, 0);
139
+ return true;
140
+ }
141
+ catch (err) {
142
+ // EPERM means the process exists but isn't ours.
143
+ return err.code === 'EPERM';
144
+ }
145
+ }
146
+ function isStaleSnapshot(path) {
147
+ try {
148
+ const metaPath = join(path, META_FILE);
149
+ // The tmpdir sweep reads world-writable locations: a planted FIFO or
150
+ // symlink-to-/dev/zero marker must not hang or balloon the read.
151
+ const st = lstatSync(metaPath);
152
+ if (!st.isFile() || st.size > 4096)
153
+ throw new Error('untrusted marker');
154
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
155
+ if (meta.preserve === true)
156
+ return false;
157
+ if (!Number.isInteger(meta.pid) || meta.pid <= 1)
158
+ return true;
159
+ const created = Date.parse(meta.created ?? '');
160
+ if (Number.isFinite(created) && Date.now() - created > MAX_AGE_MS)
161
+ return true;
162
+ return !pidAlive(meta.pid);
163
+ }
164
+ catch {
165
+ // No/unreadable/untrusted marker: a partial copy or a pre-hardening
166
+ // snapshot. Only reap once it is old enough that no live run can
167
+ // plausibly own it.
168
+ try {
169
+ return Date.now() - statSync(path).mtimeMs > LEGACY_STALE_MS;
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ }
176
+ /**
177
+ * Remove snapshots left behind by dead processes (crash, SIGKILL, ENOSPC
178
+ * mid-copy). Sweeps the snapshot base dir, plus os.tmpdir() for snapshots
179
+ * created by pre-hardening versions (UAP_SNAPSHOT_SKIP_TMP_SWEEP=1 disables
180
+ * the legacy sweep). Fail-soft: reaping must never block a delivery run.
181
+ */
182
+ export function reapStaleSnapshots() {
183
+ const bases = new Set([snapshotBaseDir()]);
184
+ if (process.env.UAP_SNAPSHOT_SKIP_TMP_SWEEP !== '1')
185
+ bases.add(tmpdir());
186
+ for (const base of bases) {
187
+ let entries;
188
+ try {
189
+ entries = readdirSync(base);
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ for (const name of entries) {
195
+ if (!name.startsWith(SNAP_PREFIX))
196
+ continue;
197
+ const path = join(base, name);
198
+ try {
199
+ if (isStaleSnapshot(path))
200
+ rmSync(path, { recursive: true, force: true });
201
+ }
202
+ catch {
203
+ /* fail-soft */
204
+ }
205
+ }
206
+ }
207
+ }
208
+ /**
209
+ * Copy the project tree (minus excluded dirs) to a disk-backed snapshot dir.
210
+ * Returns the snapshot path, or null when the snapshot is skipped (tree over
211
+ * the size cap) or fails for any reason — callers must treat null as "no
212
+ * rollback available for this run", not as an error. Never throws.
8
213
  */
9
- import { cpSync, rmSync, mkdtempSync, readdirSync } from 'fs';
10
- import { join, basename } from 'path';
11
- import { tmpdir } from 'os';
12
- const EXCLUDE = new Set(['.git', 'node_modules', '.uap-deliver']);
13
- /** Copy the project tree (minus excluded dirs) to a temp dir; return its path. */
14
214
  export function snapshotTree(root) {
15
- const snap = mkdtempSync(join(tmpdir(), 'uap-snap-'));
16
- cpSync(root, snap, {
17
- recursive: true,
18
- filter: (src) => !EXCLUDE.has(basename(src)),
19
- });
20
- return snap;
21
- }
22
- /** Restore the project tree from a snapshot: drop current entries, copy back. */
23
- export function restoreTree(root, snap) {
24
- for (const entry of readdirSync(root)) {
25
- if (EXCLUDE.has(entry))
215
+ let snap;
216
+ try {
217
+ reapStaleSnapshots();
218
+ const limit = maxSnapshotBytes();
219
+ if (treeSizeExceeds(root, limit)) {
220
+ console.warn(` no-regress: snapshot skipped — project tree exceeds ${Math.round(limit / (1024 * 1024))} MB ` +
221
+ '(raise UAP_SNAPSHOT_MAX_MB to override)');
222
+ return null;
223
+ }
224
+ snap = mkdtempSync(join(snapshotBaseDir(), SNAP_PREFIX));
225
+ }
226
+ catch (err) {
227
+ console.warn(` no-regress: snapshot unavailable (${err.message}) — rollback disabled for this run`);
228
+ return null;
229
+ }
230
+ try {
231
+ cpSync(root, snap, {
232
+ recursive: true,
233
+ // Never filter the root itself — a project dir named `build`/`dist`/…
234
+ // must still snapshot its contents, not produce an empty snapshot.
235
+ filter: (src) => src === root || !isExcludedDir(src, basename(src)),
236
+ });
237
+ writeFileSync(join(snap, META_FILE), JSON.stringify({ pid: process.pid, created: new Date().toISOString() }));
238
+ return snap;
239
+ }
240
+ catch (err) {
241
+ // Never leak a partial snapshot (the original failure mode: ENOSPC
242
+ // mid-copy left tens of GB behind).
243
+ rmSync(snap, { recursive: true, force: true });
244
+ console.warn(` no-regress: snapshot failed (${err.message}) — rollback disabled for this run`);
245
+ return null;
246
+ }
247
+ }
248
+ /**
249
+ * Delete entries under rootDir that the snapshot doesn't contain (or whose
250
+ * file/dir type flipped), recursing with the same excluded-dir contract as
251
+ * the snapshot filter so nested excluded trees (a workspace's node_modules,
252
+ * a nested target/) survive rollback untouched.
253
+ */
254
+ function pruneToSnapshot(rootDir, snapDir) {
255
+ for (const entry of readdirSync(rootDir)) {
256
+ const rootPath = join(rootDir, entry);
257
+ let rootStat;
258
+ try {
259
+ rootStat = lstatSync(rootPath);
260
+ }
261
+ catch {
262
+ continue;
263
+ }
264
+ if (rootStat.isDirectory() && EXCLUDE.has(entry))
26
265
  continue;
27
- rmSync(join(root, entry), { recursive: true, force: true });
266
+ let snapStat = null;
267
+ try {
268
+ snapStat = lstatSync(join(snapDir, entry));
269
+ }
270
+ catch {
271
+ /* not in snapshot */
272
+ }
273
+ if (!snapStat) {
274
+ rmSync(rootPath, { recursive: true, force: true });
275
+ }
276
+ else if (rootStat.isDirectory() && snapStat.isDirectory()) {
277
+ pruneToSnapshot(rootPath, join(snapDir, entry));
278
+ }
279
+ else if (rootStat.isDirectory() !== snapStat.isDirectory()) {
280
+ // Type flip (file↔dir): remove so the merge copy can recreate it.
281
+ rmSync(rootPath, { recursive: true, force: true });
282
+ }
283
+ }
284
+ }
285
+ /**
286
+ * Restore the project tree from a snapshot. Validates the snapshot BEFORE
287
+ * touching the project, then prunes extras and merge-copies the snapshot
288
+ * back — copy-first ordering so an interruption leaves a recoverable union
289
+ * of both trees, never a gutted project. On failure the snapshot is marked
290
+ * preserve (reaper-immune) and the error is rethrown.
291
+ */
292
+ export function restoreTree(root, snap) {
293
+ const entries = readdirSync(snap); // throws if the snapshot is gone — root untouched
294
+ if (!entries.includes(META_FILE)) {
295
+ throw new Error(`refusing to restore from ${snap}: missing ${META_FILE} marker`);
296
+ }
297
+ try {
298
+ pruneToSnapshot(root, snap);
299
+ for (const entry of entries) {
300
+ if (entry === META_FILE)
301
+ continue;
302
+ cpSync(join(snap, entry), join(root, entry), { recursive: true });
303
+ }
28
304
  }
29
- for (const entry of readdirSync(snap)) {
30
- cpSync(join(snap, entry), join(root, entry), { recursive: true });
305
+ catch (err) {
306
+ try {
307
+ writeFileSync(join(snap, META_FILE), JSON.stringify({ pid: process.pid, created: new Date().toISOString(), preserve: true }));
308
+ }
309
+ catch {
310
+ /* marker rewrite is best-effort */
311
+ }
312
+ console.warn(` no-regress: restore FAILED — snapshot preserved at ${snap}`);
313
+ throw err;
31
314
  }
32
315
  }
33
316
  /** Remove a snapshot directory. */
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../../src/delivery/snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,IAAI,CAAC;AAC9D,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,MAAM,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC;AAE5B,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,cAAc,EAAE,cAAc,CAAC,CAAC,CAAC;AAElE,kFAAkF;AAClF,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,MAAM,IAAI,GAAG,WAAW,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,WAAW,CAAC,CAAC,CAAC;IACtD,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE;QACjB,SAAS,EAAE,IAAI;QACf,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;KAC7C,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,WAAW,CAAC,IAAY,EAAE,IAAY;IACpD,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;QACtC,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,SAAS;QACjC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9D,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;QACtC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACpE,CAAC;AACH,CAAC;AAED,mCAAmC;AACnC,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AACjD,CAAC"}
1
+ {"version":3,"file":"snapshot.js","sourceRoot":"","sources":["../../src/delivery/snapshot.ts"],"names":[],"mappings":"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"}
@@ -137,6 +137,19 @@ uap deliver "add the orders endpoint" --until-deployed
137
137
  | `--project-root <path>` | Project whose gates define delivery (default: cwd) |
138
138
  | `--dry-run` | Show detected gates and plan without calling the model |
139
139
  | `--json` | Emit a JSON result |
140
+ | `--keep-best` | Never regress: snapshot the project first, roll back if the run ends with a worse required-gate score |
141
+
142
+ ### `--keep-best` snapshots
143
+
144
+ The pre-run snapshot lands on real disk under `~/.cache/uap/snapshots`
145
+ (`UAP_SNAPSHOT_DIR` overrides; absolute paths only) — never `/tmp`, which is
146
+ RAM-backed on many Linux systems. Derived directories (`.git`,
147
+ `node_modules`, `target`, `.venv`, `dist`, `build`, …) are neither
148
+ snapshotted nor touched by a rollback, at any depth. Trees larger than
149
+ `UAP_SNAPSHOT_MAX_MB` (default 4096) skip the snapshot and the run proceeds
150
+ with rollback disabled. Snapshots orphaned by killed runs are reaped
151
+ automatically on the next `--keep-best` run; a snapshot whose restore failed
152
+ is preserved and its path printed.
140
153
 
141
154
  ---
142
155
 
@@ -89,6 +89,8 @@ Top level: `version`, `project`, `memory`, `worktree`, `costOptimization`,
89
89
  | `UAP_PARALLEL` | `false` disables parallelism | enabled |
90
90
  | `UAP_BENCHMARK_MODE` | `true` enables benchmark template mode | off |
91
91
  | `UAP_BENCHMARK_PARALLEL` | Parallel model count in benchmarks | — |
92
+ | `UAP_SNAPSHOT_DIR` | Base dir for `--keep-best` snapshots (absolute path) | `~/.cache/uap/snapshots` |
93
+ | `UAP_SNAPSHOT_MAX_MB` | Skip the `--keep-best` snapshot (rollback disabled) when the tree exceeds this size | `4096` |
92
94
  | `HERMES_HOME` | Hermes home dir | `~/.hermes` |
93
95
  | `FACTORY_PROJECT_DIR` | Project dir in Factory hook commands | — |
94
96
  | `FORGE_UAP_PROJECT` | Project dir in ForgeCode hook scripts | `.` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.128.0",
3
+ "version": "1.128.2",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
23
23
  "test:ci": "vitest run",
24
- "test:enforcers": "python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache",
24
+ "test:enforcers": "python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -86,32 +86,40 @@ def main() -> None:
86
86
  op, args = parse_cli()
87
87
  if op not in EDIT_OPS:
88
88
  emit(True, "not a file-edit operation")
89
+ return
89
90
 
90
91
  target = args.get("file_path") or args.get("path") or args.get("target") or ""
91
92
  if not target:
92
93
  emit(True, "no file path in args")
94
+ return
93
95
 
94
96
  root = repo_root()
95
97
  try:
96
98
  rel = str(Path(target).resolve().relative_to(root))
97
99
  except ValueError:
98
100
  emit(True, "target outside repo")
101
+ return
99
102
 
100
103
  rel_posix = rel.replace(os.sep, "/")
101
104
  low = rel_posix.lower()
102
105
 
103
106
  if not low.endswith(SOURCE_EXTS):
104
107
  emit(True, "not source code")
108
+ return
105
109
  if any(rel_posix.startswith(p) for p in EXEMPT_PREFIXES):
106
110
  emit(True, f"exempt path: {rel_posix}")
111
+ return
107
112
  if any(m in "/" + low for m in TEST_MARKERS):
108
113
  emit(True, "test file (protected by deliver itself)")
114
+ return
109
115
 
110
116
  # Escape hatches.
111
117
  if os.environ.get("UAP_DELIVER_ACTIVE") == "1":
112
118
  emit(True, "inside a deliver-driven run")
119
+ return
113
120
  if os.environ.get("UAP_DELIVER_BYPASS") == "1":
114
121
  emit(True, "UAP_DELIVER_BYPASS override set")
122
+ return
115
123
 
116
124
  # #3-F: terse, imperative, model-parseable. Weak local models otherwise
117
125
  # retry the blocked edit or hallucinate completion ("the files exist") when
@@ -143,6 +151,7 @@ def main() -> None:
143
151
  route="deliver",
144
152
  deliverHint=f'implement the intended change to {rel_posix}',
145
153
  )
154
+ return
146
155
 
147
156
  # Advisory (opt-out): never blocks. Surface the nudge, then allow.
148
157
  print(f"[delivery-enforcement advisory] {msg}", file=sys.stderr)
@@ -150,4 +159,4 @@ def main() -> None:
150
159
 
151
160
 
152
161
  if __name__ == "__main__":
153
- main()
162
+ main()
@@ -16,7 +16,10 @@ WATCHED_RE = re.compile(
16
16
  r"infra/helm_charts/[^/]*envoy|infra/helm_charts/[^/]*sentinel)",
17
17
  re.I,
18
18
  )
19
- COMMIT_OPS = {"git-commit", "git commit", "Bash"}
19
+ # NOTE: bare "Bash" used to be in this set, which made EVERY shell command a
20
+ # gate point — including the `uap schema-diff` remedy itself (self-deadlock).
21
+ # Gate only actual commit/push commands (main() also inspects cmd content).
22
+ COMMIT_OPS = {"git-commit", "git commit"}
20
23
  RECENT_SEC = 3600
21
24
 
22
25
 
@@ -35,17 +38,31 @@ def schema_diff_ok(root: Path) -> bool:
35
38
  return False
36
39
  try:
37
40
  con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
38
- cur = con.execute(
39
- "SELECT timestamp FROM session_memories "
40
- "WHERE content LIKE '%schema-diff%pass%' "
41
- "ORDER BY id DESC LIMIT 1"
42
- )
43
- row = cur.fetchone()
41
+ # `uap memory store` writes to `memories` (type 'action'), while older
42
+ # UAP wrote session rows to `session_memories` — accept the marker from
43
+ # either table so the documented remedy actually clears the gate.
44
+ row = None
45
+ for table in ("memories", "session_memories"):
46
+ try:
47
+ cur = con.execute(
48
+ f"SELECT timestamp FROM {table} "
49
+ "WHERE content LIKE '%schema-diff%pass%' "
50
+ "ORDER BY id DESC LIMIT 1"
51
+ )
52
+ r = cur.fetchone()
53
+ if r and (row is None or r[0] > row[0]):
54
+ row = r
55
+ except sqlite3.Error:
56
+ continue
44
57
  con.close()
45
58
  if not row:
46
59
  return False
47
60
  try:
48
- ts = time.mktime(time.strptime(row[0][:19], "%Y-%m-%dT%H:%M:%S"))
61
+ # Timestamps are stored as UTC ISO-8601 (trailing 'Z'); parse them
62
+ # as UTC — time.mktime would misread them as local time and expire
63
+ # the marker hours early (or late) depending on the host TZ.
64
+ import calendar
65
+ ts = calendar.timegm(time.strptime(row[0][:19], "%Y-%m-%dT%H:%M:%S"))
49
66
  except Exception: # noqa: BLE001
50
67
  return False
51
68
  return (time.time() - ts) < RECENT_SEC
@@ -0,0 +1,115 @@
1
+ """Tests for the schema-diff-gate enforcer fixes (self-deadlock + marker read).
2
+
3
+ Covers the 2026-07-08 mid-session incident: bare "Bash" in COMMIT_OPS gated
4
+ EVERY shell command (including the `uap schema-diff` remedy — self-deadlock),
5
+ and the pass-marker check read only the legacy session_memories table and
6
+ parsed UTC timestamps with time.mktime (host-timezone skew).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sqlite3
14
+ import subprocess
15
+ import tempfile
16
+ import unittest
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+
20
+ REPO = Path(__file__).resolve().parents[3]
21
+ ENFORCER = REPO / "src" / "policies" / "enforcers" / "schema_diff_gate.py"
22
+
23
+
24
+ def run_gate(op: str, args: dict, root: Path, tz: str | None = None):
25
+ env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
26
+ env["UAP_REPO_ROOT"] = str(root)
27
+ env["UAP_WORKTREE_ROOT"] = str(root)
28
+ if tz:
29
+ env["TZ"] = tz
30
+ r = subprocess.run(
31
+ ["python3", str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
32
+ cwd=root,
33
+ env=env,
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=30,
37
+ )
38
+ try:
39
+ payload = json.loads(r.stdout or "{}")
40
+ except json.JSONDecodeError:
41
+ payload = {}
42
+ return r.returncode, payload.get("allowed", False), payload.get("reason", "")
43
+
44
+
45
+ class SchemaDiffGateTest(unittest.TestCase):
46
+ def setUp(self):
47
+ self._tmp = tempfile.TemporaryDirectory(prefix="schema-gate-")
48
+ self.root = Path(self._tmp.name)
49
+ subprocess.run(["git", "init", "-q"], cwd=self.root, check=True)
50
+ subprocess.run(
51
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init"],
52
+ cwd=self.root,
53
+ check=True,
54
+ )
55
+ (self.root / "migrations").mkdir()
56
+ (self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id int);")
57
+ subprocess.run(["git", "add", "migrations"], cwd=self.root, check=True)
58
+
59
+ def tearDown(self):
60
+ self._tmp.cleanup()
61
+
62
+ def write_marker(self, iso_timestamp: str, table: str = "memories"):
63
+ mem = self.root / "agents" / "data" / "memory"
64
+ mem.mkdir(parents=True)
65
+ con = sqlite3.connect(mem / "short_term.db")
66
+ con.execute(f"CREATE TABLE {table} (id INTEGER PRIMARY KEY, content TEXT, timestamp TEXT)")
67
+ con.execute(
68
+ f"INSERT INTO {table} (content, timestamp) VALUES (?, ?)",
69
+ ("schema-diff pass: verified", iso_timestamp),
70
+ )
71
+ con.commit()
72
+ con.close()
73
+
74
+ def test_ordinary_bash_is_not_a_gate_point(self):
75
+ """Regression: bare Bash op must not gate — that deadlocked the remedy itself."""
76
+ code, allowed, reason = run_gate("Bash", {"command": "uap schema-diff"}, self.root)
77
+ self.assertEqual(code, 0)
78
+ self.assertTrue(allowed)
79
+ self.assertIn("not a commit/push gate point", reason)
80
+
81
+ def test_commit_command_still_gated(self):
82
+ code, allowed, reason = run_gate("Bash", {"command": 'git commit -m "add migration"'}, self.root)
83
+ self.assertEqual(code, 2)
84
+ self.assertFalse(allowed)
85
+ self.assertIn("uap schema-diff", reason)
86
+
87
+ def test_fresh_marker_in_memories_table_clears_gate(self):
88
+ """`uap memory store` writes to `memories`; the gate must accept it."""
89
+ self.write_marker(datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
90
+ code, allowed, reason = run_gate("git-commit", {}, self.root)
91
+ self.assertEqual(code, 0)
92
+ self.assertTrue(allowed)
93
+ self.assertIn("recent schema-diff pass", reason)
94
+
95
+ def test_legacy_session_memories_table_still_accepted(self):
96
+ self.write_marker(
97
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), table="session_memories"
98
+ )
99
+ code, allowed, _ = run_gate("git-commit", {}, self.root)
100
+ self.assertEqual(code, 0)
101
+ self.assertTrue(allowed)
102
+
103
+ def test_stale_marker_blocks_regardless_of_host_timezone(self):
104
+ """Pre-fix, time.mktime read UTC timestamps as local: east-of-UTC hosts
105
+ saw stale markers shifted into the future and the gate cleared on a
106
+ 2h-old pass."""
107
+ stale = (datetime.now(timezone.utc) - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ")
108
+ self.write_marker(stale)
109
+ code, allowed, _ = run_gate("git-commit", {}, self.root, tz="Australia/Sydney")
110
+ self.assertEqual(code, 2)
111
+ self.assertFalse(allowed)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main()