@sabaiway/agent-workflow-kit 14.14.1 → 14.15.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 14.15.1 — a fenced empty read-only file no rule calls a mask stops the probe, so a concurrent sandboxed command no longer turns its masks into real paths (AD-163)
8
+
9
+ **`/agent-workflow-kit sandbox-masks` now withholds its verdict on a possible leftover.** While one sandboxed
10
+ command runs, a second one sees that command's masks as empty read-only regular files; until 14.15.1 the probe called
11
+ each of those fenced masks a real path, and the Recommendations screen offered `--apply --clear`, which would
12
+ remove a working block.
13
+
14
+ - **The refusal.** A fenced entry that neither rule calls a mask, and whose lstat is an empty regular file with a
15
+ numeric mode and no write bit, stops the probe with exit 1; the Recommendations screen records a skip. The message
16
+ lists every fenced entry that became a real path first, then each such file with its three readings: a
17
+ running sandbox's mask (re-run once no other sandboxed command runs), litter a sandbox left (remove the file outside
18
+ the sandbox, keep its line), or a real file (delete its line). No `--clear` is offered and no plain `--apply` drops
19
+ its line.
20
+ - **What stays.** An unfenced file of that shape is not a mask, a fenced foreign mount target of that shape stays a
21
+ mask, a stat without a numeric mode proves nothing, and `--apply --clear` still removes the block without judging
22
+ it. The derivation and the check of fenced entries share one lstat answer per work-tree path within a probe.
23
+
24
+ ## 14.15.0 — a sandbox mask made by a bind mount counts as a mask, so the advisor no longer offers to clear a working exclude block (AD-162)
25
+
26
+ **`/agent-workflow-kit sandbox-masks` now reads the Linux mount table.** Until 14.15.0 a mask was only a device, a
27
+ FIFO or a socket. When the sandbox masked paths with bind mounts of empty files, the probe found no mask, called the
28
+ fenced entries real paths, and the Recommendations screen offered `--apply --clear`, which removed a working block.
29
+
30
+ - **One test for a mask.** An untracked path is a mask when it is a device, FIFO or socket, or when
31
+ `/proc/self/mountinfo` shows it as a mount target brought from elsewhere (a foreign mount, usually the sandbox's). The
32
+ derivation and the check of fenced entries use the same test, so a fenced mount mask is never called a real path.
33
+ A path bound onto itself is not a mask. The probe's `--json` gains `mountOnly` and `mountSignal`.
34
+ - **No guessing.** Off Linux the probe says the mount signal is not read and judges devices alone. On Linux a mount
35
+ table it cannot read, or one it cannot parse, stops the probe with exit 1, and the Recommendations screen records a
36
+ skip. `--apply --clear` never reads the table.
37
+ - **A mount-only mask matters to reviews.** Reviews still ignore device masks only. A mask that is a mount of an
38
+ ordinary file stays in `git add -A` and the review fingerprint while git does not ignore it, so the Recommendations
39
+ item is `attention` when a managed block exists but lacks such a mask. The mode doc, the command list, the README
40
+ row and the item's text now say this instead of calling every mask a device.
41
+ - **New file.** `tools/mount-masks.mjs`, a read-only module with no command of its own.
42
+
7
43
  ## 14.14.1 — the agents writer stops on a project folder given as a symlink, before it reads or writes anything (AD-161)
8
44
 
9
45
  **`cheap-agents.mjs --cwd <dir>` now refuses a `<dir>` that is itself a symlink.** Until 14.14.1 an `--apply` there
package/README.md CHANGED
@@ -239,7 +239,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
239
239
  | `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution` / `routine` / `feedback-triage` / `epic` / `task`) read **live** from the engine, plus the **resolved recipe per slot** from your `docs/ai/orchestration.json` (agent-writable via `set-recipe`, or hand-edited) + carrier readiness (`plan-authoring`: author, fold, review; default Reviewed when a backend is ready, Council on request; slot-aware incl. Delegated and Subagent) — and, for every dispatched backend, the **full driving contract at the point of use** (exact copy-pasteable invocation, grounding levers like agy's `--facts`/`--decided`, the round-2 `--continue` delta, guarded passthrough), verbatim from the bridge manifests (drift-guarded; each wrapper's `--help` prints the same) — except `feedback-triage`, whose record-bound block carries the check with `--excerpts`, the resolved member lines (ready bridge commands, the ready lens brief, or explicit skipped members), and either the record round-table command for a bridge obligation or the no-receipt fact otherwise: the record is the artifact. `--override <slot>=<value>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
240
240
  | `/agent-workflow-kit set-recipe` | any time | **config writer** for `docs/ai/orchestration.json`: tell the agent your preference in plain language and it maps it to explicit `--set <activity>.<slot>=<value>` / `--unset` ops; the kit validates, **previews by default**, and writes only on `--write` (deployment-gated, atomic, symlink/TOCTOU-safe), resolving the effective recipe vs live readiness. Writes **only** that file — **never runs a backend or a subagent, never commits**; hand-editing stays fully supported. |
241
241
  | `/agent-workflow-kit review-state` | any time | **read-only review-receipt checker** — makes "reviewed ≠ shipped" detectable: the bridge review wrappers append a receipt per successful review (into a file inside the git dir — never committable); this checks that every backend your configured `plan-execution.review` recipe names holds a **fresh, grounded receipt for the current uncommitted tree** (any later edit the payload can see moves the fingerprint and stales the receipt; a review continuation never re-attests a folded tree). `--check` gives a gate exit code to declare in `docs/ai/gates.json` **by hand or via the consent-gated seeder** (preview → your explicit yes; never without consent). `--await [--timeout <s>]` (AD-049) BLOCKS until every recipe-named backend has receipted the current tree — the durable completion signal is the receipt, never a process event — so you wait for the bridges instead of hand-polling. Never writes, never commits, never runs a subscription CLI; it spawns read-only `git` queries to fingerprint the tree — and `git commit --no-verify` stays possible (discipline, not a sandbox). |
242
- | `/agent-workflow-kit sandbox-masks` | any time | **cosmetic exclude lane for sandbox device masks** — an OS sandbox (Claude Code) injects character-device masks into the work tree as untracked `git status` noise; the review domain already ignores them **by construction** (never-committable untracked classes — char/block devices, FIFOs, sockets — are excluded from the fingerprint, the assembled review payload, and the clean checks). This mode hides them from `git status` too: flagless = read-only probe (derives the CURRENT mask set from the unfiltered walk + lstat — never a frozen list — and revalidates fenced entries, loudly flagging one that became a real path); `--apply` = consent-gated FULL-BLOCK replace of its own fenced block in `git rev-parse --git-path info/exclude` (stale masks drop by construction; `--clear` always means REMOVE the block — it takes precedence over the derivation). Writes ONLY its fence — never `.gitignore`, never global config; symlinked/non-regular exclude paths and malformed fences fail closed. Watch note: a real file at an excluded path is silently skipped by bulk staging (`git add -A`/`git add .`) — delete the stale line first; the probe flags exactly this case. |
242
+ | `/agent-workflow-kit sandbox-masks` | any time | **exclude lane for sandbox masks** — an OS sandbox (Claude Code) masks work-tree paths with a device or, on Linux, a foreign bind mount, as untracked `git status` noise; the review domain already ignores a **device mask** **by construction** (never-committable untracked classes — char/block devices, FIFOs, sockets — are excluded from the fingerprint, the assembled review payload, and the clean checks), while a **mount-only mask** rides the fingerprint and `git add -A` while git does not ignore it (load-bearing there). This mode hides them from `git status` too: flagless = read-only probe (derives the CURRENT mask set from the unfiltered walk + lstat + `/proc/self/mountinfo` — never a frozen list; off Linux the mount signal is not read and it says so, an unreadable or malformed table refuses — and revalidates fenced entries, loudly flagging one that became a real path); `--apply` = consent-gated FULL-BLOCK replace of its own fenced block in `git rev-parse --git-path info/exclude` (stale masks drop by construction; `--clear` always means REMOVE the block — it takes precedence over the derivation). Writes ONLY its fence — never `.gitignore`, never global config; symlinked/non-regular exclude paths and malformed fences fail closed. Watch note: a real file at an excluded path is silently skipped by bulk staging (`git add -A`/`git add .`) — delete the stale line first; the probe flags exactly this case. |
243
243
  | `/agent-workflow-kit mcp` | Claude Code · opt-in | **typed-channel registration** — the kit ships a read-only stdio **MCP server** exposing its two promptless readers as TYPED tools (`path_inventory`: exists / type / size / line count / listing / a small file's text, many paths in ONE call; `repo_search`: literal search, the pattern a JSON string). Their arguments are named JSON fields rather than a string handed to a shell, so a pipe, a redirect or a quote inside a pattern or a path stays DATA and is never interpreted — legal bytes to search for, with no shell to read them as operators. Shipping the server does nothing on its own — a client sees it only once the project declares it, and this mode is that declaration: the `agent-workflow` entry in **`.mcp.json`** (command `node`, args = the absolute path of the RUNNING kit's server) plus `enabledMcpjsonServers` and the two derived tool allow rules in **`.claude/settings.json`**. Preview by default and the **exact entry is printed before consent** (registering a server means your client will run that command); `--apply` writes `.mcp.json` FIRST, then settings, merge-don't-clobber with each file's EOL kept, and a re-apply adds nothing twice. An existing `agent-workflow` entry that **structurally differs** from what this kit copy would write is refused unwritten (the comparison ignores key order, so re-serialized identical bytes are the same registration) — silently changing what an MCP server launches is what consent must not slide past. Where an OS sandbox masks `.mcp.json` with a device node, it writes nothing, hands you both paste-ready texts and exits 0. The server is a read-only child of your client (no write, no exec API) running outside the Bash sandbox as the client does. Never writes `settings.local.json`; never commits. |
244
244
  | `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's three canon sections (`## Goal and boundary` + `## Module ledger` + `## Verification`, each required; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--autonomy` (AD-044) appends the COMPUTED effective autonomy policy from the git-top `docs/ai/autonomy.json` (every red-line + per-activity level, stated source line; absent file → the computed defaults ARE the policy, exit 0; a malformed policy fails CLOSED, exit 1). `--extra <text|@file>` (repeatable) appends your own facts **byte-verbatim** after the mechanical sections, so the merge is a tool input rather than a shell append — an `@file` must sit inside the proven git work tree (never the git dir) or the system temp surface, and is read through a no-follow descriptor (a FIFO cannot block it, a symlink leaf cannot substitute its target). Prints to stdout; `--out` writes **one scratch file only** — system-temp outside the repo ($TMPDIR / /tmp, rewritable) or a **fresh** gitignored in-repo path (create-only, exclusive write; an existing in-repo file, even gitignored, is refused — the `.env` clobber class); tracked, not-ignored-in-repo, other outside-repo, and symlink/non-regular destinations are all refused. Never commits, never runs a subscription CLI. |
