@sabaiway/agent-workflow-kit 14.14.0 → 14.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ 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.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)
8
+
9
+ **`/agent-workflow-kit sandbox-masks` now reads the Linux mount table.** Until 14.15.0 a mask was only a device, a
10
+ FIFO or a socket. When the sandbox masked paths with bind mounts of empty files, the probe found no mask, called the
11
+ fenced entries real paths, and the Recommendations screen offered `--apply --clear`, which removed a working block.
12
+
13
+ - **One test for a mask.** An untracked path is a mask when it is a device, FIFO or socket, or when
14
+ `/proc/self/mountinfo` shows it as a mount target brought from elsewhere (a foreign mount, usually the sandbox's). The
15
+ derivation and the check of fenced entries use the same test, so a fenced mount mask is never called a real path.
16
+ A path bound onto itself is not a mask. The probe's `--json` gains `mountOnly` and `mountSignal`.
17
+ - **No guessing.** Off Linux the probe says the mount signal is not read and judges devices alone. On Linux a mount
18
+ table it cannot read, or one it cannot parse, stops the probe with exit 1, and the Recommendations screen records a
19
+ skip. `--apply --clear` never reads the table.
20
+ - **A mount-only mask matters to reviews.** Reviews still ignore device masks only. A mask that is a mount of an
21
+ ordinary file stays in `git add -A` and the review fingerprint while git does not ignore it, so the Recommendations
22
+ item is `attention` when a managed block exists but lacks such a mask. The mode doc, the command list, the README
23
+ row and the item's text now say this instead of calling every mask a device.
24
+ - **New file.** `tools/mount-masks.mjs`, a read-only module with no command of its own.
25
+
26
+ ## 14.14.1 — the agents writer stops on a project folder given as a symlink, before it reads or writes anything (AD-161)
27
+
28
+ **`cheap-agents.mjs --cwd <dir>` now refuses a `<dir>` that is itself a symlink.** Until 14.14.1 an `--apply` there
29
+ wrote the vehicles through the link. With a hand-edited executor it placed `changelog-skeleton.md` and then failed on
30
+ the executor with an uncoded error, leaving `.claude/agents/` half placed; with a trailing `/` even that executor was
31
+ rewritten through the link.
32
+
33
+ - **The refusal.** `<link>`, `<link>/` and `<link>/.` stop with `CHEAP_AGENTS_SYMLINK`, exit 1, and a message naming
34
+ the project root as a symlink the writer refuses to write through. It is the first step of `--dry-run` and
35
+ `--apply` alike and of a direct `preflightCheapAgents` call: nothing under the root is read, nothing is written.
36
+ - **What stays.** A real directory, a regular file, an absent path and a project reached through a linked parent
37
+ directory get the same answers as in 14.14.0, and so does the default, the current directory. The mode doc's
38
+ symlink-safe line and the tool header name the project root.
39
+
7
40
  ## 14.14.0 — the Recommendations screen lists each step of the reference setup you have not taken or declined, with its preview command or the reason it has none (AD-160)
8
41
 
9
42
  **`/agent-workflow-kit recommendations` now shows what your project still lacks of the full flow.** Until 14.14.0
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.0'
6
+ version: '14.15.0'
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.0",
6
+ "version": "14.15.0",
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.0",
3
+ "version": "14.15.0",
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",
@@ -19,7 +19,7 @@ Writing code, running gates, and user-facing copy never move to these four vehic
19
19
  Run `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs [--dry-run | --apply] [--cwd <dir>]`:
20
20
 
21
21
  1. **`--dry-run` first, always** (the default — changes nothing). It previews, per bundled vehicle, whether it **would place** the file, finds it **already current**, or finds a **customized** file (different content) that will be **preserved, never overwritten** (delete the file to reseed it from the bundle) — except the executor, whose differing bytes **would re-derive** from `docs/ai/vehicles.json` and are rewritten on `--apply` (a hand edit is replaced, never preserved).
22
- 2. **Only on an explicit yes**, re-run with `--apply`. It writes **only** under `.claude/agents/` — never `settings.json` / `settings.local.json`, never a commit. `--apply` is deployment-gated (the stamp must be at the lineage head) and symlink-safe (a symlinked `.claude` / `.claude/agents` / target file is a STOP).
22
+ 2. **Only on an explicit yes**, re-run with `--apply`. It writes **only** under `.claude/agents/` — never `settings.json` / `settings.local.json`, never a commit. `--apply` is deployment-gated (the stamp must be at the lineage head) and symlink-safe (a symlinked project root / `.claude` / `.claude/agents` / target file is a STOP, in a `--dry-run` too).
23
23
  3. **Hidden-mode deployments:** after apply, run the hide-footprint reconcile (`node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile`) so the placed files stay invisible to `git status` — `/.claude/agents/` is in the known-footprint registry; the apply report reminds you.
24
24
 
25
25
  **Invariants:** writer (writes only `.claude/agents/`) · preview by default · a diverged existing file is reported and preserved, never clobbered — except the executor, re-derived from `docs/ai/vehicles.json` on apply · never touches settings · never commits · **no READ-ONLY vehicle grants `Bash`, and exactly one bundled vehicle, `executor`, grants a shell** · the cheap-lane vehicles are pinned to `model: haiku` + `effort: low`, and the review lens is pinned OFF the cheap model (all content-tested).
@@ -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
 
@@ -12,8 +12,8 @@
12
12
  // • deployment-gated — `--apply` STOPs unless docs/ai/.workflow-version equals the lineage
13
13
  // head (a dry-run stays usable whatever the stamp says; an unreadable orchestration config
14
14
  // STOPs both, since the derived lenses it names cannot be known);
15
- // • symlink-safe — a symlinked `.claude` / `.claude/agents` / target file is a STOP, never a
16
- // write-through;
15
+ // • symlink-safe — a symlinked project root, `.claude`, `.claude/agents` or target file is a
16
+ // STOP, in a dry-run too, never a write-through;
17
17
  // • preserves differing .claude/agents/ files (`customized — preserved`), except the executor:
18
18
  // on apply its body is re-derived from docs/ai/vehicles.json, replacing a hand edit;
19
19
  // an identical file is `already current` (idempotent re-run);
@@ -31,7 +31,7 @@
31
31
  // Dependency-free, Node >= 22. No side effects on import.
32
32
 
33
33
  import { mkdirSync, writeFileSync } from 'node:fs';
34
- import { join } from 'node:path';
34
+ import { join, resolve } from 'node:path';
35
35
  import { fileURLToPath } from 'node:url';
36
36
  import { isDirectRun } from './direct-run.mjs';
37
37
  import { shellQuoteArg } from './repo-lex.mjs';
@@ -117,9 +117,21 @@ const writeFsDeps = (deps = {}) => ({
117
117
  writeFile: deps.writeFile ?? writeFileSync,
118
118
  });
119
119
 
120
+ const refuseSymlinkedRoot = (projectDir, deps) => {
121
+ const root = resolve(projectDir);
122
+ let stat;
123
+ try {
124
+ stat = readFsDeps(deps).lstat(root);
125
+ } catch {
126
+ return;
127
+ }
128
+ if (stat.isSymbolicLink()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `the project root ${root} is a symlink — refusing to write through it`);
129
+ };
130
+
120
131
  export const preflightCheapAgents = ({ cwd, derived = [] }, deps = {}) => {
121
132
  const fs = readFsDeps(deps);
122
133
  const projectDir = cwd ?? process.cwd();
134
+ refuseSymlinkedRoot(projectDir, deps);
123
135
  const templatesByName = new Map(readBundledAgents(deps).map((template) => [template.name, template]));
124
136
  for (const template of derived) templatesByName.set(template.name, template);
125
137
  const templates = [...templatesByName.values()];
@@ -169,6 +181,7 @@ export const applyCheapAgentsCommand = (root) =>
169
181
  export const writeCheapAgents = ({ cwd, dryRun = true } = {}, deps = {}) => {
170
182
  const fs = writeFsDeps(deps);
171
183
  const projectDir = cwd ?? process.cwd();
184
+ refuseSymlinkedRoot(projectDir, deps);
172
185
  const posture = loadExecutorPostureOrStop(projectDir, deps);
173
186
  const derived = [executorTemplate(posture, deps), ...configuredDerivedTemplates(projectDir, deps)];
174
187
  const preflight = preflightCheapAgents({ cwd: projectDir, derived }, deps);
@@ -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,27 @@ 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
+ };
69
88
 
70
89
  // A root-anchored gitignore pattern for one relative path: leading `/` (root-anchored — a mask
71
90
  // name can never shadow a deeper real path), glob metacharacters + leading-! / leading-# handled
@@ -77,17 +96,18 @@ export const toExcludePattern = (rel) => {
77
96
  return `/${escaped}`;
78
97
  };
79
98
 
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 } = {}) => {
99
+ // deriveMasks({ root, lstat, listUntracked, readMountinfo }) → { masks, unrenderable, mountOnly,
100
+ // mountSignal }: sorted rel paths that are a mask (a never-committable lstat class OR a foreign
101
+ // mount target). Tracked paths never appear in an --others walk; everything else fails the
102
+ // predicate. A CR/LF-carrying mask NAME cannot be ONE gitignore rule — rendering it could hide
103
+ // unrelated committable paths — so it is a LOUD unrenderable skip, never written.
104
+ export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readMountinfo = readMountinfoDefault } = {}) => {
87
105
  const entries = listUntracked(root);
88
106
  if (entries == null) return null;
107
+ const { foreign, signal } = mountTargets(root, readMountinfo);
89
108
  const masks = [];
90
109
  const unrenderable = [];
110
+ const mountOnly = [];
91
111
  for (const rel of entries) {
92
112
  let stat = null;
93
113
  try {
@@ -95,13 +115,15 @@ export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntra
95
115
  } catch {
96
116
  stat = null;
97
117
  }
98
- if (!isNeverCommittableStat(stat)) continue;
118
+ const device = isNeverCommittableStat(stat);
119
+ if (!device && !foreign.has(rel)) continue;
120
+ if (!device) mountOnly.push(rel);
99
121
  // CR/LF anywhere, or a trailing TAB (inexpressible in gitignore — only trailing SPACES escape
100
122
  // as `\ `), make the name unrenderable as ONE exclude rule.
101
123
  if (/[\r\n]/.test(rel) || /\t$/.test(rel)) unrenderable.push(rel);
102
124
  else masks.push(rel);
103
125
  }
104
- return { masks: masks.sort(), unrenderable: unrenderable.sort() };
126
+ return { masks: masks.sort(), unrenderable: unrenderable.sort(), mountOnly: mountOnly.sort(), mountSignal: signal };
105
127
  };
