@miller-tech/uap 1.127.0 → 1.128.1

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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"}
package/docs/INDEX.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # UAP Documentation
2
2
 
3
- The complete documentation for the **Universal Agent Protocol** (`@miller-tech/uap` v1.93.1) — the discipline layer that turns a talented-but-unreliable AI coding agent into a dependable member of your software delivery line.
3
+ The complete documentation for the **Universal Agent Protocol** (`@miller-tech/uap` v1.124.1) — the discipline layer that turns a talented-but-unreliable AI coding agent into a dependable member of your software delivery line.
4
+
5
+ > **Reviewing the system?** The reverse-engineered, code-verified reference set lives in
6
+ > [`documentation/`](../documentation/architecture.md): architecture, flows, permissions
7
+ > (incl. trust boundaries and enforcement gaps), variables/secrets, scheduled work, and
8
+ > embedded automation. Start there for an honest map of what's enforced vs. documented.
4
9
 
5
10
  New here? The friendliest way in is the **[Delivery Pipeline tour](guides/DELIVERY_PIPELINE.md)** — it walks the whole factory floor, station by station, showing where agents normally break and what UAP puts in place to catch it. Then grab the [Quickstart](getting-started/QUICKSTART.md).
6
11
 
@@ -45,6 +50,7 @@ Full map: **[The UAP Delivery Pipeline](guides/DELIVERY_PIPELINE.md)**.
45
50
  | [Memory](guides/MEMORY.md) | The 4-tier memory system, write-gates, semantic recall |
46
51
  | [MCP Router](guides/MCP_ROUTER.md) | Token-optimizing tool proxy + FTS5 output compression |
47
52
  | [Worktree Workflow](guides/WORKTREE_WORKFLOW.md) | Branch-per-feature isolation, auto-PR, enforcement |
53
+ | [Sandbox](guides/SANDBOX.md) | Kernel-level (bubblewrap) write isolation — the boundary that survives `--dangerously-skip-permissions` |
48
54
  | [Policies](guides/POLICIES.md) | Executable policy gates that block non-compliant tool calls |
49
55
  | [Multi-Model Routing](guides/MULTI_MODEL.md) | Plan → route → execute across 7 model profiles |
50
56
  | [Droids & Skills](guides/DROIDS_AND_SKILLS.md) | 38 expert droids, 32 skills, the expert router |
@@ -1,6 +1,11 @@
1
1
  # UAP Architecture Overview
2
2
 
3
- `v1.93.1` · 223 TypeScript modules across 18 `src/` subsystems · 170+ test suites
3
+ `v1.124.1` · ~223 TypeScript modules across 18 `src/` subsystems · 200+ test suites
4
+
5
+ > **Code-verified reference:** this narrative overview is the intent-level floor plan. For
6
+ > the reverse-engineered, implementation-accurate map — including trust boundaries,
7
+ > enforcement planes, and where enforcement diverges from the prose — see
8
+ > [`documentation/architecture.md`](../../documentation/architecture.md).
4
9
 
5
10
  > **🏭 Where this fits:** Whole pipeline — this is the factory-floor map. A bare
6
11
  > agent walks work from an idea to a shipped change with no stations in between,
@@ -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
 