245
245
  | `/agent-workflow-kit core-evidence` | any time | **the ONE loop-evidence writer** (strip-the-kit) — every core evidence record lands in a single append-only JSONL store inside the git dir (never committable; versioned schema, latest-per-key supersession, byte-identical duplicates refused, malformed lines fail every reader closed). `red-proof "<file>#<pattern>"` declares an observed-red **BEFORE a bugfix** (N/N red runs + content custody + base + the pre-fix fingerprint; green/mixed/timeout are DISTINGUISHED refusals — nothing written); `degrade --backend --reason` is the ONLY escape for an unavailable review backend (per-tree, never all backends); `summary` renders the whole loop state statelessly (gate result · per-backend verdicts · red-proofs · degrades) — no ledger, no rounds, nothing remembered. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow". New to the epic, story and task tier? Run `/agent-workflow-kit tier`.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.14.1'
6
+ version: '14.15.1'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.14.1",
6
+ "version": "14.15.1",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.14.1",
3
+ "version": "14.15.1",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -2,15 +2,15 @@
2
2
 
3
3
  <!-- opt-in-capability: sandbox-masks -->
4
4
 
5
- **Cosmetic exclude lane for sandbox-injected device masks** (AD-044). An OS sandbox (Claude Code) injects character-device masks into the work tree (`.bashrc`, `.gitconfig`, `.vscode`, …); git lists them as untracked noise. The **review domain already ignores them by construction** — never-committable untracked classes (character/block devices, FIFOs, sockets) are excluded from the fingerprint, the assembled review payload, and the clean checks in both bridge wrappers and the kit checker. This mode is the optional **cosmetic** half: it hides the masks from `git status` (and every other `--exclude-standard` untracked walk) via **one managed fenced block** in the file `git rev-parse --git-path info/exclude` names.
5
+ **Exclude lane for sandbox masks** (AD-044). An OS sandbox (Claude Code) masks paths in the work tree (`.bashrc`, `.gitconfig`, `.vscode`, …) with a device or with a foreign bind mount; git lists them as untracked noise. A **device mask** is a never-committable untracked class (character/block devices, FIFOs, sockets) the **review domain already ignores by construction** — excluded from the fingerprint, the assembled review payload, and the clean checks in both bridge wrappers and the kit checker — so for it this mode is **cosmetic**. A **mount-only mask** (a foreign mount target whose file looks ordinary, e.g. an empty file) is not in that class: while git does not ignore it, it rides the review fingerprint and `git add -A` (the block adds a local exclude pattern for it; a `.gitignore` negation such as `!/.bashrc` outranks the block), so for it this mode is **load-bearing**. The mode hides the masks from `git status` (and every other `--exclude-standard` untracked walk) via **one managed fenced block** in the file `git rev-parse --git-path info/exclude` names.
6
6
 