106
128
 
107
129
  // ── the fence (own block; malformed → fail closed) ─────────────────────────────────
@@ -119,7 +141,7 @@ export const findMasksFence = (lines) => {
119
141
  // escaped forms; unescape is its exact inverse — the escaped-space form `\ ` included, so a
120
142
  // trailing-space mask round-trips). Non-pattern lines (blank/comment) are skipped;
121
143
  // only UNESCAPED trailing whitespace is insignificant (gitignore semantics).
122
- const patternToRel = (line) => {
144
+ export const patternToRel = (line) => {
123
145
  // A trailing CR (a CRLF-saved exclude file) strips FIRST — it would otherwise ride into the rel
124
146
  // name and false-ENOENT the revalidation lstat; then only UNESCAPED trailing
125
147
  // whitespace is insignificant (gitignore semantics — an escaped `\ ` survives).
@@ -129,9 +151,9 @@ const patternToRel = (line) => {
129
151
  };
130
152
 
131
153
  // 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 } = {}) => {
154
+ // that is not a mask (the derivation's predicate) is a STALE-REAL flag — the D5 watch (a real file
155
+ // at an excluded path is silently skipped by bulk staging; delete the line before trusting git).
156
+ export const revalidateFence = (bodyLines, { root, lstat = lstatSync, readMountinfo = readMountinfoDefault, foreign = mountTargets(root, readMountinfo).foreign } = {}) => {
135
157
  const staleReal = [];
136
158
  for (const line of bodyLines) {
137
159
  const rel = patternToRel(line);
@@ -142,7 +164,7 @@ export const revalidateFence = (bodyLines, { root, lstat = lstatSync } = {}) =>
142
164
  } catch {
143
165
  continue; // vanished mask — the next --apply drops it by construction, nothing real is hidden
144
166
  }
145
- if (!isNeverCommittableStat(stat)) staleReal.push(rel);
167
+ if (!isNeverCommittableStat(stat) && !foreign.has(rel)) staleReal.push(rel);
146
168
  }
147
169
  return staleReal;
148
170
  };
@@ -151,7 +173,7 @@ export const revalidateFence = (bodyLines, { root, lstat = lstatSync } = {}) =>
151
173
 
152
174
  // probeSandboxMasks({ cwd, ... }) → everything the render/apply/advisor needs, or null when not a
153
175
  // git work tree. Read-only always.
154
- export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readFile = readFileSync } = {}) => {
176
+ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readFile = readFileSync, readMountinfo = readMountinfoDefault } = {}) => {
155
177
  const root = gitLine(['rev-parse', '--show-toplevel'], cwd);
156
178
  if (root == null) return null;
157
179
  const gitPathRaw = gitLine(['rev-parse', '--git-path', 'info/exclude'], cwd);
@@ -161,9 +183,11 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
161
183
  // The write-containment root: info/exclude lives in the COMMON git dir (worktree-safe),
162
184
  // and the apply must never write through a symlinked component or a non-regular leaf.
163
185
  const gitCommonDir = resolve(cwd, commonDirRaw);
164
- const derived = deriveMasks({ root, lstat, listUntracked });
186
+ let text; // read ONCE: derivation and revalidation judge one table
187
+ const once = () => (text === undefined ? (text = readMountinfo()) : text);
188
+ const derived = deriveMasks({ root, lstat, listUntracked, readMountinfo: once });
165
189
  if (derived == null) return null;
166
- const { masks, unrenderable } = derived;
190
+ const { masks, unrenderable, mountOnly, mountSignal } = derived;
167
191
  // The WHOLE chain is guarded BEFORE any read: a symlinked exclude leaf OR a
168
192
  // symlinked parent (.git/info) must never be read through, and a FIFO/socket leaf would HANG
169
193
  // the read-only probe — parent-chain containment + leaf lstat first, fail closed on everything
@@ -198,9 +222,9 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
198
222
  }