@@ -0,0 +1,99 @@
1
+ # The Sandbox — kernel-level isolation for skip-permissions sessions
2
+
3
+ > **🏭 Where this fits:** Isolation station. The hook-based worktree/workdir guards
4
+ > (`PreToolUse`) are the everyday boundary, but a harness launched with
5
+ > `--dangerously-skip-permissions` ignores hook denials entirely. The sandbox is the
6
+ > compensating control that *can't* be skipped — because it's enforced by the Linux
7
+ > kernel, not by a hook the agent's flags can wave away.
8
+ > **What it delivers:** an agent that can read your whole machine but can only *write*
9
+ > inside the job's workdir (plus a few caches), so a mis-fired `rm -rf` or an errant Write
10
+ > to `~/.ssh` fails at the kernel with `EROFS`.
11
+
12
+ ## What it is
13
+
14
+ `uap sandbox -- <command>` runs `<command>` inside a **bubblewrap (`bwrap`)** mount
15
+ namespace: the whole filesystem is bind-mounted **read-only**, with a small set of
16
+ **writable holes** punched through. This binds Write/Edit tools, `Bash`, and every
17
+ subprocess they spawn equally — nothing that runs inside can write outside the holes.
18
+
19
+ It is *not* a container, *not* a VM, and *not* env-variable restriction. It is a Linux
20
+ namespace, so it has near-zero startup cost and shares the host network (deliberately — the
21
+ agent still needs to reach the local proxy at `127.0.0.1:4000`).
22
+
23
+ ```bash
24
+ # Typical wiring: sandbox wraps the whole harness invocation
25
+ uap sandbox -- env … claude --dangerously-skip-permissions …
26
+ ```
27
+
28
+ ## What's writable (and nothing else)
29
+
30
+ `src/cli/sandbox.ts:86-105` binds these as read-write; everything else is read-only:
31
+
32
+ - the **resolved workdir** (the job's project directory)
33
+ - `/tmp`, a fresh tmpfs `/var/tmp`, and `/run`
34
+ - `~/.claude`, `~/.cache`, `~/.npm` (if present) — so the harness and package tooling work
35
+ - anything listed in **`UAP_SANDBOX_ALLOW`** (colon-separated extra prefixes)
36
+
37
+ Plus `--unshare-pid --die-with-parent --new-session` so the sandboxed process tree can't
38
+ outlive or escape its parent.
39
+
40
+ **Guardrail:** it refuses to sandbox an over-broad workdir — `$HOME`, `/`, and `/home` are
41
+ rejected (`sandbox.ts:81-83`), because a writable hole that big would defeat the point.
42
+
43
+ **Requires bwrap:** if `bwrap` isn't installed, `uap sandbox` fails with exit 127 rather
44
+ than silently running your command unsandboxed. There is no unsafe fallback.
45
+
46
+ ## Why hooks aren't enough
47
+
48
+ The `workdir-scope` and `worktree_required` `PreToolUse` enforcers return an exit-2 deny to
49
+ block an out-of-bounds edit — but a harness run with `--dangerously-skip-permissions`
50
+ **does not consult PreToolUse hooks at all**, so those denials never fire. The sandbox is
51
+ the answer: the kernel doesn't care what flags the harness was launched with. This is the
52
+ one boundary that survives skip-permissions (see also `documentation/permissions.md`, which
53
+ maps every enforcement plane and where each one holds or fails).
54
+
55
+ ## Browser MCP tool stripping (why it's here)
56
+
57
+ A subtlety worth knowing: when a session runs under `uap sandbox`, the Chrome extension
58
+ socket that the `mcp__claude-in-chrome__*` browser tools rely on is **not** bound into the
59
+ namespace — so those tools would connect to nothing and the model loops on a dead end.
60
+
61
+ The sandbox handles this without changing the tool list itself. `sandboxCustomHeaders()`
62
+ (`sandbox.ts:30-39`) appends a marker header `X-Uap-Sandbox: 1` to
63
+ `ANTHROPIC_CUSTOM_HEADERS`, which the harness forwards on every request. The **proxy** reads
64
+ it and calls `_strip_sandbox_unreachable_tools` (`anthropic_proxy.py:4393`), removing any
65
+ tool whose name starts with a prefix in `PROXY_SANDBOX_UNREACHABLE_PREFIXES` (default
66
+ `mcp__claude-in-chrome__`). The model never sees the unreachable browser tools and falls
67
+ back to `WebFetch` / local reads instead. (This is the "strip unreachable browser MCP tools
68
+ from sandboxed sessions" change from v1.63.0.)
69
+
70
+ ## Escape hatches (operator-only)
71
+
72
+ | Variable | Effect |
73
+ |---|---|
74
+ | `UAP_SANDBOX_OFF=1` | Run the command as-is, no sandbox (for debugging). |
75
+ | `UAP_SANDBOX_ALLOW=/path:/other` | Extra writable prefixes. |
76
+ | `UAP_SANDBOX_WORKDIR=/path` | Override the resolved workdir. |
77
+ | `PROXY_SANDBOX_UNREACHABLE_PREFIXES` | Change which tool prefixes are stripped (proxy side). |
78
+
79
+ Treat all of these as trusted-launch-environment settings — they widen the boundary, and
80
+ the model should never be able to set them for itself.
81
+
82
+ ## Limits (be honest about them)
83
+
84
+ - **Network is not restricted.** A sandboxed agent can still make outbound network calls;
85
+ the sandbox is a *filesystem* boundary. Egress control is the proxy's job
86
+ (`ANTHROPIC_PASSTHROUGH_MODELS=__local_only__` for the cloud path).
87
+ - **The writable holes are real writable areas.** Anything under the workdir, `/tmp`, or
88
+ `~/.cache`/`~/.claude`/`~/.npm` can be modified — including caches that persist across
89
+ runs.
90
+ - **It only helps if you actually launch under it.** The workdir boundary is only real for
91
+ sessions started with `uap sandbox`; a plain skip-permissions session has no filesystem
92
+ boundary at all.
93
+
94
+ ## Related
95
+
96
+ - `documentation/permissions.md` — all enforcement planes, the resource matrix, and the
97
+ findings (including why the hook plane doesn't survive skip-permissions)
98
+ - [Worktree Workflow](WORKTREE_WORKFLOW.md) — the everyday (hook-based) isolation boundary
99
+ - [Policies](POLICIES.md) — the PreToolUse gate the sandbox backstops
@@ -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.127.0",
3
+ "version": "1.128.1",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,6 +20,8 @@
20
20
  "dev": "tsc --watch",
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
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",
23
25
  "test:coverage": "vitest --coverage",
24
26
  "bench": "vitest --config vitest.bench.config.ts",
25
27
  "lint": "eslint src --ext .ts",
@@ -42,18 +42,73 @@ ARGS="$(printf '%s' "$PAYLOAD" | python3 -c 'import json,sys; d=json.load(sys.st
42
42
 
43
43
  [[ -z "$TOOL" ]] && exit 0
44
44
 
45
+ # FAIL-CLOSED for the enforcement control surface. The gate is fail-SOFT by
46
+ # design (a broken/absent enforcer must not wedge ALL work) — but that same
47
+ # fail-open makes anything that BREAKS the enforcer a silent bypass of the
48
+ # self-protect control. So: if THIS operation touches the enforcement surface
49
+ # (policy DB/enforcers, .uap.json, proxy env, hook scripts) or sets a
50
+ # bypass/relax flag, the gate fails CLOSED (exit 2) whenever the self-protect
51
+ # enforcer cannot actually run and make the call. Normal ops keep failing open.
52
+ SEC_SENSITIVE="$(printf '%s' "$ARGS" | TOOL="$TOOL" python3 -c '
53
+ import json, os, re, sys
54
+ try: a = json.loads(sys.stdin.read() or "{}")
55
+ except Exception: a = {}
56
+ markers = ("/.policy-tools/", "/src/policies/", "/policies/", "/.uap.json",
57
+ ".uap.json", "/.uap/", "anthropic-proxy.env", "uap-policy-gate.sh",
58
+ "uap-reactor-prompt.sh", "pre-tool-use")
59
+ target = a.get("file_path") or a.get("path") or a.get("target") or ""
60
+ cmd = a.get("command") or ""
61
+ low = ("/" + str(target)).lower()
62
+ hit = any(m in low for m in markers)
63
+ bypass = re.search(
64
+ r"UAP_DELIVER_BYPASS\s*=\s*[\x27\"]?1|UAP_ENFORCE_DELIVERY\s*=\s*[\x27\"]?(advisory|off|0|false|no)"
65
+ r"|UAP_SELF_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_NO_WORKTREE\s*=\s*[\x27\"]?1|UAP_WORKDIR_SCOPE_OFF\s*=\s*[\x27\"]?1",
66
+ cmd, re.I)
67
+ print("1" if (hit or bypass) else "0")
68
+ ' 2>/dev/null || echo 1)"
69
+
70
+ # Operator out-of-band override disables the fail-closed guard too.
71
+ [[ "${UAP_SELF_PROTECT_OFF:-}" == "1" ]] && SEC_SENSITIVE=0
72
+
73
+ fail_closed() {
74
+ echo "[UAP policy gate] FAIL-CLOSED: this operation touches the enforcement control surface but the self-protect enforcer could not run (${1:-machinery unavailable}). Blocked so a broken/absent gate can't become a bypass. (Operator override: UAP_SELF_PROTECT_OFF=1.)" >&2
75
+ exit 2
76
+ }
77
+
45
78
  DB="$MAIN_ROOT/agents/data/memory/policies.db"
46
- [[ ! -f "$DB" ]] && exit 0
79
+ if [[ ! -f "$DB" ]]; then
80
+ [[ "$SEC_SENSITIVE" == "1" ]] && fail_closed "policies.db not found"
81
+ exit 0
82
+ fi
83
+ if ! command -v sqlite3 >/dev/null 2>&1; then
84
+ [[ "$SEC_SENSITIVE" == "1" ]] && fail_closed "sqlite3 not on PATH"
85
+ exit 0
86
+ fi
87
+
88
+ # Did the self-protect enforcer actually run and make a decision this call?
89
+ sec_enforcer_ran=0
47
90
 
48
91
  # Iterate active policies with attached executable tools
49
92
  while IFS='|' read -r pid pname tool; do
50
93
  [[ -z "$pid" ]] && continue
51
94
  enforcer="$MAIN_ROOT/.policy-tools/${pid}_${tool}.py"
52
- [[ ! -f "$enforcer" ]] && continue
95
+ if [[ ! -f "$enforcer" ]]; then
96
+ # A missing self-protect enforcer on a sensitive op = fail closed.
97
+ [[ "$SEC_SENSITIVE" == "1" && "$tool" == "enforcement_self_protect" ]] && fail_closed "enforcer file missing"
98
+ continue
99
+ fi
53
100
  out="$(python3 "$enforcer" --operation "$TOOL" --args "$ARGS" 2>/dev/null || true)"
54
101
  allowed="$(printf '%s' "$out" | python3 -c 'import json,sys;
55
102
  try: d=json.loads(sys.stdin.read()); print("1" if d.get("allowed",True) else "0")
56
- except: print("1")' 2>/dev/null || echo 1)"
103
+ except: print("2")' 2>/dev/null || echo 2)"
104
+ # allowed=2 => enforcer errored / emitted unparseable output. For a sensitive
105
+ # op via the self-protect enforcer, that error must NOT default to allow.
106
+ if [[ "$tool" == "enforcement_self_protect" ]]; then
107
+ [[ "$SEC_SENSITIVE" == "1" && "$allowed" == "2" ]] && fail_closed "enforcer errored"
108
+ sec_enforcer_ran=1
109
+ fi
110
+ # For all other enforcers, an error still fails open (unchanged behavior).
111
+ [[ "$allowed" == "2" ]] && allowed=1
57
112
  if [[ "$allowed" == "0" ]]; then
58
113
  # R1: consume the enforcer's route:deliver signal (log intent, opt-in
59
114
  # background auto-route to `uap deliver`). Falls back to the plain reason if
@@ -73,4 +128,8 @@ except: print("")' 2>/dev/null || echo "")"
73
128
  fi
74
129
  done < <(sqlite3 "$DB" "SELECT p.id, p.name, t.toolName FROM policies p JOIN executable_tools t ON t.policyId=p.id WHERE p.isActive=1;")
75
130
 
131
+ # A sensitive op that no self-protect enforcer ever evaluated = the control
132
+ # surface is unguarded (self-protect not registered/active). Fail closed.
133
+ [[ "$SEC_SENSITIVE" == "1" && "$sec_enforcer_ran" == "0" ]] && fail_closed "self-protect not registered/active"
134
+
76
135
  exit 0