create-agentic-workspace 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agentic-workspace",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "The pre-session bootstrap wizard for an Agentic Foundry workspace: declares (never grants) the permission floor, absorbs foundry-bootstrap.sh's out-of-session identity wiring, and scaffolds a seven-file schema-valid workspace. Zero dependencies, no lifecycle scripts, no telemetry.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "marketplace_name": "agentic-foundry",
35
35
  "marketplace_repo": "lukasrepublic/agentic-foundry",
36
36
  "plugin_name": "foundry",
37
- "plugin_version": "1.3.1",
37
+ "plugin_version": "1.4.0",
38
38
  "pins_researched": "2026-08-02"
39
39
  }
40
40
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "plugin_root_glob": "~/.claude/plugins/cache/*/foundry/*",
4
- "generated_for_plugin_version": "1.3.1",
4
+ "generated_for_plugin_version": "1.4.0",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -0,0 +1,233 @@
1
+ // floorReconcile.mjs — feat-foundry-adoption-permission-floor-reconcile.
2
+ //
3
+ // The wizard already computes the whole answer for an existing workspace and refuses to act on it:
4
+ // it ships the floor as a bundled constant, reads the target's effective rules, and names every
5
+ // missing rule — then reports and writes nothing, because settings.json goes through the whole-file
6
+ // never-clobber plan (exists and differs -> `drifted` -> untouched). Five of seven adopter
7
+ // handbooks are missing the entire floor as a result.
8
+ //
9
+ // This module converges the target's `permissions` block by ADDING the rules the classifier named.
10
+ // Nothing is removed, nothing is reordered. The desired state is the shipped constant, the current
11
+ // state is what the classifier reads, and the write is the delta — recomputed every run, which is
12
+ // why no ledger is needed and why a second run is silent.
13
+ //
14
+ // THE WRITE IS THIS CLI'S FIRST TO A PATH THAT ALREADY EXISTS, and every anti-clobber control in
15
+ // the codebase is structurally unavailable to it. `applyPlan` opens O_EXCL, create-only, refusing
16
+ // by definition the case this module is entirely about. So:
17
+ // - confinedJoin refuses a resolution that ESCAPES the root but PASSES an in-root symlink, and
18
+ // `.claude/settings.json` symlinked to `.claude/foundry-operators.json` resolves inside it —
19
+ // onto the file whose key membership alone mints an authorizer. statSync().isFile() returns
20
+ // true through a symlink; only lstat sees it.
21
+ // - truncate-then-write loses the operator's whole permissions block AND their install pin on a
22
+ // ^C or ENOSPC. That is silent loss from a benign cause, which is never-clobber's substance
23
+ // rather than its letter.
24
+ // One mechanism answers all three: confinement join + LINK-LEVEL stat + temp-in-.claude + rename.
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { confinedJoin, RefusalError } from './util.mjs';
28
+ import { buildSettings } from './permissionFloor.mjs';
29
+
30
+ /** The drift classes whose findings name a rule this module may ADD. Everything else the
31
+ * classifier can emit is report-only: blanket-allow, ask-shadowed, ask-shadowed-ceremony and
32
+ * tier-conflict each need a REMOVAL or a narrowing to close, which is a different and strictly
33
+ * more dangerous capability than adding a rule from a shipped constant. An allowlist, not a
34
+ * denylist — a class added to the vocabulary later defaults to not being written. */
35
+ export const ADDITIVE_CLASSES = Object.freeze(['allow-absent', 'ask-absent', 'deny-missing']);
36
+
37
+ const TIER_OF_CLASS = Object.freeze({
38
+ 'allow-absent': 'allow',
39
+ 'ask-absent': 'ask',
40
+ 'deny-missing': 'deny',
41
+ });
42
+
43
+ /** Read the target's TRACKED settings.json only. Deliberately not the union with
44
+ * settings.local.json: a floor rule carried only in the untracked file reads as covered, so the
45
+ * tracked file would stay incomplete while the report says converged — sharpest for `deny`, where
46
+ * the repo then ships to every other clone and to CI without it. The union is still what the
47
+ * REPORT is computed over; only the write set is narrowed. */
48
+ export function readTrackedRules(settingsObj) {
49
+ const perms = (settingsObj && settingsObj.permissions) || {};
50
+ const out = { allow: [], ask: [], deny: [] };
51
+ for (const tier of ['allow', 'ask', 'deny']) {
52
+ for (const rule of perms[tier] || []) out[tier].push({ rule, origin: 'settings.json', tierKey: tier });
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Does this target carry a foundry marketplace entry, and is it pinned?
58
+ *
59
+ * Load-bearing, and easy to miss. Every bundled `allow` rule is wildcarded across the plugin cache,
60
+ * so the grant is bounded only by the pinned marketplace ref + autoUpdate:false that the create
61
+ * path writes IN THE SAME FILE, IN THE SAME WRITE. Adding the allow rules alone would convert one
62
+ * trust acceptance into a standing grant over whatever a future resolution drops into that cache
63
+ * path. Six of the seven adopter handbooks carry no marketplace entry at all. */
64
+ export function classifyPin(settingsObj, pins) {
65
+ const entry = ((settingsObj && settingsObj.extraKnownMarketplaces) || {})[pins.marketplace_name];
66
+ if (entry === undefined) return { state: 'absent', ref: null, skew: false };
67
+ const ref = entry && entry.source && entry.source.ref;
68
+ const pinned = typeof ref === 'string' && ref !== '' && !ref.includes('*') && entry.autoUpdate === false;
69
+ // A pin can be perfectly well-formed and still name a DIFFERENT plugin than the map these rules
70
+ // came from. The 42 allow rules are wildcarded across the cache and their per-script rationales
71
+ // were reviewed against THIS version's scripts; writing them over a workspace pinned at an older
72
+ // one grants the same paths against different code. Surfaced rather than refused — the operator's
73
+ // review of the plan is the control, and it can only work if the skew is on screen.
74
+ const skew = pinned && ref !== `v${pins.plugin_version}`;
75
+ return { state: pinned ? 'pinned' : 'unpinned', ref: typeof ref === 'string' ? ref : null, skew };
76
+ }
77
+
78
+ /** Compute the delta WITHOUT touching the filesystem. Returns
79
+ * { additions: {allow,ask,deny}, total, pin, withheldAllow, blanket }.
80
+ *
81
+ * `findings` is classifyDrift's output over the TRACKED rules; `map` supplies the tier each rule
82
+ * belongs in — never the finding, and never anything read from the target. */
83
+ export function planAdditions({ findings, map, settingsObj, pins }) {
84
+ const tierOfRule = new Map(map.entries.map((e) => [e.rule, e.tier]));
85
+ const additions = { allow: [], ask: [], deny: [] };
86
+
87
+ const pin = classifyPin(settingsObj, pins);
88
+ // An UNPINNED existing entry withholds the allow tier only. ask and deny are strengthening —
89
+ // more prompting, more blocking — so holding them hostage to the pin would leave the floor worse
90
+ // for no gain. An ABSENT entry is not a refusal: the pin is added alongside the grants, exactly
91
+ // as the create path emits them together.
92
+ const withheldAllow = pin.state === 'unpinned';
93
+
94
+ for (const f of findings) {
95
+ if (!ADDITIVE_CLASSES.includes(f.class)) continue;
96
+ const tier = tierOfRule.get(f.rule);
97
+ // the tier comes from the map; a finding naming a rule the map does not declare is not ours
98
+ if (tier === undefined || tier !== TIER_OF_CLASS[f.class]) continue;
99
+ if (tier === 'allow' && withheldAllow) continue;
100
+ if (!additions[tier].includes(f.rule)) additions[tier].push(f.rule);
101
+ }
102
+
103
+ // preserve bundled-map order so two runs over the same input produce the same diff
104
+ for (const tier of ['allow', 'ask', 'deny']) {
105
+ const order = map.entries.filter((e) => e.tier === tier).map((e) => e.rule);
106
+ additions[tier].sort((a, b) => order.indexOf(a) - order.indexOf(b));
107
+ }
108
+
109
+ const total = additions.allow.length + additions.ask.length + additions.deny.length;
110
+ const blanket = findings.filter((f) => f.class === 'blanket-allow').map((f) => f.rule);
111
+ return { additions, total, pin, withheldAllow, blanket, pinsVersion: pins.plugin_version };
112
+ }
113
+
114
+ /** Apply a plan to a parsed settings object, returning a NEW object. Pure — no I/O.
115
+ *
116
+ * Additive by construction: every pre-existing rule keeps its text, its tier and its position
117
+ * relative to the other pre-existing rules of that tier, because new rules are appended and the
118
+ * prior array is copied in order. No input can express a removal — the rules come from the bundled
119
+ * map and the only operation is append. */
120
+ export function applyAdditions(settingsObj, plan, { map, pins }) {
121
+ const next = { ...settingsObj };
122
+ const perms = { ...(settingsObj.permissions || {}) };
123
+ for (const tier of ['allow', 'ask', 'deny']) {
124
+ const existing = Array.isArray(perms[tier]) ? perms[tier] : [];
125
+ perms[tier] = plan.additions[tier].length > 0 ? [...existing, ...plan.additions[tier]] : existing;
126
+ }
127
+ next.permissions = perms;
128
+
129
+ if (plan.pin.state === 'absent') {
130
+ // Taken from buildSettings — the SAME function the create path uses, over the same map and the
131
+ // same pins — rather than re-spelling the entry here. A second copy of that literal is how the
132
+ // pin drifts from what a fresh scaffold writes. Only its marketplace block is used; the
133
+ // permissions it also builds are irrelevant here and discarded.
134
+ const created = buildSettings(map, pins);
135
+ next.extraKnownMarketplaces = {
136
+ ...(settingsObj.extraKnownMarketplaces || {}),
137
+ ...created.extraKnownMarketplaces,
138
+ };
139
+ }
140
+ return next;
141
+ }
142
+
143
+ /** Resolve the target settings path, refusing anything that is not a regular file inside the root.
144
+ * lstat, NOT stat: statSync().isFile() follows a symlink, so an in-root link would pass. */
145
+ export function resolveTarget(physicalRoot) {
146
+ const joined = confinedJoin(physicalRoot, path.join('.claude', 'settings.json'));
147
+ if (joined === null) {
148
+ throw new RefusalError('refusing .claude/settings.json: path escapes the target root', '.claude/settings.json');
149
+ }
150
+ const st = fs.lstatSync(joined, { throwIfNoEntry: false });
151
+ if (!st) return { path: joined, present: false };
152
+ if (!st.isFile()) {
153
+ throw new RefusalError(
154
+ `refusing ${joined}: not a regular file (symlink or special file)`,
155
+ '.claude/settings.json',
156
+ );
157
+ }
158
+ return { path: joined, present: true };
159
+ }
160
+
161
+ /** Parse the tracked settings file, refusing rather than treating unparseable as empty. Classifying
162
+ * against an empty rule set and then writing into a file whose keys could not be read would make
163
+ * the preserve-every-other-key guarantee unimplementable. */
164
+ export function readTarget(targetPath) {
165
+ const raw = fs.readFileSync(targetPath, 'utf-8');
166
+ try {
167
+ const doc = JSON.parse(raw);
168
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) throw new Error('not an object');
169
+ return doc;
170
+ } catch (e) {
171
+ throw new RefusalError(
172
+ `refusing ${targetPath}: does not parse as a JSON object (${e.message})`,
173
+ '.claude/settings.json',
174
+ );
175
+ }
176
+ }
177
+
178
+ /** Install new content by rename. Temp file in the TARGET'S OWN directory so the rename is
179
+ * same-filesystem and therefore atomic; rename neither follows a symlink at the destination nor
180
+ * can leave a truncated file, which closes the interrupted-write loss and the classify-to-write
181
+ * race together — the same argument applyPlan's O_EXCL comment already makes for the create path. */
182
+ export function writeTargetAtomically(targetPath, obj) {
183
+ const dir = path.dirname(targetPath);
184
+ const tmp = path.join(dir, `.settings.json.${process.pid}.tmp`);
185
+ const bytes = Buffer.from(`${JSON.stringify(obj, null, 2)}\n`, 'utf-8');
186
+ const fd = fs.openSync(tmp, 'wx');
187
+ try {
188
+ fs.writeFileSync(fd, bytes);
189
+ fs.fsyncSync(fd);
190
+ } finally {
191
+ fs.closeSync(fd);
192
+ }
193
+ try {
194
+ fs.renameSync(tmp, targetPath);
195
+ } catch (e) {
196
+ fs.rmSync(tmp, { force: true });
197
+ throw e;
198
+ }
199
+ }
200
+
201
+ /** Render the plan for the operator: every rule that would be added, with its tier, plus the
202
+ * per-tier counts and any qualifier. Used for both the dry-run report and the post-write one, so
203
+ * the two cannot describe the same plan differently. */
204
+ export function renderPlan(plan, { applied }) {
205
+ const lines = [];
206
+ const verb = applied ? 'added' : 'would add';
207
+ for (const tier of ['allow', 'ask', 'deny']) {
208
+ for (const rule of plan.additions[tier]) lines.push(` [${tier}] ${rule}`);
209
+ }
210
+ lines.push(
211
+ `permission-floor reconcile: ${verb} ` +
212
+ ['allow', 'ask', 'deny'].map((t) => `${t}=${plan.additions[t].length}`).join(', '),
213
+ );
214
+ if (plan.pin.state === 'absent') {
215
+ lines.push(` + marketplace pin added — the bundled allow rules are wildcarded across the plugin cache and are bounded only by it`);
216
+ } else if (plan.pin.state === 'pinned') {
217
+ // printed on EVERY pinned run, not only the skewed one: an operator cannot notice a mismatch
218
+ // that is never shown, and this is the branch where 42 grants are written without comment
219
+ lines.push(
220
+ ` · marketplace pinned at ${plan.pin.ref}; these rules come from the floor generated for v${plan.pinsVersion}` +
221
+ (plan.pin.skew ? ' — VERSION SKEW: the same wildcarded paths will resolve to that pin\'s scripts, not this one\'s' : ''),
222
+ );
223
+ } else if (plan.withheldAllow) {
224
+ lines.push(
225
+ ` ! marketplace entry present but unpinned (ref=${plan.pin.ref === null ? 'none' : plan.pin.ref})` +
226
+ ' — allow-tier rules WITHHELD; ask and deny still applied. Pin the marketplace, then re-run.',
227
+ );
228
+ }
229
+ for (const rule of plan.blanket) {
230
+ lines.push(` ! qualified by blanket allow ${rule} — this rule defeats the floor until it is narrowed`);
231
+ }
232
+ return lines;
233
+ }
@@ -4,10 +4,56 @@
4
4
  // (AC-BCL-8). `covers()` agrees with tests/test_permission_floor_map.py::_subsumes on the shared
5
5
  // 8-row table by construction (same prefix-subsumption rule).
6
6
  import fs from 'node:fs';
7
+ import os from 'node:os';
7
8
 
9
+ /** The one map schema_version this build understands. A map declaring anything else is refused
10
+ * rather than read optimistically — the tier field is load-bearing (it decides which effective tier
11
+ * an entry is compared against, and which tier a consumer writes it into), so a shape this code
12
+ * does not know is not a shape it may guess at. */
13
+ export const MAP_SCHEMA_VERSION = 1;
14
+
15
+ export const TIERS = Object.freeze(['allow', 'ask', 'deny']);
16
+
17
+ /** Thrown by loadMap for a structurally invalid bundled map. Mirrors the Python floor checker's
18
+ * FloorMalformed, which is the one condition that reds the doctor rather than reporting advisory. */
19
+ export class MapMalformed extends Error {}
20
+
21
+ /** Load + VALIDATE the bundled map. Validation is new (AC-FDC-4): this used to parse and return,
22
+ * with no schema_version check and no tier check — the Python twin already refused an out-of-enum
23
+ * tier, so the two disagreed on what counted as a loadable map. On the create path a bad tier at
24
+ * least crashed in buildSettings (`byTier[e.tier].push` on undefined); anything consuming findings
25
+ * programmatically would instead act on it. */
8
26
  export function loadMap(mapPath) {
9
27
  const text = fs.readFileSync(mapPath, 'utf-8');
10
- return JSON.parse(text);
28
+ let doc;
29
+ try {
30
+ doc = JSON.parse(text);
31
+ } catch (e) {
32
+ throw new MapMalformed(`permission-floor map unparseable: ${e.message}`);
33
+ }
34
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
35
+ throw new MapMalformed('permission-floor map is not a JSON object');
36
+ }
37
+ if (doc.schema_version !== MAP_SCHEMA_VERSION) {
38
+ throw new MapMalformed(
39
+ `permission-floor map schema_version ${JSON.stringify(doc.schema_version)} is not the ` +
40
+ `${MAP_SCHEMA_VERSION} this build understands`,
41
+ );
42
+ }
43
+ if (!Array.isArray(doc.entries) || doc.entries.length === 0) {
44
+ throw new MapMalformed('permission-floor map missing entries');
45
+ }
46
+ for (const e of doc.entries) {
47
+ if (e === null || typeof e !== 'object' || typeof e.rule !== 'string' || e.rule === '') {
48
+ throw new MapMalformed('permission-floor map entry has a non-string/empty rule');
49
+ }
50
+ if (!TIERS.includes(e.tier)) {
51
+ throw new MapMalformed(
52
+ `permission-floor map entry has invalid tier ${JSON.stringify(e.tier)}`,
53
+ );
54
+ }
55
+ }
56
+ return doc;
11
57
  }