7
7
  Run `node ${CLAUDE_SKILL_DIR}/tools/sandbox-masks.mjs [--cwd <project-root>]`:
8
8
 
9
- 1. **Flagless → READ-ONLY probe/preview.** Derives the CURRENT mask set from the **unfiltered** untracked walk (`git ls-files --others -z`, without standard excludes — an already-hidden mask stays visible to a rerun) plus lstat classification. Only the never-committable classes are ever candidates; tracked / regular / directory / symlink / gitlink / missing paths can never enter the block **by construction**. The probe also **revalidates** the existing fenced entries and loudly flags any that became a REAL path. It prints the exact apply one-liner.
9
+ 1. **Flagless → READ-ONLY probe/preview.** Derives the CURRENT mask set from the **unfiltered** untracked walk (`git ls-files --others -z`, without standard excludes — an already-hidden mask stays visible to a rerun) plus one mask test: a path is a mask when its lstat is a never-committable class or, on Linux, it is a foreign mount target read from `/proc/self/mountinfo` (off Linux the mount signal is not read and the probe says so; an unreadable or malformed table refuses, exit 1). Tracked paths and every other regular / directory / symlink / gitlink / missing path can never enter the block **by construction**. The probe also **revalidates** the existing fenced entries and loudly flags any that became a REAL path. It prints the exact apply one-liner.
10
10
  2. **`--apply` → consent-gated FULL-BLOCK REPLACE** from a fresh derivation — stale masks drop by construction, never append-only. Run it **inside the sandbox** (masks are visible only there). An apply whose derivation is EMPTY while a non-empty block exists refuses loudly (you are probably outside the sandbox) unless `--clear` is also given; an empty derivation with no block is a stated no-op. `--clear` always means **remove the managed block** — it takes precedence over the derivation, even while masks are visible.
11
11
  3. **Writes ONLY its own fenced block** — never `.gitignore`, never a global excludesFile, never outside the fence. A malformed fence (start without end, duplicated markers) **fails closed**: loud report, file untouched. The fence is separate from the hidden-mode reconcile's block.
12
12
 
13
- **Where the need surfaces on its own (no standing detector):** `review-state --check` prints one non-failing notice with this mode's apply one-liner whenever it observes filtered-class untracked paths, and the upgrade **Recommendations** section carries the same item (the probe is exported to the advisor — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`) — a NEW mask appearing after a sandbox update surfaces its own fix at the next check or upgrade.
13
+ **Where the need surfaces on its own (no standing detector):** `review-state --check` prints one non-failing notice with this mode's apply one-liner whenever it observes device-class untracked paths (it never counts a mount-only mask), and the upgrade **Recommendations** section carries the item for every mask — attention-class when a present managed block lacks a mount-only mask (the probe is exported to the advisor — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`) — so a NEW mask appearing after a sandbox update surfaces its own fix at the next upgrade, a device mask at the next check too.
14
14
 
15
15
  **Watch note (D5):** an exclude rule hides a path from `git status` **AND from bulk staging** — a real file at an excluded path is silently skipped by `git add -A`/`git add .` (an explicit `git add <path>` refuses without `-f`), so it would stay out of your commits; `.git/info/exclude` never warns. Before creating a REAL file at a masked path (e.g. a real `.vscode/`), delete the matching line from the managed block first — the probe flags exactly this case.
16
16
 
@@ -180,7 +180,7 @@ const CATALOG = [
180
180
  invocation: invocationOf('sandbox-masks'),
181
181
  group: 'Configure',
182
182
  kind: GUARDED,
183
- oneLine: 'Hide the sandbox’s injected device masks from git status via one managed block in the repo’s local exclude file — probe-derived (never a frozen list), preview first, full-block replace on your yes; reviews already ignore the masks by construction.',
183
+ oneLine: 'Hide the sandbox masks (a device, or on Linux a foreign bind mount) from git status via one managed block in the repo’s local exclude file — probe-derived (never a frozen list), preview first, full-block replace on your yes; reviews already ignore device masks, while a mount-only mask stays in git add -A and the review fingerprint as long as git does not ignore it.',
184
184
  },