199
223
  const lines = content.split('\n');
200
224
  const fence = findMasksFence(lines);
201
- const staleReal = fence.state === 'ok' ? revalidateFence(fence.body, { root, lstat }) : [];
225
+ const staleReal = fence.state === 'ok' ? revalidateFence(fence.body, { root, lstat, readMountinfo: once }) : [];
202
226
  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 };
227
+ return { root, excludeFile, gitCommonDir, content, lines, fence, masks, unrenderable, mountOnly, mountSignal, staleReal, applyCmd };
204
228
  };
205
229
 
206
230
  // needsMasksApply(probe) → whether the CURRENT derivation diverges from the fenced block — the
@@ -261,18 +285,19 @@ export const planApply = (probe, { clear = false } = {}) => {
261
285
 
262
286
  const formatProbe = (probe) => {
263
287
  const lines = [
264
- `sandbox-masks — never-committable untracked masks (device/FIFO/socket) in ${probe.root}`,
288
+ `sandbox-masks — sandbox masks (device/FIFO/socket or foreign mount target) in ${probe.root}`,
265
289
  ` exclude file: ${probe.excludeFile}`,
266
290
  ` managed block: ${probe.fence.state === 'ok' ? `present (${probe.fence.body.filter((l) => patternToRel(l) != null).length} entr(ies))` : probe.fence.state}`,
267
291
  ];
268
292
  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
293
  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}`);
294
+ 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)' : ''}`);
295
+ if (probe.mountSignal === 'absent') lines.push(' mount signal: not read on this platform — only device/FIFO/socket masks are judged');
271
296
  for (const rel of probe.staleReal) {
272
297
  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
298
  }