12
58
 
13
59
  const RULE_RE = /^([A-Za-z0-9_-]+)\((.*)\)$/s;
@@ -23,28 +69,110 @@ export function ruleTool(rule) {
23
69
  return m ? m[1] : null;
24
70
  }
25
71
 
26
- /** covers(A, B): true if the broad rule A's reach (a `:*`-terminated prefix) subsumes rule B's
27
- * body. Mirrors tests/test_permission_floor_map.py::_subsumes exactly (same prefix rule). */
28
- export function covers(ruleA, ruleB) {
29
- const bodyA = ruleBody(ruleA);
30
- if (bodyA === null || !bodyA.endsWith(':*')) return false;
31
- const sA = bodyA.slice(0, -2);
32
- const bodyB = ruleBody(ruleB);
33
- if (bodyB === null) return false;
34
- const sB = bodyB.endsWith(':*') ? bodyB.slice(0, -2) : bodyB;
35
- return sB.startsWith(sA);
72
+ // --------------------------------------------------------------------------------------------- //
73
+ // canonicalization + the covers relation — AC-DPF-3's fold, ported from the Python floor checker
74
+ // so the two implementations decide coverage the same way (AC-FDC-6).
75
+ //
76
+ // WHY THIS REPLACED A RAW PREFIX MATCH. The previous `covers` compared rule bodies as literal
77
+ // strings. The Python twin canonicalizes first: it drops a leading interpreter word, expands `~/`
78
+ // and `$HOME/` against the real home, and folds away the marketplace/version segments beneath
79
+ // the plugin root. For the input that actually occurs in the wild — the harness's ask-to-allow persist,
80
+ // which writes an ABSOLUTE, version-resolved path into settings.local.json — the two disagreed:
81
+ //
82
+ // effective an ABSOLUTE, version-resolved path under the operator's real plugin root
83
+ // map the same script named through the map's `~` + wildcard form
84
+ // python covers -> true node covers -> false
85
+ //
86
+ // So the CLI reported `allow-absent` for rules the doctor could see were covered. Left alone, the
87
+ // consuming reconcile atom would write ~42 duplicates of rules already effectively present.
88
+ // --------------------------------------------------------------------------------------------- //
89
+
90
+ const INTERPRETER_WORDS = new Set(['python3', 'python', 'bash', 'sh']);
91
+
92
+ /** Build the cache-fold pattern from the map's OWN `plugin_root_glob` rather than from a second
93
+ * hardcoded copy of that path. One source of truth: a map that moves its plugin root cannot leave
94
+ * a stale fold behind here. Each `*` becomes one non-empty, slash-free segment.
95
+ *
96
+ * Absent a glob there is no fold — a caller that does not know where the plugin root lives has no
97
+ * business guessing at it. classifyDrift always has the map, so the real path always folds. */
98
+ export function foldRegexFromGlob(glob) {
99
+ if (typeof glob !== 'string' || glob === '') return null;
100
+ const tail = glob.replace(/^~\//, '').replace(/^\.claude\//, '');
101
+ const escaped = tail
102
+ .split('*')
103
+ .map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
104
+ .join('[^/\\s]+');
105
+ return new RegExp(escaped + '/');
36
106
  }
37
107
 
38
- const BLANKET_BODIES = new Set(['*', 'python3 *', 'python3:*']);
108
+ function foldBody(body, home, foldRe) {
109
+ body = body.trim();
110
+ const m = /^(\S+)\s+([\s\S]+)$/.exec(body);
111
+ if (m && INTERPRETER_WORDS.has(m[1])) body = m[2];
112
+ if (body.startsWith('~/')) body = home.replace(/\/+$/, '') + body.slice(1);
113
+ else if (body.startsWith('$HOME/')) body = home.replace(/\/+$/, '') + body.slice('$HOME'.length);
114
+ if (foldRe) {
115
+ const f = foldRe.exec(body);
116
+ if (f) body = body.slice(f.index + f[0].length);
117
+ }
118
+ return body;
119
+ }
120
+
121
+ /** Returns { reach, isPrefix }. */
122
+ function splitMarker(body) {
123
+ if (body === '*') return { reach: '', isPrefix: true };
124
+ if (body.endsWith(':*')) return { reach: body.slice(0, -2), isPrefix: true };
125
+ if (body.length >= 2 && body.endsWith('*') && (body[body.length - 2] === ' ' || body[body.length - 2] === '/')) {
126
+ return { reach: body.slice(0, -2), isPrefix: true };
127
+ }
128
+ return { reach: body, isPrefix: false };
129
+ }
39
130
 
40
- /** A blanket effective allow rule: one whose reach swallows the whole map (AC-DPF-3(a)'s named
41
- * spellings this atom's own controls (g)(h)(i) exercise: `Bash(*)`, `Bash(python3 *)`,
42
- * `Bash(python3:*)`). */
43
- export function isBlanketAllow(rule) {
131
+ /** AC-DPF-3's ask/allow-direction fold. Returns null for a rule that does not participate. */
132
+ export function canonicalize(rule, home = os.homedir(), foldRe = null) {
44
133
  const body = ruleBody(rule);
45
- if (body === null) return false;
46
- const stripped = body.endsWith(':*') ? body.slice(0, -2) : body;
47
- return BLANKET_BODIES.has(body) || stripped === '' || stripped === '*';
134
+ if (body === null) return null;
135
+ const folded = foldBody(body, home, foldRe);
136
+ const { reach, isPrefix } = splitMarker(folded);
137
+ const isBlanket = isPrefix && (reach === '' || INTERPRETER_WORDS.has(reach));
138
+ return { raw: rule, folded, reach, isPrefix, isBlanket, coverageReach: isBlanket ? '' : reach };
139
+ }
140
+
141
+ function coversCanon(effectiveC, mapC) {
142
+ if (effectiveC === null || mapC === null) return false;
143
+ if (effectiveC.isPrefix) return mapC.reach.startsWith(effectiveC.coverageReach);
144
+ return effectiveC.reach === mapC.reach;
145
+ }
146
+
147
+ /** Does `effectiveRule` (the broad rule) cover `mapRule` under the ask/allow fold? */
148
+ export function covers(effectiveRule, mapRule, home = os.homedir(), foldRe = null) {
149
+ return coversCanon(canonicalize(effectiveRule, home, foldRe), canonicalize(mapRule, home, foldRe));
150
+ }
151
+
152
+ /** AC-DPF-3(b), the DENY direction: no interpreter drop, no plugin-cache fold, and coverage
153
+ * requires EXACT reach equality rather than a prefix. A deny is a promise about a specific thing;
154
+ * folding one rule into another would let a broader-looking deny silently stand in for a narrower
155
+ * one the map actually requires. The Node side previously reused the allow-direction `covers` here,
156
+ * which is strictly more permissive than the twin. */
157
+ export function canonicalizeIdentity(rule) {
158
+ const body = ruleBody(rule);
159
+ if (body === null) return null;
160
+ return splitMarker(body.trim());
161
+ }
162
+
163
+ export function denyCovers(effectiveRule, mapRule) {
164
+ const a = canonicalizeIdentity(effectiveRule);
165
+ const b = canonicalizeIdentity(mapRule);
166
+ if (a === null || b === null) return false;
167
+ return a.reach === b.reach;
168
+ }
169
+
170
+ /** A blanket effective allow rule: one whose reach swallows the whole map. Derived from the fold
171
+ * rather than from an enumerated set of spellings — the old hardcoded trio (`Bash(*)`,
172
+ * `Bash(python3 *)`, `Bash(python3:*)`) missed every other interpreter word the twin folds. */
173
+ export function isBlanketAllow(rule, home = os.homedir(), foldRe = null) {
174
+ const c = canonicalize(rule, home, foldRe);
175
+ return c !== null && c.isBlanket;
48
176
  }
49
177
 
50
178
  const SCRIPTS_BASENAME_RE = /scripts\/([A-Za-z0-9_.-]+)/;
@@ -60,6 +188,19 @@ export const DRIFT_CLASSES = Object.freeze([
60
188
  'ask-shadowed-ceremony',
61
189
  'ask-shadowed',
62
190
  'deny-missing',
191
+ // AC-FDC-1. The `ask` tier was the one absence this vocabulary could not name: ask entries were
192
+ // checked ONLY for being shadowed, never for being missing. Measured against a real target
193
+ // a real adopter workspace, the tools reported 46 findings and were silent about 16 more — every
194
+ // ceremony rule gone, with the floor reading clean on that dimension.
195
+ 'ask-absent',
196
+ // AC-FDC-2. Every absence test is tier-scoped: it consults only the effective tier matching the
197
+ // map's declared tier. A map rule the operator deliberately placed in ANOTHER tier was therefore
198
+ // reported absent while plainly present — and a consumer acting on that would add a second copy,
199
+ // leaving one capability declared twice under two tiers. Which of the two governs is a
200
+ // precedence question this tooling deliberately does not model (the shipped
201
+ // doctor-permission-floor-check residual R3), so the double declaration is REPORTED, never
202
+ // resolved.
203
+ 'tier-conflict',
63
204
  'settings-unreadable',
64
205
  'stale-plugin-path',
65
206
  'allow-absent',
@@ -115,8 +256,9 @@ export function renderCapabilityLines(map) {
115
256
  * directories `plugin_root_glob` expanded to on disk (empty => stale-plugin-path). Returns an
116
257
  * array of finding objects `{class, ...}` using exactly the AC-DPF-8 vocabulary — no other class
117
258
  * name is ever emitted. */
118
- export function classifyDrift(map, effective, { pluginRootExpansion = [], unreadableOrigins = [] } = {}) {
259
+ export function classifyDrift(map, effective, { pluginRootExpansion = [], unreadableOrigins = [], home = os.homedir() } = {}) {
119
260
  const findings = [];
261
+ const foldRe = foldRegexFromGlob(map.plugin_root_glob);
120
262
 
121
263
  for (const origin of unreadableOrigins) {
122
264
  findings.push({ class: 'settings-unreadable', origin });
@@ -124,11 +266,14 @@ export function classifyDrift(map, effective, { pluginRootExpansion = [], unread
124
266
 
125
267
  const shadowedByBlanket = new Set();
126
268
  for (const a of effective.allow) {
127
- if (isBlanketAllow(a.rule)) {
269
+ if (isBlanketAllow(a.rule, home, foldRe)) {
128
270
  const swallowed = map.entries.filter((e) => e.tier !== 'allow');
129
271
  findings.push({
130
272
  class: 'blanket-allow',
131
273
  rule: a.rule,
274
+ // the folded reach the blanket decision was actually made on — the same short form the
275
+ // Python twin renders, so the two name this class identically (AC-FDC-6)
276
+ folded: canonicalize(a.rule, home, foldRe).folded,
132
277
  origin: a.origin,
133
278
  swallows: swallowed.map((e) => e.rule),
134
279
  });
@@ -136,9 +281,34 @@ export function classifyDrift(map, effective, { pluginRootExpansion = [], unread
136
281
  }
137
282
  }
138
283
 
284
+ // AC-FDC-2, computed FIRST because it suppresses the absence findings below. A map entry whose
285
+ // exact rule text sits in a tier other than the one the map declares is PRESENT — deliberately
286
+ // placed — so reporting it absent would be false, and would invite a consumer to add a duplicate.
287
+ // Exact text only: `covers()` needs a `:*`-terminated body to match at all, so a cross-tier
288
+ // duplicate of a non-wildcard rule is invisible to the shadowing tests too, which is precisely
289
+ // the gap this class closes.
290
+ const conflicted = new Set();
291
+ for (const entry of map.entries) {
292
+ for (const tierKey of TIERS) {
293
+ if (tierKey === entry.tier) continue;
294
+ const found = effective[tierKey].filter((e) => e.rule === entry.rule);
295
+ if (found.length === 0) continue;
296
+ conflicted.add(entry.rule);
297
+ findings.push({
298
+ class: 'tier-conflict',
299
+ rule: entry.rule,
300
+ declaredTier: entry.tier,
301
+ foundTier: tierKey,
302
+ // AC-FDC-3 — the origin is what lets a consumer separate the tracked settings.json from the
303
+ // untracked settings.local.json, which the effective set unions together.
304
+ origins: found.map((e) => e.origin),
305
+ });
306
+ }
307
+ }
308
+
139
309
  for (const entry of map.entries.filter((e) => e.tier === 'ask')) {
140
310
  if (shadowedByBlanket.has(entry.rule)) continue;
141
- const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule));
311
+ const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule, home, foldRe));
142
312
  if (coveringAllow.length > 0) {
143
313
  findings.push({
144
314
  class: isCeremonyEntry(entry) ? 'ask-shadowed-ceremony' : 'ask-shadowed',
@@ -149,18 +319,36 @@ export function classifyDrift(map, effective, { pluginRootExpansion = [], unread
149
319
  }
150
320
 
151
321
  for (const entry of map.entries.filter((e) => e.tier === 'deny')) {
152
- const coveringDeny = effective.deny.filter((d) => covers(d.rule, entry.rule) || d.rule === entry.rule);
322
+ if (conflicted.has(entry.rule)) continue;
323
+ // denyCovers, not covers: the deny direction refuses the fold and requires exact reach
324
+ // equality (AC-DPF-3(b)). Reusing the allow-direction relation here was strictly more
325
+ // permissive than the Python twin — a broad deny would have stood in for a narrower one.
326
+ const coveringDeny = effective.deny.filter((d) => denyCovers(d.rule, entry.rule));
153
327
  if (coveringDeny.length === 0) {
154
328
  findings.push({ class: 'deny-missing', rule: entry.rule });
155
329
  }
156
330
  }
157
331
 
332
+ // AC-FDC-1 — the exact mirror of deny-missing above, against the `ask` tier. Note what it does
333
+ // NOT consult: effective.allow. An ask entry covered by a broad allow is already named by
334
+ // ask-shadowed, and whether that allow actually defeats the ask at match time is the precedence
335
+ // question this tooling abstains from — so absence and shadowing are reported as the two
336
+ // independent facts they are, rather than one being folded into the other.
337
+ for (const entry of map.entries.filter((e) => e.tier === 'ask')) {
338
+ if (conflicted.has(entry.rule)) continue;
339
+ const coveringAsk = effective.ask.filter((a) => covers(a.rule, entry.rule, home, foldRe));
340
+ if (coveringAsk.length === 0) {
341
+ findings.push({ class: 'ask-absent', rule: entry.rule });
342
+ }
343
+ }
344
+
158
345
  if (pluginRootExpansion.length === 0) {
159
346
  findings.push({ class: 'stale-plugin-path', glob: map.plugin_root_glob });
160
347
  }
161
348
 
162
349
  for (const entry of map.entries.filter((e) => e.tier === 'allow')) {
163
- const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule) || a.rule === entry.rule);
350
+ if (conflicted.has(entry.rule)) continue;
351
+ const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule, home, foldRe));
164
352
  if (coveringAllow.length === 0) {
165
353
  findings.push({ class: 'allow-absent', rule: entry.rule });
166
354
  }
@@ -175,5 +363,18 @@ export function classifyDrift(map, effective, { pluginRootExpansion = [], unread
175
363
  }
176
364
  }
177
365
 
366
+ // AC-FDC-7 — QUALIFIED, not suppressed. `covers()` returns false for a body that does not end
367
+ // `:*`, so under `Bash(*)` every map entry still classifies absent and a consumer could converge
368
+ // all 62 rules and report success over a floor that rule defeats entirely. Suppressing the other
369
+ // findings would hide the gap; marking them names both facts at once — here is the gap, and here
370
+ // is why closing it changes nothing until this rule is narrowed.
371
+ const blanketRules = findings.filter((f) => f.class === 'blanket-allow').map((f) => f.rule);
372
+ if (blanketRules.length > 0) {
373
+ for (const f of findings) {
374
+ if (f.class === 'blanket-allow') continue;
375
+ f.qualifiedBy = [...blanketRules];
376
+ }
377
+ }
378
+
178
379
  return findings;
179
380
  }
package/src/questions.mjs CHANGED
@@ -122,6 +122,21 @@ export const QUESTION_TABLE = [
122
122
  default: false,
123
123
  interactive: false,
124
124
  },
125
+ {
126
+ id: 'reconcileFloor',
127
+ flag: 'reconcile-floor',
128
+ type: 'boolean',
129
+ prompt: 'Add any missing permission-floor rules to this workspace?',
130
+ description:
131
+ 'For an EXISTING workspace whose .claude/settings.json is missing floor\n' +
132
+ 'rules. Adds only the rules the floor declares and this workspace lacks —\n' +
133
+ 'nothing is removed, reordered, or overwritten, and no other setting is\n' +
134
+ 'touched. Running it twice changes nothing the second time.\n' +
135
+ 'Use --dry-run first to see the exact rules it would add.\n' +
136
+ 'Enter alone means no. y, yes, true or 1 mean yes.',
137
+ default: false,
138
+ interactive: false,
139
+ },
125
140
  {
126
141
  id: 'help',
127
142
  flag: 'help',
package/src/run.mjs CHANGED
@@ -14,6 +14,10 @@ import { buildManagedFiles, DECLARED_PATH_SET } from './scaffold.mjs';
14
14
  import { planManagedFiles, applyPlan, exitCodeForPlan } from './reconcile.mjs';
15
15
  import { renderPreview, TRUST_HANDOFF_TEXT } from './preview.mjs';
16
16
  import { validateSlug, resolveIdentity, wireIdentity, plannedMachineScopeWrites } from './identity.mjs';
17
+ import {
18
+ resolveTarget, readTarget, readTrackedRules, planAdditions, applyAdditions,
19
+ writeTargetAtomically, renderPlan,
20
+ } from './floorReconcile.mjs';
17
21
 
18
22
  export { DECLARED_PATH_SET };
19
23
 
@@ -151,6 +155,54 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
151
155
 
152
156
  print(renderPreview({ plan, machineScopeWrites, map }));
153
157
 
158
+ // Drift is classified HERE — before the write phase and before the dry-run return — not after
159
+ // applyPlan where the advisory report used to compute it. Two things depend on the move: the
160
+ // reconcile must know what it would add in order to decide whether to write at all, and
161
+ // --dry-run must be able to report those rules, which it never could before because it returned
162
+ // above the only classifyDrift call in the file.
163
+ const { effective, unreadable } = readEffectiveRules(physicalRoot);
164
+ const pluginRootExpansion = expandPluginRootGlob(map.plugin_root_glob, homeDir);
165
+ const findings = classifyDrift(map, effective, {
166
+ pluginRootExpansion, unreadableOrigins: unreadable, home: homeDir,
167
+ });
168
+
169
+ // The reconcile classifies against the TRACKED settings.json alone. A floor rule carried only
170
+ // in the untracked settings.local.json reads as covered in the union above, so the tracked file
171
+ // would stay incomplete while the report said converged — and the repo would then ship to every
172
+ // other clone and to CI without it.
173
+ let floorPlan = null;
174
+ let floorTarget = null;
175
+ if (answers.reconcileFloor) {
176
+ floorTarget = resolveTarget(physicalRoot);
177
+ if (floorTarget.present) {
178
+ const settingsObj = readTarget(floorTarget.path);
179
+ const trackedFindings = classifyDrift(map, readTrackedRules(settingsObj), {
180
+ pluginRootExpansion, unreadableOrigins: [], home: homeDir,
181
+ });
182
+ floorPlan = planAdditions({ findings: trackedFindings, map, settingsObj, pins });
183
+ floorPlan.settingsObj = settingsObj;
184
+ print('');
185
+ for (const line of renderPlan(floorPlan, { applied: false })) print(line);
186
+ } else {
187
+ // absent settings.json is the CREATE path's business, not this one's — the managed-file
188
+ // plan above already writes the full floor for it, and racing that would duplicate it
189
+ print('');
190
+ print('permission-floor reconcile: .claude/settings.json absent — left to the create path.');
191
+ }
192
+ }
193
+
194
+ // Refuse BEFORE anything is written. isYesMode is true whenever stdin is not a TTY, which also
195
+ // waives the --existing basename ceremony, so a piped invocation would otherwise mutate the
196
+ // permission floor unattended; --yes must be given EXPLICITLY. This sits above applyPlan
197
+ // deliberately — a "refused" verdict printed after the scaffold write had already landed reads
198
+ // as "nothing happened", which is the one thing it must not mean.
199
+ if (floorPlan && floorPlan.total > 0 && !isTTY && answers.yes !== true) {
200
+ throw new RefusalError(
201
+ 'refusing --reconcile-floor without a terminal: pass --yes explicitly to confirm the write',
202
+ 'reconcile-floor',
203
+ );
204
+ }
205
+
154
206
  if (answers.dryRun) {
155
207
  print('(dry-run: no write, no side effect, zero child processes spawned)');
156
208
  return { exitCode: 0, output: lines.join('\n') };
@@ -167,6 +219,12 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
167
219
 
168
220
  applyPlan(plan);
169
221
 
222
+ if (floorPlan && floorPlan.total > 0) {
223
+ writeTargetAtomically(floorTarget.path, applyAdditions(floorPlan.settingsObj, floorPlan, { map, pins }));
224
+ print('');
225
+ for (const line of renderPlan(floorPlan, { applied: true })) print(line);
226
+ }
227
+
170
228
  if (slug) {
171
229
  ensureGitRepo(physicalRoot);
172
230
  const identity = await resolveIdentity(slug, {
@@ -185,9 +243,6 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
185
243
  wireIdentity({ slug, name: identity.name, email: identity.email, targetRoot: physicalRoot, homeDir });
186
244
  }
187
245
 
188
- const { effective, unreadable } = readEffectiveRules(physicalRoot);
189
- const pluginRootExpansion = expandPluginRootGlob(map.plugin_root_glob, homeDir);
190
- const findings = classifyDrift(map, effective, { pluginRootExpansion, unreadableOrigins: unreadable });
191
246
  if (findings.length > 0) {
192
247
  print('');
193
248
  print(`Permission-floor report (advisory, ${findings.length} finding(s)):`);