@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 +36 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/sandbox-masks.md +3 -3
- package/tools/commands.mjs +1 -1
- package/tools/mount-masks.mjs +96 -0
- package/tools/recommendations.mjs +7 -5
- package/tools/sandbox-masks.mjs +89 -62
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 | **
|
|
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.
|
|
6
|
+
version: '14.15.1'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "14.
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
|
package/tools/commands.mjs
CHANGED
|
@@ -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
|
|
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
|
|
275
|
-
'sandbox-masks.stale-real': '{n} sandbox
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/tools/sandbox-masks.mjs
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// sandbox-masks.mjs — the GUARDED
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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)
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
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) →
|
|
207
|
-
//
|
|
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
|
-
//
|
|
242
|
-
//
|
|
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 —
|
|
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
|
-
|
|
272
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
from \`git status\` via one managed fenced block in
|
|
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
|
-
|
|
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
|
|
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);
|