274
299
  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)}`);
300
+ 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
301
  }
277
302
  // A stale-real-only fence (empty derivation over a non-empty block) makes the plain --apply
278
303
  // REFUSE — the rendered one-liner must be the form planApply actually accepts (--clear), the
@@ -284,15 +309,16 @@ const formatProbe = (probe) => {
284
309
  return lines.join('\n');
285
310
  };
286
311
 
287
- const HELP = `sandbox-masks — cosmetic exclude lane for sandbox-injected device masks (agent-workflow kit, AD-044).
312
+ const HELP = `sandbox-masks — exclude lane for sandbox masks (agent-workflow kit, AD-044).
288
313
 
289
314
  Usage:
290
315
  node sandbox-masks.mjs [--cwd <dir>] [--json] # READ-ONLY probe/preview
291
316
  node sandbox-masks.mjs [--cwd <dir>] --apply [--clear] # consent-gated FULL-BLOCK replace
292
317
 
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\`.
318
+ A mask is an untracked device/FIFO/socket (the review domain already ignores these) or, on Linux,
319
+ a foreign mount target read from /proc/self/mountinfo (the review domain sees it while git does not ignore it).
320
+ The lane hides the CURRENT, probe-derived set from \`git status\` via one managed fenced block in
321
+ \`git rev-parse --git-path info/exclude\`. An unreadable or malformed mountinfo refuses (exit 1).
296
322
  Only that block is ever written — never .gitignore, never a global excludesFile. --apply REPLACES
297
323
  the whole block from a fresh derivation (stale masks drop by construction); an EMPTY derivation
298
324
  over a non-empty block refuses unless --clear is given (you are probably outside the sandbox).
@@ -317,7 +343,9 @@ export const main = (argv, ctx = {}) => {
317
343
  const cwd = cwdIdx !== -1 ? argv[cwdIdx + 1] : (ctx.cwd ?? process.cwd());
318
344
  if (cwdIdx !== -1 && (!cwd || cwd.startsWith('--'))) throw fail(2, '--cwd requires a directory argument');
319
345
  if (argv.includes('--clear') && !argv.includes('--apply')) throw fail(2, '--clear is only valid together with --apply');
320
- const probe = probeSandboxMasks({ cwd, ...(ctx.deps ?? {}) });
346
+ // --apply --clear needs no derivation, so it never reads the mount table (the absent answer).
347
+ const clearOnly = argv.includes('--apply') && argv.includes('--clear');
348
+ const probe = probeSandboxMasks({ cwd, ...(ctx.deps ?? {}), ...(clearOnly ? { readMountinfo: () => null } : {}) });
321
349
  if (probe == null) throw fail(1, `not a git work tree: ${cwd}`);
322
350
  if (!argv.includes('--apply')) {
323
351
  if (argv.includes('--json')) {
@@ -330,7 +358,7 @@ export const main = (argv, ctx = {}) => {
330
358
  // The unrenderable skips stay LOUD on EVERY apply path — the same warning the
331
359
  // probe prints; the renderable subset still applies (a refusal would block the useful masks).
332
360
  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)}`)
361
+ .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
362
  .join('\n');
335
363
  // A refusal carries the unrenderable warnings too — they are never discarded.
336
364
  if (plan.action === 'refuse') throw fail(1, skipWarnings ? `${plan.reason}\n${skipWarnings}` : plan.reason);