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 +2 -2
- package/permission-floor.json +1 -1
- package/src/floorReconcile.mjs +233 -0
- package/src/permissionFloor.mjs +225 -24
- package/src/questions.mjs +15 -0
- package/src/run.mjs +58 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agentic-workspace",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
37
|
+
"plugin_version": "1.4.0",
|
|
38
38
|
"pins_researched": "2026-08-02"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/permission-floor.json
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/permissionFloor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
41
|
-
|
|
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
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)):`);
|