185
185
  {
186
186
  key: 'mcp',
@@ -0,0 +1,96 @@
1
+ // mount-masks.mjs — the mount-table judge of the sandbox-masks lane (spec kit/review-domain/sandbox-masks).
2
+ // A pure read-only leaf: it parses /proc/self/mountinfo TEXT (the caller reads it) and answers which
3
+ // work-tree paths are FOREIGN mount targets — the second arm of the mask predicate beside the lstat
4
+ // class. Library-only: no import effect, no direct-run dispatch.
5
+
6
+ import { realpathSync } from 'node:fs';
7
+
8
+ const ESCAPES = new Map([['\\040', ' '], ['\\011', '\t'], ['\\012', '\n'], ['\\134', '\\']]);
9
+
10
+ const malformed = (why) => new Error(`malformed mountinfo: ${why}`);
11
+
12
+ // Root and mount point decode the kernel's four octal escapes; any other backslash sequence is malformed.
13
+ const decode = (field, lineNo) => field.replace(/\\.{0,3}/gs, (seq) => {
14
+ if (ESCAPES.has(seq)) return ESCAPES.get(seq);
15
+ throw malformed(`line ${lineNo} carries the backslash sequence ${JSON.stringify(seq)}`);
16
+ });
17
+
18
+ // One entry per line: split on SINGLE spaces (an empty field is a field); nothing after the source is read.
19
+ const parseLine = (line, lineNo) => {
20
+ const fields = line.split(' ');
21
+ const dash = fields.indexOf('-');
22
+ if (dash === -1) throw malformed(`line ${lineNo} has no - field`);
23
+ if (dash < 6) throw malformed(`line ${lineNo} has fewer than six fields before the - field`);
24
+ if (fields.length - dash - 1 < 2) throw malformed(`line ${lineNo} has fewer than two fields after the - field`);
25
+ const [id, parent, dev, root, point] = fields;
26
+ return { id, parent, dev, root: decode(root, lineNo), point: decode(point, lineNo) };
27
+ };
28
+
29
+ const parseTable = (text) => {
30
+ const lines = text.split('\n');
31
+ if (lines[lines.length - 1] === '') lines.pop(); // the empty tail after the last newline is not a line
32
+ return lines.map((line, i) => parseLine(line, i + 1));
33
+ };
34
+
35
+ const parentPath = (path) => path.slice(0, path.lastIndexOf('/')) || '/';
36
+ const below = (path, point) => (point === '/' ? path : path.slice(point.length));
37
+ const joinRoot = (root, rest) => (rest === '' ? root : root === '/' ? rest : `${root}${rest}`);
38
+
39
+ // The judge follows PARENT IDS, never list order, and resolves visibility from the root mount down.
40
+ const buildJudge = (entries) => {
41
+ const byId = new Map(entries.map((e) => [e.id, e]));
42
+ const atPoint = new Map();
43
+ for (const e of entries) atPoint.set(e.point, [...(atPoint.get(e.point) ?? []), e]);
44
+ const roots = (atPoint.get('/') ?? []).filter((e) => e.parent === e.id || !byId.has(e.parent));
45
+ if (roots.length !== 1) throw malformed(`${roots.length} root mounts at / (exactly one entry at / has an unlisted or its own parent)`);
46
+ const rootMount = roots[0];
47
+ // The BASE of an entry at P: follow parent ids down through the entries at P.
48
+ const baseOf = (entry, point) => {
49
+ const seen = new Set();
50
+ let cur = entry;
51
+ while (cur.point === point && cur !== rootMount) {
52
+ if (seen.has(cur.id)) throw malformed(`a parent cycle at ${point}`);
53
+ seen.add(cur.id);
54
+ const next = byId.get(cur.parent);
55
+ if (next == null) throw malformed(`parent id ${cur.parent} of mount ${cur.id} is not listed`);
56
+ cur = next;
57
+ }
58
+ return cur;
59
+ };
60
+ const memo = new Map();
61
+ // → { visible, served }: the visible entry serving the path (itself or an ancestor's) and, when
62
+ // the path has its own visible entry, the H it sits on.
63
+ const serve = (point) => {
64
+ if (memo.has(point)) return memo.get(point);
65
+ let ancestor = point === '/' ? null : parentPath(point);
66
+ while (ancestor != null && !atPoint.has(ancestor)) ancestor = parentPath(ancestor);
67
+ const H = ancestor == null ? rootMount : serve(ancestor).visible;
68
+ const here = atPoint.get(point);
69
+ const named = new Set(here.filter((e) => e.parent !== e.id).map((e) => e.parent));
70
+ const tops = here.filter((e) => !named.has(e.id) && baseOf(e, point) === H);
71
+ if (tops.length > 1) throw malformed(`${tops.length} tops over one base at ${point}`);
72
+ const answer = tops.length === 1 ? { visible: tops[0], H, own: true } : { visible: H, H, own: false };
73
+ memo.set(point, answer);
74
+ return answer;
75
+ };
76
+ return { atPoint, serve };
77
+ };
78
+
79
+ // A SELF bind shares H's device and names H's root joined with P's path below H's mount point.
80
+ const isSelfBind = (M, H, point) => M.dev === H.dev && joinRoot(H.root, below(point, H.point)) === M.root;
81
+
82
+ // foreignMountTargets(root, text) → Set of work-tree-relative paths ('/'-separated) whose visible
83
+ // mount is FOREIGN. Only an EXACT mount target strictly below the realpath of `root` counts; a path
84
+ // whose entries are all hidden layers is left to lstat. A malformed table throws.
85
+ export const foreignMountTargets = (root, text) => {
86
+ const top = realpathSync(root);
87
+ const { atPoint, serve } = buildJudge(parseTable(text));
88
+ serve('/');
89
+ const foreign = new Set();
90
+ for (const point of atPoint.keys()) {
91
+ if (!point.startsWith(`${top}/`)) continue;
92
+ const { visible, H, own } = serve(point);
93
+ if (own && !isSelfBind(visible, H, point)) foreign.add(point.slice(top.length + 1));
94
+ }
95
+ return foreign;
96
+ };
@@ -53,7 +53,7 @@ import { detectBackends, findOnPath } from './detect-backends.mjs';
53
53
  import { isDirectRun } from './direct-run.mjs';
54
54
  import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
55
55
  import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
56
- import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
56
+ import { probeSandboxMasks, needsMasksApply, patternToRel } from './sandbox-masks.mjs';
57
57
  import { shellQuoteArg } from './review-state.mjs';
58
58
  import { isFinalCapableDeclaration } from './run-gates.mjs';
59
59
  import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKitOwnedCheckerGate, GATES_REL, LCOV_PRODUCER_KEY } from './gates-declaration.mjs';
@@ -193,6 +193,7 @@ export const SEVERITIES = Object.freeze({
193
193
  'family-freshness': SEVERITY_ATTENTION,
194
194
  'adr-store-migration': SEVERITY_ATTENTION,
195
195
  'sandbox-masks': SEVERITY_OPTIONAL,
196
+ 'sandbox-masks.unfenced-mount': SEVERITY_ATTENTION,
196
197
  'sandbox-lane': SEVERITY_OPTIONAL,
197
198
  'worktrees-dir': SEVERITY_OPTIONAL,
198
199
  // The layer is opt-in, so both arms are OFFERS under the frozen registry (attention is a CONFIGURED
@@ -271,8 +272,8 @@ export const WHATS = Object.freeze({
271
272
  'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
272
273
  'family-freshness': '{parts}',
273
274
  'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
274
- 'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
275
- 'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
275
+ 'sandbox-masks': '{n} sandbox mask(s) clutter git status — the managed exclude block is absent or stale',
276
+ 'sandbox-masks.stale-real': '{n} sandbox mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
276
277
  'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
277
278
  'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
278
279
  'spec-adoption': 'feature-spec store absent (docs/ai/specs) — no feature contract can govern a plan here yet; seed the store, or record the decline',
@@ -333,7 +334,7 @@ export const BENEFITS = Object.freeze({
333
334
  'executor-vehicle': 'carrier readiness — a slot you configured subagent dispatches the subagent it names instead of silently running solo',
334
335
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
335
336
  'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
336
- 'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
337
+ 'sandbox-masks': 'zero clutter — a mask git ignores through the block leaves git status; a mount-only one leaves git add -A and the review fingerprint too',
337
338
  'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
338
339
  'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
339
340
  'spec-adoption': 'contracts — a plan names the contract it builds to, and a change to a governed slice is visible at review instead of after it',
@@ -1124,7 +1125,8 @@ const probeMasksItem = ({ root, deps, add, skip }) => {
1124
1125
  // A stale-real-only fence (EMPTY derivation over a non-empty block) makes the plain --apply
1125
1126
  // REFUSE — the exact one-liner must carry --clear there (Segment B).
1126
1127
  const apply = p.masks.length === 0 && p.staleReal.length > 0 ? `${p.applyCmd} --clear` : p.applyCmd;
1127
- add('sandbox-masks', fillTemplate(WHATS[variant], { n: p.masks.length, m: p.staleReal.length }), apply);
1128
+ const unfenced = p.fence.state === 'ok' && p.mountOnly.some((rel) => !p.fence.body.map(patternToRel).includes(rel));
1129
+ add('sandbox-masks', fillTemplate(WHATS[variant], { n: p.masks.length, m: p.staleReal.length }), apply, unfenced ? 'sandbox-masks.unfenced-mount' : 'sandbox-masks');
1128
1130
  } catch (err) {
1129
1131
  skip('sandbox-masks', err);
1130
1132
  }
@@ -1,20 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // sandbox-masks.mjs — the GUARDED cosmetic exclude lane behind `/agent-workflow-kit sandbox-masks`
3
- // (AD-044 Plan 4, Phase 1.5; design consult codex+agy CONVERGED on B+D — probe-DERIVED, never a
4
- // frozen list). An OS sandbox (Claude Code) injects character-device masks into the work tree;
5
- // the review domain already ignores them BY CONSTRUCTION (review-state.mjs + both bridge wrappers,
6
- // Decision 1), so this lane is COSMETIC ONLY: it hides the masks from `git status` (and from every
7
- // other --exclude-standard untracked walk) via ONE managed fenced block in the repo's
8
- // `git rev-parse --git-path info/exclude` file.
2
+ // sandbox-masks.mjs — the GUARDED exclude lane behind `/agent-workflow-kit sandbox-masks` (AD-044
3
+ // Plan 4; probe-DERIVED, never a frozen list; contract docs: specs/kit/review-domain/sandbox-masks).
4
+ // An OS sandbox masks work-tree paths: a device node (the review domain ignores it by construction)
5
+ // or a FOREIGN bind mount of an empty file (the review domain sees it as an untracked file until the
6
+ // block below hides it — load-bearing there). The lane hides the masks from `git status` and every
7
+ // --exclude-standard walk via ONE managed fenced block in `git rev-parse --git-path info/exclude`.
9
8
  //
10
9
  // Contract:
11
10
  // flagless READ-ONLY probe/preview. Derives the CURRENT mask set from the UNFILTERED
12
11
  // untracked walk (`git ls-files --others -z`, WITHOUT standard excludes — an
13
- // already-excluded mask must stay visible to a rerun) + lstat classification.
14
- // ONLY the never-committable classes (char/block devices, FIFOs, sockets — the
15
- // SAME class set as the review-domain filter) are ever candidates; tracked /
16
- // regular / directory / symlink / gitlink / missing paths can never enter the
17
- // block by construction. The probe also REVALIDATES the existing fenced entries
12
+ // already-excluded mask must stay visible to a rerun). A mask is a never-committable
13
+ // lstat class (char/block device, FIFO, socket) OR a foreign mount target judged
14
+ // from /proc/self/mountinfo (mount-masks.mjs; off Linux the arm is absent and said
15
+ // so, an unreadable or malformed table refuses). The probe also REVALIDATES the existing fenced entries
18
16
  // and loudly flags any that became a REAL path (delete that line first — an
19
17
  // excluded real file is silently skipped by bulk staging, `git add -A`/`git add .`,
20
18
  // and `.git/info/exclude` never warns). No standing detector: the flags ride probe runs.
@@ -43,6 +41,7 @@ import { fail } from './orchestration-config.mjs';
43
41
  import { isNeverCommittableStat, shellQuoteArg } from './review-state.mjs';
44
42
  import { assertContainedRealPath } from './fs-safe.mjs';
45
43
  import { GIT_MAX_BUFFER } from './git-env.mjs';
44
+ import { foreignMountTargets } from './mount-masks.mjs';
46
45
 
47
46
  export const MASKS_FENCE_START = '# >>> agent-workflow sandbox-masks — managed block, fully REPLACED by the kit sandbox-masks lane; do not hand-edit inside >>>';
48
47
  export const MASKS_FENCE_END = '# <<< agent-workflow sandbox-masks <<<';
@@ -65,7 +64,30 @@ const listUntrackedUnfilteredZ = (root) => {
65
64
  return r.stdout.split('\0').filter(Boolean);
66
65
  };
67
66
 
68
- // ── derivation (the D5 guard IS the classifier: only never-committable classes survive) ────────
67
+ // ── derivation (the D5 guard IS the classifier) ────────
68
+
69
+ // The mount signal: text, null (absent by platform — the default answers it off Linux) or a throw.
70
+ const readMountinfoDefault = (platform = process.platform) => (platform === 'linux' ? readFileSync('/proc/self/mountinfo', 'utf8') : null);
71
+
72
+ // → { foreign: Set of work-tree-relative foreign mount targets, signal: 'read' | 'absent' }; an
73
+ // unreadable or malformed table refuses (never a device-only guess on Linux).
74
+ const mountTargets = (root, readMountinfo) => {
75
+ let text;
76
+ try {
77
+ text = readMountinfo();
78
+ } catch (err) {
79
+ throw fail(1, `cannot read /proc/self/mountinfo (${err?.code ?? err?.message ?? err}) — the mount signal is required on Linux`);
80
+ }
81
+ if (text == null) return { foreign: new Set(), signal: 'absent' };
82
+ try {
83
+ return { foreign: foreignMountTargets(root, text), signal: 'read' };
84
+ } catch (err) {
85
+ throw fail(1, `${err.message} — refusing a device-only answer (/proc/self/mountinfo)`);
86
+ }
87
+ };
88
+
89
+ // A LEFTOVER: an empty regular file with a numeric mode and no write bit — it only withholds a verdict.
90
+ const isLeftoverStat = (stat) => stat?.isFile?.() === true && stat.size === 0 && typeof stat.mode === 'number' && (stat.mode & 0o222) === 0;
69
91
 
70
92
  // A root-anchored gitignore pattern for one relative path: leading `/` (root-anchored — a mask
71
93
  // name can never shadow a deeper real path), glob metacharacters + leading-! / leading-# handled
@@ -77,17 +99,18 @@ export const toExcludePattern = (rel) => {
77
99
  return `/${escaped}`;
78
100
  };
79
101
 
80
- // deriveMasks({ root, lstat, listUntracked }) → { masks, unrenderable }: sorted rel paths whose
81
- // lstat class is never-committable (char/block/FIFO/socket). Everything else is refused by
82
- // construction — tracked paths never appear in an --others walk; regular/dir/symlink/missing fail
83
- // the predicate. A CR/LF-carrying mask NAME cannot be expressed as ONE gitignore rule — rendering
84
- // it would split into several rules and could hide unrelated committable paths — so it
85
- // is a LOUD unrenderable skip, never written (the review domain already ignores the mask itself).
86
- export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ } = {}) => {
102
+ // deriveMasks({ root, lstat, listUntracked, readMountinfo }) → { masks, unrenderable, mountOnly,
103
+ // mountSignal }: sorted rel paths that are a mask (a never-committable lstat class OR a foreign
104
+ // mount target). Tracked paths never appear in an --others walk; everything else fails the
105
+ // predicate. A CR/LF-carrying mask NAME cannot be ONE gitignore rule — rendering it could hide
106
+ // unrelated committable paths — so it is a LOUD unrenderable skip, never written.
107
+ export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readMountinfo = readMountinfoDefault } = {}) => {
87
108
  const entries = listUntracked(root);
88
109
  if (entries == null) return null;
110
+ const { foreign, signal } = mountTargets(root, readMountinfo);
89
111
  const masks = [];
90
112
  const unrenderable = [];
113
+ const mountOnly = [];
91
114
  for (const rel of entries) {
92
115
  let stat = null;
93
116
  try {
@@ -95,13 +118,15 @@ export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntra
95
118
  } catch {
96
119
  stat = null;
97
120
  }
98
- if (!isNeverCommittableStat(stat)) continue;
121
+ const device = isNeverCommittableStat(stat);
122
+ if (!device && !foreign.has(rel)) continue;
123
+ if (!device) mountOnly.push(rel);
99
124
  // CR/LF anywhere, or a trailing TAB (inexpressible in gitignore — only trailing SPACES escape
100
125
  // as `\ `), make the name unrenderable as ONE exclude rule.
101
126
  if (/[\r\n]/.test(rel) || /\t$/.test(rel)) unrenderable.push(rel);
102
127
  else masks.push(rel);
103
128
  }
104
- return { masks: masks.sort(), unrenderable: unrenderable.sort() };
129
+ return { masks: masks.sort(), unrenderable: unrenderable.sort(), mountOnly: mountOnly.sort(), mountSignal: signal };
105
130
  };
106
131
 
107
132
  // ── the fence (own block; malformed → fail closed) ─────────────────────────────────
@@ -119,7 +144,7 @@ export const findMasksFence = (lines) => {
119
144
  // escaped forms; unescape is its exact inverse — the escaped-space form `\ ` included, so a
120
145
  // trailing-space mask round-trips). Non-pattern lines (blank/comment) are skipped;
121
146
  // only UNESCAPED trailing whitespace is insignificant (gitignore semantics).
122
- const patternToRel = (line) => {
147
+ export const patternToRel = (line) => {
123
148
  // A trailing CR (a CRLF-saved exclude file) strips FIRST — it would otherwise ride into the rel
124
149
  // name and false-ENOENT the revalidation lstat; then only UNESCAPED trailing
125
150
  // whitespace is insignificant (gitignore semantics — an escaped `\ ` survives).
@@ -128,10 +153,8 @@ const patternToRel = (line) => {
128
153
  return t.slice(1).replace(/\\([\\*?[\] ])/g, '$1');
129
154
  };
130
155
 
131
- // Revalidate the CURRENT fence body against the disk: an entry whose path now exists as anything
132
- // OTHER than a never-committable class is a STALE-REAL flag — the D5 watch (a real file at an
133
- // excluded path is silently skipped by bulk staging; delete the line before trusting git with it).
134
- export const revalidateFence = (bodyLines, { root, lstat = lstatSync } = {}) => {
156
+ // A fenced entry that exists and fails the derivation's predicate is STALE-REAL (the D5 watch).
157
+ export const revalidateFence = (bodyLines, { root, lstat = lstatSync, readMountinfo = readMountinfoDefault, foreign = mountTargets(root, readMountinfo).foreign } = {}) => {
135
158
  const staleReal = [];
136
159
  for (const line of bodyLines) {
137
160
  const rel = patternToRel(line);
@@ -142,7 +165,7 @@ export const revalidateFence = (bodyLines, { root, lstat = lstatSync } = {}) =>
142
165
  } catch {
143
166
  continue; // vanished mask — the next --apply drops it by construction, nothing real is hidden
144
167
  }
145
- if (!isNeverCommittableStat(stat)) staleReal.push(rel);
168
+ if (!isNeverCommittableStat(stat) && !foreign.has(rel)) staleReal.push(rel);
146
169
  }
147
170
  return staleReal;
148
171
  };
@@ -150,8 +173,8 @@ export const revalidateFence = (bodyLines, { root, lstat = lstatSync } = {}) =>
150
173
  // ── the probe (read-only; exported — the Recommendations advisor consumes it) ──────
151
174
 
152
175
  // probeSandboxMasks({ cwd, ... }) → everything the render/apply/advisor needs, or null when not a
153
- // git work tree. Read-only always.
154
- export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readFile = readFileSync } = {}) => {
176
+ // git work tree; a fenced LEFTOVER refuses unless clearOnly. Read-only always.
177
+ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readFile = readFileSync, readMountinfo = readMountinfoDefault, clearOnly = false } = {}) => {
155
178
  const root = gitLine(['rev-parse', '--show-toplevel'], cwd);
156
179
  if (root == null) return null;
157
180
  const gitPathRaw = gitLine(['rev-parse', '--git-path', 'info/exclude'], cwd);
@@ -161,21 +184,23 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
161
184
  // The write-containment root: info/exclude lives in the COMMON git dir (worktree-safe),
162
185
  // and the apply must never write through a symlinked component or a non-regular leaf.
163
186
  const gitCommonDir = resolve(cwd, commonDirRaw);
164
- const derived = deriveMasks({ root, lstat, listUntracked });
187
+ let text; // read ONCE: derivation and revalidation judge one table, and one lstat answer per path
188
+ const once = () => (text === undefined ? (text = readMountinfo()) : text);
189
+ const answers = new Map();
190
+ const lstatOnce = (p) => {
191
+ if (!answers.has(p)) answers.set(p, (() => { try { return { stat: lstat(p) }; } catch (error) { return { error }; } })());
192
+ if (answers.get(p).error) throw answers.get(p).error;
193
+ return answers.get(p).stat;
194
+ };
195
+ const derived = deriveMasks({ root, lstat: lstatOnce, listUntracked, readMountinfo: once });
165
196
  if (derived == null) return null;
166
- const { masks, unrenderable } = derived;
167
- // The WHOLE chain is guarded BEFORE any read: a symlinked exclude leaf OR a
168
- // symlinked parent (.git/info) must never be read through, and a FIFO/socket leaf would HANG
169
- // the read-only probe — parent-chain containment + leaf lstat first, fail closed on everything
170
- // but a plain absent file, then read the existing REGULAR content. The apply re-checks
171
- // (defense in depth) before writing.
197
+ const { masks, unrenderable, mountOnly, mountSignal } = derived;
198
+ // The whole chain is guarded BEFORE any read (a symlinked component; a FIFO/socket leaf would hang).
172
199
  try {
173
200
  assertContainedRealPath(gitCommonDir, excludeFile, { lstat });
174
201
  } catch (err) {
175
202
  throw fail(1, `refusing to touch ${excludeFile}: ${err.message}`);
176
203
  }
177
- // The chain guard above already refused symlinked components (the leaf included) and PROPAGATED
178
- // any non-ENOENT lstat failure — after it, the leaf can only be absent or exist non-symlinked.
179
204
  const leafStat = (() => {
180
205
  try {
181
206
  return lstat(excludeFile);
@@ -198,16 +223,15 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
198
223
  }
199
224
  const lines = content.split('\n');
200
225
  const fence = findMasksFence(lines);
201
- const staleReal = fence.state === 'ok' ? revalidateFence(fence.body, { root, lstat }) : [];
226
+ const staleReal = fence.state === 'ok' ? revalidateFence(fence.body, { root, lstat: lstatOnce, readMountinfo: once }) : [];
227
+ const leftovers = clearOnly ? [] : staleReal.filter((rel) => isLeftoverStat(lstatOnce(join(root, rel))));
228
+ if (leftovers.length > 0) throw fail(1, [...staleReal.filter((rel) => !leftovers.includes(rel)).map(staleRealLine), ...leftovers.map(leftoverLine)].join('\n'));
202
229
  const applyCmd = `node ${shellQuoteArg(join(HERE, 'sandbox-masks.mjs'))} --cwd ${shellQuoteArg(root)} --apply`;
203
- return { root, excludeFile, gitCommonDir, content, lines, fence, masks, unrenderable, staleReal, applyCmd };
230
+ return { root, excludeFile, gitCommonDir, content, lines, fence, masks, unrenderable, mountOnly, mountSignal, staleReal, applyCmd };
204
231
  };
205
232
 
206
- // needsMasksApply(probe) → whether the CURRENT derivation diverges from the fenced block — the
207
- // Recommendations advisor's fire condition (Phase 3, AD-044 Plan 4). True when any fenced entry
208
- // became a real path (stale-real), or when visible masks exist and the derived set ≠ the fenced
209
- // set. A malformed fence is NOT an apply item (the apply would fail closed — it needs a hand fix,
210
- // which the probe render itself flags loudly). Pure over the probe result.
233
+ // needsMasksApply(probe) → the advisor's fire condition: a stale-real entry, or visible masks whose set
234
+ // differs from the fenced one; a malformed fence is never an item (the render flags it). Pure.
211
235
  export const needsMasksApply = (probe) => {
212
236
  if (probe == null) return false;
213
237
  if (probe.staleReal.length > 0) return true;
@@ -238,10 +262,8 @@ export const planApply = (probe, { clear = false } = {}) => {
238
262
  }
239
263
  const masks = clear ? [] : probe.masks;
240
264
  const block = masks.length === 0 ? [] : [MASKS_FENCE_START, ...masks.map(toExcludePattern), MASKS_FENCE_END];
241
- // EVERY hand byte outside the managed fence is preserved EXACTLY: the first-apply
242
- // append adds at most ONE separator newline, and the existing-block replace splices via RAW
243
- // string offsets around the fence lines — a split/join('\n') rebuild would silently normalize
244
- // CRLF hand content outside the block. Only the fenced block itself is ours (always LF).
265
+ // Every hand byte outside the fence is preserved EXACTLY (raw offsets, never a split/join rebuild
266
+ // that would normalize CRLF); only the fenced block itself is ours (always LF).
245
267
  const rendered = block.length ? `${block.join('\n')}\n` : '';
246
268
  let content;
247
269
  if (probe.fence.state === 'ok') {
@@ -259,20 +281,22 @@ export const planApply = (probe, { clear = false } = {}) => {
259
281
 
260
282
  // ── rendering ───────────────────────────────────────────────────────────────────────
261
283
 
284
+ const staleRealLine = (rel) => ` ⚠ fenced entry became a REAL path: ${rel} — delete its line from the managed block BEFORE relying on git for this path: an excluded real file is silently skipped by bulk staging (git add -A / git add .), so it stays OUT of your commits, and an explicit git add refuses it without -f; .git/info/exclude never warns`;
285
+ const leftoverLine = (rel) => ` ⚠ refusing: fenced entry ${rel} is an empty read-only file no arm calls a mask, so neither verdict is given — a running sandbox's mask: re-run once no other sandboxed command runs; litter a sandbox left: remove the file outside the sandbox and keep its line; a real file: delete its line`;
286
+
262
287
  const formatProbe = (probe) => {
263
288
  const lines = [
264
- `sandbox-masks — never-committable untracked masks (device/FIFO/socket) in ${probe.root}`,
289
+ `sandbox-masks — sandbox masks (device/FIFO/socket or foreign mount target) in ${probe.root}`,
265
290
  ` exclude file: ${probe.excludeFile}`,
266
291
  ` managed block: ${probe.fence.state === 'ok' ? `present (${probe.fence.body.filter((l) => patternToRel(l) != null).length} entr(ies))` : probe.fence.state}`,
267
292
  ];
268
293
  if (probe.fence.state === 'malformed') lines.push(` ⚠ MALFORMED managed block (${probe.fence.reason}) — this lane fails closed and will not write until it is fixed by hand`);
269
294
  lines.push(` masks visible now: ${probe.masks.length === 0 ? '0 (outside the sandbox or clean)' : ''}`);
270
- for (const m of probe.masks) lines.push(` · ${m}`);
271
- for (const rel of probe.staleReal) {
272
- lines.push(` ⚠ fenced entry became a REAL path: ${rel} — delete its line from the managed block BEFORE relying on git for this path: an excluded real file is silently skipped by bulk staging (git add -A / git add .), so it stays OUT of your commits, and an explicit git add refuses it without -f; .git/info/exclude never warns`);
273
- }
295
+ for (const m of probe.masks) lines.push(` · ${m}${probe.mountOnly.includes(m) ? ' (mount-only: it rides git add -A and the review fingerprint while git does not ignore it)' : ''}`);
296
+ if (probe.mountSignal === 'absent') lines.push(' mount signal: not read on this platform — only device/FIFO/socket masks are judged');
297
+ lines.push(...probe.staleReal.map(staleRealLine));
274
298
  for (const rel of probe.unrenderable) {
275
- lines.push(` ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the review domain already ignores the mask itself): ${JSON.stringify(rel)}`);
299
+ lines.push(` ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the mask itself stays untracked): ${JSON.stringify(rel)}`);
276
300
  }
277
301
  // A stale-real-only fence (empty derivation over a non-empty block) makes the plain --apply
278
302
  // REFUSE — the rendered one-liner must be the form planApply actually accepts (--clear), the
@@ -284,15 +308,16 @@ const formatProbe = (probe) => {
284
308
  return lines.join('\n');
285
309
  };
286
310
 
287
- const HELP = `sandbox-masks — cosmetic exclude lane for sandbox-injected device masks (agent-workflow kit, AD-044).
311
+ const HELP = `sandbox-masks — exclude lane for sandbox masks (agent-workflow kit, AD-044).
288
312
 
289
313
  Usage:
290
314
  node sandbox-masks.mjs [--cwd <dir>] [--json] # READ-ONLY probe/preview
291
315
  node sandbox-masks.mjs [--cwd <dir>] --apply [--clear] # consent-gated FULL-BLOCK replace
292
316
 
293
- The review domain already ignores never-committable untracked paths (char/block devices, FIFOs,
294
- sockets) BY CONSTRUCTION — this lane is cosmetic: it hides the CURRENT, probe-derived mask set
295
- from \`git status\` via one managed fenced block in \`git rev-parse --git-path info/exclude\`.
317
+ A mask is an untracked device/FIFO/socket (the review domain already ignores these) or, on Linux,
318
+ a foreign mount target read from /proc/self/mountinfo (the review domain sees it while git does not ignore it).
319
+ The lane hides the CURRENT, probe-derived set from \`git status\` via one managed fenced block in
320
+ \`git rev-parse --git-path info/exclude\`. An unreadable or malformed mountinfo refuses (exit 1).
296
321
  Only that block is ever written — never .gitignore, never a global excludesFile. --apply REPLACES
297
322
  the whole block from a fresh derivation (stale masks drop by construction); an EMPTY derivation
298
323
  over a non-empty block refuses unless --clear is given (you are probably outside the sandbox).
@@ -317,7 +342,9 @@ export const main = (argv, ctx = {}) => {
317
342
  const cwd = cwdIdx !== -1 ? argv[cwdIdx + 1] : (ctx.cwd ?? process.cwd());
318
343
  if (cwdIdx !== -1 && (!cwd || cwd.startsWith('--'))) throw fail(2, '--cwd requires a directory argument');
319
344
  if (argv.includes('--clear') && !argv.includes('--apply')) throw fail(2, '--clear is only valid together with --apply');
320
- const probe = probeSandboxMasks({ cwd, ...(ctx.deps ?? {}) });
345
+ // --apply --clear needs no derivation: it never reads the mount table and never judges the fence.
346
+ const clearOnly = argv.includes('--apply') && argv.includes('--clear');
347
+ const probe = probeSandboxMasks({ cwd, ...(ctx.deps ?? {}), ...(clearOnly ? { readMountinfo: () => null, clearOnly } : {}) });
321
348
  if (probe == null) throw fail(1, `not a git work tree: ${cwd}`);
322
349
  if (!argv.includes('--apply')) {
323
350
  if (argv.includes('--json')) {
@@ -330,7 +357,7 @@ export const main = (argv, ctx = {}) => {
330
357
  // The unrenderable skips stay LOUD on EVERY apply path — the same warning the
331
358
  // probe prints; the renderable subset still applies (a refusal would block the useful masks).
332
359
  const skipWarnings = probe.unrenderable
333
- .map((rel) => `sandbox-masks: ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the review domain already ignores the mask itself): ${JSON.stringify(rel)}`)
360
+ .map((rel) => `sandbox-masks: ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the mask itself stays untracked): ${JSON.stringify(rel)}`)
334
361
  .join('\n');
335
362
  // A refusal carries the unrenderable warnings too — they are never discarded.
336
363
  if (plan.action === 'refuse') throw fail(1, skipWarnings ? `${plan.reason}\n${skipWarnings}` : plan.reason);