create-agentic-workspace 0.17.2 → 0.17.4
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 +3 -2
- package/permission-floor.json +1 -1
- package/retired-artifacts.json +22 -0
- package/src/floorReconcile.mjs +93 -3
- package/src/permissionFloor.mjs +5 -0
- package/src/retiredArtifacts.mjs +193 -0
- package/src/run.mjs +23 -1
- package/src/selfGuardDeny.mjs +75 -0
- package/src/update.mjs +90 -2
- package/src/upgradeReport.mjs +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agentic-workspace",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
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",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"src",
|
|
25
25
|
"templates",
|
|
26
26
|
"permission-floor.json",
|
|
27
|
+
"retired-artifacts.json",
|
|
27
28
|
"package.json",
|
|
28
29
|
"README.md"
|
|
29
30
|
],
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"marketplace_name": "agentic-foundry",
|
|
35
36
|
"marketplace_repo": "lukasrepublic/agentic-foundry",
|
|
36
37
|
"plugin_name": "foundry",
|
|
37
|
-
"plugin_version": "1.17.
|
|
38
|
+
"plugin_version": "1.17.4",
|
|
38
39
|
"pins_researched": "2026-08-02"
|
|
39
40
|
}
|
|
40
41
|
}
|
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.17.
|
|
4
|
+
"generated_for_plugin_version": "1.17.4",
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
7
7
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"_comment": "Workspace-side artifacts an earlier release wrote and no current release reads (hotfix-v1.17.4, ER #236). The updater reports every present entry as [stale] on every run and removes it only under --cleanup. `path` is relative to the workspace root; a single `*` is allowed in the LAST segment only (a basename glob). `kind` is `file` or `dir`; a path of the other kind, or a symlink, is reported and never removed. Nothing outside this list is ever touched; `.claude/skills`, `.claude/agents` and operator files are never candidates.",
|
|
4
|
+
"entries": [
|
|
5
|
+
{ "path": ".foundry/wiring-hash.pin", "kind": "file", "retired_in": "0.24.0", "reason": "the wiring-hash auto-heal and its pin were removed with the live-seam merge gate" },
|
|
6
|
+
{ "path": ".foundry/active-workflow.json", "kind": "file", "retired_in": "0.5.0", "reason": "the macro-workflow engine's active-workflow pointer; the engine was retired" },
|
|
7
|
+
{ "path": ".foundry/context-snapshots", "kind": "dir", "retired_in": "0.25.0", "reason": "the bespoke context-lifecycle snapshots; /foundry:context wraps native /compact and --resume now" },
|
|
8
|
+
{ "path": ".foundry/branch-protection-unenforced.json", "kind": "file", "retired_in": "0.24.0", "reason": "branch-protection-as-code's marker; Tier A is applied by hand and observed, never written from here" },
|
|
9
|
+
{ "path": ".foundry/hasher-ref", "kind": "file", "retired_in": "0.24.0", "reason": "the retired hasher reference; no current script reads it" },
|
|
10
|
+
{ "path": ".claude/settings.json.bak", "kind": "file", "retired_in": "1.7.0", "reason": "a backup an early settings migration left behind; the updater writes atomically and keeps no .bak" },
|
|
11
|
+
{ "path": ".claude/dispatch-queue", "kind": "dir", "retired_in": "1.10.0", "reason": "the retired dispatch-queue's old location; the multi-repo worktree manifest lives under .foundry/dispatch-queue" },
|
|
12
|
+
{ "path": ".claude/dispatcher-mode.json", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's mode file" },
|
|
13
|
+
{ "path": ".claude/dispatcher-mode.json.example", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's example" },
|
|
14
|
+
{ "path": ".claude/dispatcher-mode.json.lock", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's lock" },
|
|
15
|
+
{ "path": ".claude/dispatch-log.jsonl.example", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's example log" },
|
|
16
|
+
{ "path": ".claude/context-state", "kind": "dir", "retired_in": "0.25.0", "reason": "the bespoke context-state store; native session state replaced it" },
|
|
17
|
+
{ "path": ".claude/resume", "kind": "dir", "retired_in": "0.25.0", "reason": "the bespoke resume store; native --resume replaced it" },
|
|
18
|
+
{ "path": ".claude/logs", "kind": "dir", "retired_in": "1.10.0", "reason": "the retired dispatcher's logs" },
|
|
19
|
+
{ "path": ".claude/session-learnings", "kind": "dir", "retired_in": "1.0.0", "reason": "the old learnings location; the partition is .foundry/session-learnings" },
|
|
20
|
+
{ "path": ".claude/hooks/*-exec-guard.sh", "kind": "file", "exclude_prefix": "foundry-", "retired_in": "1.8.0", "reason": "the retired session-context framework's cloud-cli exec guard; a hook still named by a command in .claude/settings*.json is refused, never removed" }
|
|
21
|
+
]
|
|
22
|
+
}
|
package/src/floorReconcile.mjs
CHANGED
|
@@ -213,6 +213,45 @@ export function parseFloorRootShape(rule, pluginRootGlob) {
|
|
|
213
213
|
return { name: m[1], sub: m[2] ?? null };
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/** The floor's root-glob shape with every `*` segment of the glob allowed to be a CONCRETE
|
|
217
|
+
* segment instead (`cache/agentic-foundry/foundry/1.9.1/scripts/<name>[ <sub>]:*`) — the shape an
|
|
218
|
+
* init before installer-unpinning (v1.7.0) wrote, with the marketplace directory and the plugin
|
|
219
|
+
* version spelled out. hotfix-v1.17.3: such rows are stale by construction (the floor has written
|
|
220
|
+
* only version-wildcarded rows since; the wildcard row the reconcile adds in the same pass covers
|
|
221
|
+
* the script), and because they never matched the exact-shape regex above, nothing ever retired
|
|
222
|
+
* them — an adopter carried `1.9.1` allow rows across eight releases. `*` in the glob becomes
|
|
223
|
+
* `([^/]+)`; a row whose captured segments are ALL literal `*` is the exact shape (handled above),
|
|
224
|
+
* so this parser reports `pinned: true` only when at least one segment is concrete. */
|
|
225
|
+
function floorPinnedShapeRe(pluginRootGlob) {
|
|
226
|
+
// PR #233 security review Risk 2: every `*` but the last (the marketplace directory) may be a
|
|
227
|
+
// plain name (no dots — so `..` and an operator's partial glob like `1.*` never qualify) or the
|
|
228
|
+
// literal `*`; the LAST `*` (the plugin version) may be a semver-shaped segment or `*`. A rule
|
|
229
|
+
// from a foreign marketplace still matches by shape (the name is not the floor's to know here),
|
|
230
|
+
// but only for the tiers and conditions planRetirements allows.
|
|
231
|
+
const stars = (pluginRootGlob.match(/\*/g) || []).length;
|
|
232
|
+
let seen = 0;
|
|
233
|
+
const src = escapeLiteral(pluginRootGlob).replace(/\\\*/g, () => {
|
|
234
|
+
seen += 1;
|
|
235
|
+
return seen === stars ? '(\\*|\\d+\\.\\d+\\.\\d+[A-Za-z0-9.+-]*)' : '(\\*|[A-Za-z0-9_-]+)';
|
|
236
|
+
});
|
|
237
|
+
return new RegExp(`^Bash\\(${src}/scripts/(${ROOT_SHAPE_NAME_RE})(?: (.+))?:\\*\\)$`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Parse `rule` as a version-/marketplace-PINNED variant of the floor's own row shape. Returns
|
|
241
|
+
* `{ name, sub, pinned: true }` when at least one glob segment is concrete in the row, `null` for
|
|
242
|
+
* the exact wildcard shape (parseFloorRootShape's business) and for every other shape. Exported for
|
|
243
|
+
* the fixture tests. */
|
|
244
|
+
export function parseFloorPinnedShape(rule, pluginRootGlob) {
|
|
245
|
+
if (typeof pluginRootGlob !== 'string' || pluginRootGlob === '') return null;
|
|
246
|
+
const stars = (pluginRootGlob.match(/\*/g) || []).length;
|
|
247
|
+
if (stars === 0) return null;
|
|
248
|
+
const m = floorPinnedShapeRe(pluginRootGlob).exec(rule);
|
|
249
|
+
if (!m) return null;
|
|
250
|
+
const segs = m.slice(1, 1 + stars);
|
|
251
|
+
if (segs.every((x) => x === '*')) return null;
|
|
252
|
+
return { name: m[1 + stars], sub: m[2 + stars] ?? null, pinned: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
216
255
|
/** A collision-free key for the `(name, sub)` pair — `JSON.stringify` of a 2-tuple rather than a
|
|
217
256
|
* string concatenation with a hand-picked separator, which a `sub` containing that exact separator
|
|
218
257
|
* (an unlikely but not-impossible flag value) could otherwise fold into a DIFFERENT pair's key. */
|
|
@@ -233,21 +272,61 @@ function shippedRootNames(map) {
|
|
|
233
272
|
return set;
|
|
234
273
|
}
|
|
235
274
|
|
|
275
|
+
/** The `(name, sub)` keys of every wildcard-shaped `ask` row a settings object carries — what
|
|
276
|
+
* `planRetirements({ askCoveredBy })` takes for the local file (hotfix-v1.17.4). */
|
|
277
|
+
export function askRootKeys(settingsObj, map) {
|
|
278
|
+
const keys = new Set();
|
|
279
|
+
const perms = (settingsObj && settingsObj.permissions) || {};
|
|
280
|
+
for (const rule of perms.ask || []) {
|
|
281
|
+
const parsed = typeof rule === 'string' ? parseFloorRootShape(rule, map.plugin_root_glob) : null;
|
|
282
|
+
if (parsed) keys.add(rootNameKey(parsed));
|
|
283
|
+
}
|
|
284
|
+
return keys;
|
|
285
|
+
}
|
|
286
|
+
|
|
236
287
|
/** Compute the rows the reconcile SHALL remove (AC-FRR-1), WITHOUT touching the filesystem. Every
|
|
237
288
|
* `allow`/`ask` row shaped exactly like the floor's own root-glob rows, whose `(name, sub)` pair the
|
|
238
289
|
* shipped map no longer declares, is queued for removal. `deny` is never a candidate (AC-FRR-2) —
|
|
239
290
|
* retirement only narrows a grant or a prompt, and a deny row read back as "extra" is, if anything,
|
|
240
291
|
* a reason to leave it exactly where the operator (or an earlier release) put it. Returns
|
|
241
292
|
* `{ retirements: { allow: [...], ask: [...] }, total }`. */
|
|
242
|
-
export function planRetirements({ settingsObj, map }) {
|
|
293
|
+
export function planRetirements({ settingsObj, map, askCoveredBy = null }) {
|
|
243
294
|
const shipped = shippedRootNames(map);
|
|
295
|
+
const shippedAsk = new Set();
|
|
296
|
+
for (const e of map.entries) {
|
|
297
|
+
if (e.tier !== 'ask') continue;
|
|
298
|
+
const parsed = parseFloorRootShape(e.rule, map.plugin_root_glob);
|
|
299
|
+
if (parsed) shippedAsk.add(rootNameKey(parsed));
|
|
300
|
+
}
|
|
301
|
+
// hotfix-v1.17.4 (PR #237 security review Risk 3): for a file this pass does NOT also reconcile
|
|
302
|
+
// (`.claude/settings.local.json`), "the wildcard ask row replaces it" is only true when the
|
|
303
|
+
// TRACKED file actually carries that wildcard row — so the caller passes the tracked file's ask
|
|
304
|
+
// keys (`askRootKeys`) and a pinned `ask` row is retired only when both sets hold the pair.
|
|
305
|
+
// `null` (the tracked-file call sites, where the same pass adds the wildcard row) keeps the
|
|
306
|
+
// v1.17.3 rule unchanged.
|
|
307
|
+
const askReplaced = (key) => shippedAsk.has(key) && (askCoveredBy === null || askCoveredBy.has(key));
|
|
244
308
|
const retirements = { allow: [], ask: [] };
|
|
245
309
|
const perms = (settingsObj && settingsObj.permissions) || {};
|
|
246
310
|
for (const tier of ['allow', 'ask']) {
|
|
247
311
|
for (const rule of perms[tier] || []) {
|
|
248
312
|
const parsed = parseFloorRootShape(rule, map.plugin_root_glob);
|
|
249
|
-
if (
|
|
250
|
-
|
|
313
|
+
if (parsed) {
|
|
314
|
+
if (!shipped.has(rootNameKey(parsed))) retirements[tier].push(rule);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// hotfix-v1.17.3: a version-/marketplace-pinned variant of the floor's own shape. An `allow`
|
|
318
|
+
// row is retired whether or not the script still ships — the wildcard row covers a shipped
|
|
319
|
+
// script (added in this same pass when absent), and a pinned row for a gone script is exactly
|
|
320
|
+
// the ER #199 class. An `ask` row is retired ONLY when the shipped map declares the same
|
|
321
|
+
// (name, sub) at `ask`, so the wildcard `ask` row replaces it (PR #233 security review Risk 1:
|
|
322
|
+
// `ask` beats `allow`, so dropping an ask row under a broader allow would turn a prompt into a
|
|
323
|
+
// silent grant — a widening this pass must never perform).
|
|
324
|
+
const pinned = parseFloorPinnedShape(rule, map.plugin_root_glob);
|
|
325
|
+
if (pinned) {
|
|
326
|
+
if (tier === 'allow') retirements[tier].push(rule);
|
|
327
|
+
else if (askReplaced(rootNameKey(pinned))) retirements[tier].push(rule);
|
|
328
|
+
}
|
|
329
|
+
// any other shape -> never touched (AC-FRR-2)
|
|
251
330
|
}
|
|
252
331
|
}
|
|
253
332
|
const total = retirements.allow.length + retirements.ask.length;
|
|
@@ -378,8 +457,19 @@ export function writeTargetAtomically(targetPath, obj) {
|
|
|
378
457
|
const dir = path.dirname(targetPath);
|
|
379
458
|
const tmp = path.join(dir, `.settings.json.${process.pid}.tmp`);
|
|
380
459
|
const bytes = Buffer.from(`${JSON.stringify(obj, null, 2)}\n`, 'utf-8');
|
|
460
|
+
// hotfix-v1.17.4 (PR #237 security review Risk 5): a rename-install would otherwise reset the
|
|
461
|
+
// target's mode to the umask default — an operator's 0600 `settings.local.json` (it often holds
|
|
462
|
+
// `env`) must come back 0600. The original's permission bits are copied onto the temp file
|
|
463
|
+
// before the rename; a target that does not exist yet keeps the default.
|
|
464
|
+
let mode = null;
|
|
465
|
+
try {
|
|
466
|
+
mode = fs.statSync(targetPath).mode & 0o777;
|
|
467
|
+
} catch {
|
|
468
|
+
mode = null;
|
|
469
|
+
}
|
|
381
470
|
const fd = fs.openSync(tmp, 'wx');
|
|
382
471
|
try {
|
|
472
|
+
if (mode !== null) fs.fchmodSync(fd, mode);
|
|
383
473
|
fs.writeFileSync(fd, bytes);
|
|
384
474
|
fs.fsyncSync(fd);
|
|
385
475
|
} finally {
|
package/src/permissionFloor.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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 { SELF_GUARD_DENY } from './selfGuardDeny.mjs';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
|
|
9
10
|
/** The one map schema_version this build understands. A map declaring anything else is refused
|
|
@@ -214,6 +215,10 @@ export function buildSettings(map, pins) {
|
|
|
214
215
|
for (const e of map.entries) {
|
|
215
216
|
byTier[e.tier].push(e.rule);
|
|
216
217
|
}
|
|
218
|
+
// hotfix-v1.17.3: the policy file's own self-guard pair rides with the floor on the create path
|
|
219
|
+
// (the scaffold seeds .foundry/permissions.yaml in the same run), so a fresh workspace is in-sync
|
|
220
|
+
// instead of `policy drift (2)` until someone runs the compiler.
|
|
221
|
+
for (const r of SELF_GUARD_DENY) if (!byTier.deny.includes(r)) byTier.deny.push(r);
|
|
217
222
|
// THE PINNED LITERAL — SUPERSEDED (feat-foundry-installer-unpinning, AC-IUP-3). This block used
|
|
218
223
|
// to read (AC-BCL-4(b), contract v1.2 — PR #61 security review Block 1), verbatim:
|
|
219
224
|
//
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// retiredArtifacts.mjs — the sweep for workspace-side files an earlier release wrote and no current
|
|
2
|
+
// release reads (hotfix-v1.17.4, ER #236). Driven by the shipped catalogue `cli/retired-artifacts.json`:
|
|
3
|
+
// every present entry is reported `[stale]` on every run; removal happens only under `--cleanup`,
|
|
4
|
+
// only for catalogued paths, only when the path is exactly the catalogued kind (a regular file, or a
|
|
5
|
+
// real directory) — a symlink or the other kind is reported `[refused]` and left alone, and so is a
|
|
6
|
+
// hook under `.claude/hooks/` that a hook command in `.claude/settings*.json` still names. Nothing
|
|
7
|
+
// outside the catalogue is ever a candidate: `.claude/skills`, `.claude/agents` and every operator
|
|
8
|
+
// file are invisible to this module by construction.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { confinedJoin } from './util.mjs';
|
|
12
|
+
|
|
13
|
+
export const CATALOGUE_REL = 'retired-artifacts.json';
|
|
14
|
+
|
|
15
|
+
/** Load and validate the catalogue shipped beside `src/` (the package root). Refuses a malformed
|
|
16
|
+
* entry loudly rather than sweeping with a half-read list. */
|
|
17
|
+
export function loadRetiredCatalogue(pkgDir) {
|
|
18
|
+
const doc = JSON.parse(fs.readFileSync(path.join(pkgDir, CATALOGUE_REL), 'utf-8'));
|
|
19
|
+
if (!doc || doc.schema_version !== 1 || !Array.isArray(doc.entries)) {
|
|
20
|
+
throw new Error('retired-artifacts.json: unrecognised schema');
|
|
21
|
+
}
|
|
22
|
+
for (const e of doc.entries) {
|
|
23
|
+
if (typeof e.path !== 'string' || path.isAbsolute(e.path) || e.path.split(/[\\/]+/).includes('..')) {
|
|
24
|
+
throw new Error(`retired-artifacts.json: bad path ${JSON.stringify(e.path)}`);
|
|
25
|
+
}
|
|
26
|
+
if (e.kind !== 'file' && e.kind !== 'dir') throw new Error(`retired-artifacts.json: bad kind for ${e.path}`);
|
|
27
|
+
const star = e.path.indexOf('*');
|
|
28
|
+
if (star !== -1 && (e.path.lastIndexOf('*') !== star || e.path.slice(star).includes('/'))) {
|
|
29
|
+
throw new Error(`retired-artifacts.json: only one '*' in the last segment is allowed (${e.path})`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return doc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Expand an entry to concrete relative paths present on disk: the path itself, or, for a
|
|
36
|
+
* basename glob, every directory entry that matches (names starting with `exclude_prefix` skipped). */
|
|
37
|
+
function candidates(physicalRoot, entry) {
|
|
38
|
+
if (!entry.path.includes('*')) return [entry.path];
|
|
39
|
+
const dirRel = path.posix.dirname(entry.path);
|
|
40
|
+
const pattern = path.posix.basename(entry.path);
|
|
41
|
+
const [pre, post] = pattern.split('*');
|
|
42
|
+
const dirAbs = confinedJoin(physicalRoot, dirRel);
|
|
43
|
+
if (!dirAbs) return [];
|
|
44
|
+
let names;
|
|
45
|
+
try {
|
|
46
|
+
names = fs.readdirSync(dirAbs);
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
return names
|
|
51
|
+
.filter((n) => n.startsWith(pre) && n.endsWith(post) && n.length >= pre.length + post.length)
|
|
52
|
+
.filter((n) => !(entry.exclude_prefix && n.startsWith(entry.exclude_prefix)))
|
|
53
|
+
.map((n) => path.posix.join(dirRel, n));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HOOKS_DIR_REL = '.claude/hooks/';
|
|
57
|
+
const SETTINGS_FILES = ['.claude/settings.json', '.claude/settings.local.json'];
|
|
58
|
+
|
|
59
|
+
/** Every `command` string under `hooks` in the two workspace settings files, joined — the text a
|
|
60
|
+
* hook candidate's basename is checked against (PR #237 security review Risk 1: a basename glob
|
|
61
|
+
* must never take a hook the operator still wires). `unreadable` is true when a settings file
|
|
62
|
+
* exists but does not parse: then nothing under `.claude/hooks/` is planned removable (fail-closed). */
|
|
63
|
+
function hookCommandText(physicalRoot) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
let unreadable = false;
|
|
66
|
+
for (const rel of SETTINGS_FILES) {
|
|
67
|
+
const abs = confinedJoin(physicalRoot, rel);
|
|
68
|
+
if (!abs) continue;
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.lstatSync(abs).isFile()) continue;
|
|
72
|
+
raw = fs.readFileSync(abs, 'utf-8');
|
|
73
|
+
} catch {
|
|
74
|
+
continue; // absent: nothing wired there
|
|
75
|
+
}
|
|
76
|
+
let obj;
|
|
77
|
+
try {
|
|
78
|
+
obj = JSON.parse(raw);
|
|
79
|
+
} catch {
|
|
80
|
+
unreadable = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const walk = (v) => {
|
|
84
|
+
if (Array.isArray(v)) { for (const x of v) walk(x); return; }
|
|
85
|
+
if (v && typeof v === 'object') {
|
|
86
|
+
if (typeof v.command === 'string') parts.push(v.command);
|
|
87
|
+
for (const x of Object.values(v)) walk(x);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
if (obj && typeof obj === 'object' && obj.hooks) walk(obj.hooks);
|
|
91
|
+
}
|
|
92
|
+
return { text: parts.join('\n'), unreadable };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Plan: `{ rows: [{ relPath, kind, retired_in, reason, state, why?, entries? }], present, refused }`
|
|
96
|
+
* where `state` is `stale` (present, of the catalogued kind, and not wired) or `refused` (present
|
|
97
|
+
* but a symlink, the other kind, escaping the root, or — for a hook — still named by a hook command
|
|
98
|
+
* in `.claude/settings*.json`, or those files unreadable); absent paths are omitted. A `dir` row
|
|
99
|
+
* carries `entries` (its direct entry count) so the operator sees what a removal takes. Pure over
|
|
100
|
+
* the filesystem — nothing is written. */
|
|
101
|
+
export function planRetiredArtifacts({ physicalRoot, catalogue }) {
|
|
102
|
+
const rows = [];
|
|
103
|
+
let hooks = null; // read lazily, once, only when a hook candidate is present
|
|
104
|
+
for (const entry of catalogue.entries) {
|
|
105
|
+
for (const relPath of candidates(physicalRoot, entry)) {
|
|
106
|
+
const abs = confinedJoin(physicalRoot, relPath);
|
|
107
|
+
let st = null;
|
|
108
|
+
if (abs) {
|
|
109
|
+
try {
|
|
110
|
+
st = fs.lstatSync(abs);
|
|
111
|
+
} catch {
|
|
112
|
+
continue; // absent: nothing to say
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// the path resolves OUTSIDE the workspace (a symlink escaping the root): present, reported,
|
|
116
|
+
// never touched
|
|
117
|
+
try {
|
|
118
|
+
st = fs.lstatSync(path.join(physicalRoot, relPath));
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const kindOk = abs && !st.isSymbolicLink() && (entry.kind === 'dir' ? st.isDirectory() : st.isFile());
|
|
124
|
+
const row = { relPath, kind: entry.kind, retired_in: entry.retired_in, reason: entry.reason, state: kindOk ? 'stale' : 'refused', why: kindOk ? null : 'kind' };
|
|
125
|
+
if (row.state === 'stale' && relPath.startsWith(HOOKS_DIR_REL)) {
|
|
126
|
+
if (hooks === null) hooks = hookCommandText(physicalRoot);
|
|
127
|
+
if (hooks.unreadable) { row.state = 'refused'; row.why = 'settings-unreadable'; }
|
|
128
|
+
else if (hooks.text.includes(path.posix.basename(relPath))) { row.state = 'refused'; row.why = 'referenced'; }
|
|
129
|
+
}
|
|
130
|
+
if (row.state === 'stale' && entry.kind === 'dir') {
|
|
131
|
+
try { row.entries = fs.readdirSync(abs).length; } catch { row.entries = null; }
|
|
132
|
+
}
|
|
133
|
+
rows.push(row);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
rows,
|
|
138
|
+
present: rows.filter((r) => r.state === 'stale').length,
|
|
139
|
+
refused: rows.filter((r) => r.state === 'refused').length,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Remove every `stale` row (re-checked with lstat immediately before each removal). Returns the
|
|
144
|
+
* count removed. A row whose state changed between plan and apply is skipped, never forced. */
|
|
145
|
+
export function applyRetiredArtifacts(plan, physicalRoot) {
|
|
146
|
+
let removed = 0;
|
|
147
|
+
for (const row of plan.rows) {
|
|
148
|
+
if (row.state !== 'stale') continue;
|
|
149
|
+
const abs = confinedJoin(physicalRoot, row.relPath);
|
|
150
|
+
if (!abs) continue;
|
|
151
|
+
let st;
|
|
152
|
+
try {
|
|
153
|
+
st = fs.lstatSync(abs);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (st.isSymbolicLink()) continue;
|
|
158
|
+
if (row.kind === 'dir' ? !st.isDirectory() : !st.isFile()) continue;
|
|
159
|
+
try {
|
|
160
|
+
if (row.kind === 'dir') fs.rmSync(abs, { recursive: true, force: false });
|
|
161
|
+
else fs.unlinkSync(abs);
|
|
162
|
+
removed += 1;
|
|
163
|
+
row.state = 'removed';
|
|
164
|
+
} catch {
|
|
165
|
+
row.state = 'refused';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
plan.removed = removed;
|
|
169
|
+
return removed;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The rows every writer prints. `cleanup` false → `[stale]` (nothing removed); true → `[removed]`
|
|
173
|
+
* for what went, `[refused]` for what did not. Empty when nothing catalogued is present. */
|
|
174
|
+
export function renderRetiredArtifactRows(plan, { cleanup }) {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const r of plan.rows) {
|
|
177
|
+
const size = r.kind === 'dir' && typeof r.entries === 'number' ? ` [${r.entries} entr${r.entries === 1 ? 'y' : 'ies'}]` : '';
|
|
178
|
+
if (r.state === 'stale') {
|
|
179
|
+
out.push(cleanup
|
|
180
|
+
? ` [stale] ${r.relPath}${size} — retired in v${r.retired_in} (${r.reason}) — NOT removed`
|
|
181
|
+
: ` [stale] ${r.relPath}${size} — retired in v${r.retired_in} (${r.reason}); remove with --cleanup`);
|
|
182
|
+
} else if (r.state === 'removed') {
|
|
183
|
+
out.push(` [removed] ${r.relPath}${size} — retired in v${r.retired_in}`);
|
|
184
|
+
} else if (r.why === 'referenced') {
|
|
185
|
+
out.push(` [refused] ${r.relPath} — still named by a hook command in .claude/settings*.json — left alone`);
|
|
186
|
+
} else if (r.why === 'settings-unreadable') {
|
|
187
|
+
out.push(` [refused] ${r.relPath} — .claude/settings*.json does not parse, so its hook wiring is unknown — left alone`);
|
|
188
|
+
} else {
|
|
189
|
+
out.push(` [refused] ${r.relPath} — present but not a regular ${r.kind} (a link, or the other kind) — left alone`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
package/src/run.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
|
|
21
21
|
import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
|
|
22
22
|
import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows } from './statuslineWiring.mjs';
|
|
23
|
+
import { policyPresent, missingSelfGuardDeny, applySelfGuardDeny, renderSelfGuardRow, selfGuardShapeOk } from './selfGuardDeny.mjs';
|
|
23
24
|
|
|
24
25
|
export { DECLARED_PATH_SET };
|
|
25
26
|
|
|
@@ -253,8 +254,18 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
253
254
|
// permission floor unattended; --yes must be given EXPLICITLY. This sits above applyPlan
|
|
254
255
|
// deliberately — a "refused" verdict printed after the scaffold write had already landed reads
|
|
255
256
|
// as "nothing happened", which is the one thing it must not mean.
|
|
257
|
+
// hotfix-v1.17.3 (PR #233 review Risk 4): the self-guard pair is previewed here and counts as
|
|
258
|
+
// floor work, so a piped run cannot write it without an explicit --yes and --dry-run names it.
|
|
259
|
+
let selfGuardPreview = { missing: [], shapeOk: true };
|
|
260
|
+
if (answers.reconcileFloor && floorTarget && floorTarget.present && policyPresent(physicalRoot)) {
|
|
261
|
+
const cur0 = readTarget(floorTarget.path);
|
|
262
|
+
selfGuardPreview = { missing: missingSelfGuardDeny(cur0), shapeOk: selfGuardShapeOk(cur0) };
|
|
263
|
+
const prow = renderSelfGuardRow(physicalRoot, selfGuardPreview.missing.length, { shapeOk: selfGuardPreview.shapeOk, applied: false });
|
|
264
|
+
if (prow) print(prow);
|
|
265
|
+
}
|
|
256
266
|
const floorHasWork = Boolean(floorPlan)
|
|
257
|
-
&& (floorPlan.total > 0 || (floorRetirementPlan && floorRetirementPlan.total > 0)
|
|
267
|
+
&& (floorPlan.total > 0 || (floorRetirementPlan && floorRetirementPlan.total > 0)
|
|
268
|
+
|| selfGuardPreview.missing.length > 0);
|
|
258
269
|
if (floorHasWork && !isTTY && answers.yes !== true) {
|
|
259
270
|
throw new RefusalError(
|
|
260
271
|
'refusing --reconcile-floor without a terminal: pass --yes explicitly to confirm the write',
|
|
@@ -297,6 +308,17 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
297
308
|
applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
|
|
298
309
|
})) print(line);
|
|
299
310
|
}
|
|
311
|
+
// hotfix-v1.17.3 (ER #232): under --reconcile-floor (the one opt-in that permits a narrow
|
|
312
|
+
// settings write on --existing), converge the policy file's self-guard deny pair, fresh from
|
|
313
|
+
// disk after the floor write, whenever a policy file exists.
|
|
314
|
+
if (answers.reconcileFloor && floorTarget && floorTarget.present && policyPresent(physicalRoot)) {
|
|
315
|
+
const cur = readTarget(floorTarget.path);
|
|
316
|
+
const shapeOk = selfGuardShapeOk(cur);
|
|
317
|
+
const missing = missingSelfGuardDeny(cur);
|
|
318
|
+
if (shapeOk && missing.length > 0) writeTargetAtomically(floorTarget.path, applySelfGuardDeny(cur));
|
|
319
|
+
const row = renderSelfGuardRow(physicalRoot, missing.length, { shapeOk });
|
|
320
|
+
if (row) print(row);
|
|
321
|
+
}
|
|
300
322
|
// AFTER the floor write above: that write serialises a settings object read before this
|
|
301
323
|
// point, so wiring the statusLine keys first would have been overwritten by it. The wiring
|
|
302
324
|
// re-reads settings.json itself and adds only the absent keys (AC-SLW-2).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// selfGuardDeny.mjs — the two framework-owned deny rules that protect the operator's policy file
|
|
2
|
+
// (hotfix-v1.17.3, ER #232): `Edit(.foundry/permissions.yaml)` and `Write(.foundry/permissions.yaml)`.
|
|
3
|
+
//
|
|
4
|
+
// They are what `scripts/foundry-permissions-compile.py --write` derives for a policy with ZERO grants
|
|
5
|
+
// (its SELF_GUARD pair) — no operator judgement is in them, so every writer that seeds or keeps the
|
|
6
|
+
// policy file converges them too, and a fresh seed is in-sync instead of `policy drift (2)` until
|
|
7
|
+
// someone remembers the compiler. Grants stay the compiler's (operator-run) business.
|
|
8
|
+
//
|
|
9
|
+
// Discipline: add-if-absent onto `permissions.deny`, never reorder or remove anything, never touch
|
|
10
|
+
// another key; a settings object without a `permissions` block gains one with only `deny`.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const POLICY_REL = '.foundry/permissions.yaml';
|
|
15
|
+
export const SELF_GUARD_DENY = Object.freeze([
|
|
16
|
+
`Edit(${POLICY_REL})`,
|
|
17
|
+
`Write(${POLICY_REL})`,
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
21
|
+
|
|
22
|
+
/** `true` when `settingsObj.permissions` is absent or a plain object whose `deny`, if present, is an
|
|
23
|
+
* array — the only shapes this module will write into. Anything else is refused rather than
|
|
24
|
+
* guessed (PR #233 security review Risk 3: a string `deny` would otherwise be spread into
|
|
25
|
+
* characters and written back). */
|
|
26
|
+
export function selfGuardShapeOk(settingsObj) {
|
|
27
|
+
if (!isPlainObject(settingsObj)) return false;
|
|
28
|
+
const perms = settingsObj.permissions;
|
|
29
|
+
if (perms === undefined) return true;
|
|
30
|
+
if (!isPlainObject(perms)) return false;
|
|
31
|
+
return perms.deny === undefined || Array.isArray(perms.deny);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Which of the two rules `settingsObj` still lacks. Pure. Returns [] for a shape this module will
|
|
35
|
+
* not write into (see selfGuardShapeOk) — the caller reports that separately. */
|
|
36
|
+
export function missingSelfGuardDeny(settingsObj) {
|
|
37
|
+
if (!selfGuardShapeOk(settingsObj)) return [];
|
|
38
|
+
const deny = (settingsObj.permissions && settingsObj.permissions.deny) || [];
|
|
39
|
+
return SELF_GUARD_DENY.filter((r) => !deny.includes(r));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A NEW settings object with the missing rules appended to `permissions.deny`. Pure; returns the
|
|
43
|
+
* input unchanged for a shape it will not write into. */
|
|
44
|
+
export function applySelfGuardDeny(settingsObj) {
|
|
45
|
+
if (!selfGuardShapeOk(settingsObj)) return settingsObj;
|
|
46
|
+
const missing = missingSelfGuardDeny(settingsObj);
|
|
47
|
+
if (missing.length === 0) return settingsObj;
|
|
48
|
+
const next = { ...settingsObj };
|
|
49
|
+
next.permissions = { ...(next.permissions || {}) };
|
|
50
|
+
next.permissions.deny = [...(next.permissions.deny || []), ...missing];
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `true` when the policy file exists as a regular file under `physicalRoot` (seeded or kept) — the
|
|
55
|
+
* rules guard a file, so they are written only when there is one. Never follows a symlink. */
|
|
56
|
+
export function policyPresent(physicalRoot) {
|
|
57
|
+
try {
|
|
58
|
+
return fs.lstatSync(path.join(physicalRoot, POLICY_REL)).isFile();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The one row every writer prints: `[permissions] self-guard deny rules added (N)` or
|
|
65
|
+
* `already present`. `null` when there is no policy file to guard (nothing to say). */
|
|
66
|
+
export function renderSelfGuardRow(physicalRoot, addedCount, { shapeOk = true, applied = true } = {}) {
|
|
67
|
+
if (!policyPresent(physicalRoot)) return null;
|
|
68
|
+
if (!shapeOk) return ' [permissions] settings.permissions is not the shape expected (object with an array deny) — self-guard deny rules NOT written';
|
|
69
|
+
if (addedCount > 0) {
|
|
70
|
+
return applied
|
|
71
|
+
? ` [permissions] self-guard deny rules added (${addedCount}): Edit/Write on ${POLICY_REL}`
|
|
72
|
+
: ` [permissions] would add self-guard deny rules (${addedCount}): Edit/Write on ${POLICY_REL}`;
|
|
73
|
+
}
|
|
74
|
+
return ' [permissions] self-guard deny rules already present';
|
|
75
|
+
}
|
package/src/update.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// machinery run.mjs's create path already uses (cli/src/reconcile.mjs, cli/src/floorReconcile.mjs).
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { RefusalError, physicalResolve } from './util.mjs';
|
|
10
|
+
import { RefusalError, physicalResolve, confinedJoin } from './util.mjs';
|
|
11
11
|
import { loadMap, buildSettings } from './permissionFloor.mjs';
|
|
12
12
|
import { buildManagedFiles } from './scaffold.mjs';
|
|
13
13
|
import { planManagedFiles, applyPlan } from './reconcile.mjs';
|
|
@@ -18,6 +18,9 @@ import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from '
|
|
|
18
18
|
import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
|
|
19
19
|
import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, versionOrNull, NEXT_LINE } from './upgradeReport.mjs';
|
|
20
20
|
import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows, statuslineChanged } from './statuslineWiring.mjs';
|
|
21
|
+
import { policyPresent, missingSelfGuardDeny, applySelfGuardDeny, renderSelfGuardRow, selfGuardShapeOk } from './selfGuardDeny.mjs';
|
|
22
|
+
import { loadRetiredCatalogue, planRetiredArtifacts, applyRetiredArtifacts, renderRetiredArtifactRows } from './retiredArtifacts.mjs';
|
|
23
|
+
import { planRetirements, applyRetirements, askRootKeys } from './floorReconcile.mjs';
|
|
21
24
|
import {
|
|
22
25
|
ALLOWED_CLAUDE_SUBCOMMANDS, resolveClaudeOnPath, runClaude,
|
|
23
26
|
defaultScopes, snapshotScopes, classifyMigration, migrationActions, migrateScope,
|
|
@@ -47,6 +50,29 @@ export function parseUpdateArgv(argv) {
|
|
|
47
50
|
* itself defines (marketplace-refresh, plugin-update, reinitialization); the full orchestrator
|
|
48
51
|
* below may print a fourth `cleanup` row once the sibling atom's phase is wired in, which is a
|
|
49
52
|
* property of the RUNTIME composition, not of this formatter's own contract. */
|
|
53
|
+
/** hotfix-v1.17.4 (ER #236): the retirement-only plan over `.claude/settings.local.json`, which the
|
|
54
|
+
* tracked-file reconcile never reads. Read-only; `{ rel, abs, plan, error }` — `plan` null when the
|
|
55
|
+
* file is absent, not a regular file, or resolves outside the root; `error` set when it exists but
|
|
56
|
+
* does not parse (reported, never guessed). A pinned `ask` row is retired only when the TRACKED file
|
|
57
|
+
* carries the replacing wildcard row (`askCoveredBy`; PR #237 security review Risk 3). */
|
|
58
|
+
function planLocalRetirement({ physicalRoot, map, trackedSettingsObj }) {
|
|
59
|
+
const rel = '.claude/settings.local.json';
|
|
60
|
+
const abs = confinedJoin(physicalRoot, rel);
|
|
61
|
+
if (!abs) return { rel, abs: null, plan: null, error: 'resolves outside the workspace' };
|
|
62
|
+
try {
|
|
63
|
+
if (!fs.lstatSync(abs).isFile()) return { rel, abs, plan: null, error: 'not a regular file' };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { rel, abs, plan: null, error: e && e.code === 'ENOENT' ? null : e.message };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const settingsObj = readTarget(abs);
|
|
69
|
+
const askCoveredBy = trackedSettingsObj ? askRootKeys(trackedSettingsObj, map) : new Set();
|
|
70
|
+
return { rel, abs, settingsObj, plan: planRetirements({ settingsObj, map, askCoveredBy }), error: null };
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { rel, abs, plan: null, error: e.message };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
export function renderSummary(phases) {
|
|
51
77
|
const lines = ['Summary:'];
|
|
52
78
|
for (const p of phases) {
|
|
@@ -211,6 +237,13 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
211
237
|
if (previewRetirementPlan && previewRetirementPlan.total > 0) {
|
|
212
238
|
previewLines.push(` [permission-floor] would retire allow=${previewRetirementPlan.retirements.allow.length}, ask=${previewRetirementPlan.retirements.ask.length}`);
|
|
213
239
|
}
|
|
240
|
+
// hotfix-v1.17.3 (PR #233 review Risk 4): the self-guard pair is previewed like the floor.
|
|
241
|
+
if (policyPresent(physicalRoot) || filePlan.some((f) => f.seed && f.action === 'create')) {
|
|
242
|
+
const cur0 = readTarget(floorTarget.path);
|
|
243
|
+
const prow = renderSelfGuardRow(physicalRoot, missingSelfGuardDeny(cur0).length, { shapeOk: selfGuardShapeOk(cur0), applied: false });
|
|
244
|
+
if (prow) previewLines.push(prow);
|
|
245
|
+
else previewLines.push(' [permissions] would add self-guard deny rules (2): Edit/Write on .foundry/permissions.yaml (with the seed)');
|
|
246
|
+
}
|
|
214
247
|
} else {
|
|
215
248
|
previewLines.push(' [permission-floor] .claude/settings.json absent — left to the create path');
|
|
216
249
|
}
|
|
@@ -222,6 +255,18 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
222
255
|
if (previewAmendmentsRow) previewLines.push(previewAmendmentsRow);
|
|
223
256
|
// statusline-wiring (AC-SLW-1/-2): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
|
|
224
257
|
previewLines.push(...renderStatuslineRows(planStatuslineWiring({ physicalRoot, templatesDir })));
|
|
258
|
+
// retired-artifacts (hotfix-v1.17.4, ER #236): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
|
|
259
|
+
const retiredCatalogue = loadRetiredCatalogue(pkgDir);
|
|
260
|
+
previewLines.push(...renderRetiredArtifactRows(planRetiredArtifacts({ physicalRoot, catalogue: retiredCatalogue }), { cleanup: flags.cleanup }));
|
|
261
|
+
// settings.local.json retirement (hotfix-v1.17.4): PREVIEW-ONLY row (PR #237 review Risk 2 —
|
|
262
|
+
// every Phase 4 write is announced before the first write happens); Phase 4 re-plans fresh.
|
|
263
|
+
{
|
|
264
|
+
const lp = planLocalRetirement({
|
|
265
|
+
physicalRoot, map, trackedSettingsObj: floorTarget.present ? readTarget(floorTarget.path) : null,
|
|
266
|
+
});
|
|
267
|
+
if (lp.plan && lp.plan.total > 0) previewLines.push(` [permission-floor] ${lp.rel}: would retire allow=${lp.plan.retirements.allow.length}, ask=${lp.plan.retirements.ask.length} (never adds)`);
|
|
268
|
+
else if (lp.error) previewLines.push(` [permission-floor] ${lp.rel}: would not be reconciled (${lp.error})`);
|
|
269
|
+
}
|
|
225
270
|
print(previewLines.join('\n'));
|
|
226
271
|
|
|
227
272
|
const env = { ...spawnEnv, CLAUDE_CONFIG_DIR: configDir };
|
|
@@ -293,6 +338,17 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
293
338
|
applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
|
|
294
339
|
})) print(line);
|
|
295
340
|
}
|
|
341
|
+
// hotfix-v1.17.3 (ER #232): the policy file's self-guard deny pair is framework-owned — converge
|
|
342
|
+
// it here, fresh from disk after the floor write, whenever a policy file exists (seeded above or
|
|
343
|
+
// kept). Grants stay the compiler's (operator-run) business.
|
|
344
|
+
if (policyPresent(physicalRoot)) {
|
|
345
|
+
const cur = readTarget(freshFloorTarget.path);
|
|
346
|
+
const shapeOk = selfGuardShapeOk(cur);
|
|
347
|
+
const missing = missingSelfGuardDeny(cur);
|
|
348
|
+
if (shapeOk && missing.length > 0) writeTargetAtomically(freshFloorTarget.path, applySelfGuardDeny(cur));
|
|
349
|
+
const row = renderSelfGuardRow(physicalRoot, missing.length, { shapeOk });
|
|
350
|
+
if (row) print(row);
|
|
351
|
+
}
|
|
296
352
|
}
|
|
297
353
|
// Recomputed FRESH from disk, same reasoning as floorPlan just above: never apply a plan
|
|
298
354
|
// captured before Phases 1-3 ran, even though `.gitignore` is not itself a migration target.
|
|
@@ -329,10 +385,40 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
329
385
|
const statuslinePlan = planStatuslineWiring({ physicalRoot, templatesDir });
|
|
330
386
|
applyStatuslineWiring(statuslinePlan);
|
|
331
387
|
for (const row of renderStatuslineRows(statuslinePlan)) print(row);
|
|
388
|
+
// retired-artifacts (hotfix-v1.17.4, ER #236): re-planned FRESH from disk; reported every run,
|
|
389
|
+
// removed only under --cleanup, only catalogued paths of the catalogued kind, never a hook a
|
|
390
|
+
// settings hook command still names.
|
|
391
|
+
const retiredPlan = planRetiredArtifacts({ physicalRoot, catalogue: retiredCatalogue });
|
|
392
|
+
let retiredRemoved = 0;
|
|
393
|
+
if (flags.cleanup && retiredPlan.present > 0) retiredRemoved = applyRetiredArtifacts(retiredPlan, physicalRoot);
|
|
394
|
+
for (const row of renderRetiredArtifactRows(retiredPlan, { cleanup: flags.cleanup })) print(row);
|
|
395
|
+
|
|
396
|
+
// settings.local.json (hotfix-v1.17.4): the tracked-file reconcile never reads it, so a
|
|
397
|
+
// version-pinned floor row an old init left there survived every upgrade. Only RETIREMENT is
|
|
398
|
+
// applied here (never additions — the local file is the operator's), with the same planner and
|
|
399
|
+
// the same never-clobber, mode-preserving write as the tracked file. The tracked file is re-read
|
|
400
|
+
// AFTER its own write above, so an `ask` row is retired only when the wildcard row that replaces
|
|
401
|
+
// it is really there (Risk 3); confined like every other path (Risk 4).
|
|
402
|
+
let localRetired = 0;
|
|
403
|
+
{
|
|
404
|
+
const lp = planLocalRetirement({
|
|
405
|
+
physicalRoot, map,
|
|
406
|
+
trackedSettingsObj: freshFloorTarget.present ? readTarget(freshFloorTarget.path) : null,
|
|
407
|
+
});
|
|
408
|
+
if (lp.plan && lp.plan.total > 0) {
|
|
409
|
+
writeTargetAtomically(lp.abs, applyRetirements(lp.settingsObj, lp.plan));
|
|
410
|
+
localRetired = lp.plan.total;
|
|
411
|
+
for (const tier of ['allow', 'ask']) for (const r of lp.plan.retirements[tier]) print(` [retired] ${lp.rel}: ${r}`);
|
|
412
|
+
print(` [permission-floor] ${lp.rel}: retired ${localRetired} version-pinned/gone row(s)`);
|
|
413
|
+
} else if (lp.error) {
|
|
414
|
+
print(` [permission-floor] ${lp.rel}: not reconciled (${lp.error})`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
332
418
|
phases.push({
|
|
333
419
|
name: 'reinitialization',
|
|
334
420
|
verdict: anyCreated || anyFloorAdded || anyGitignoreChanged || anyAmendmentsBackfilled
|
|
335
|
-
|| statuslineChanged(statuslinePlan) ? 'changed' : 'already current',
|
|
421
|
+
|| statuslineChanged(statuslinePlan) || retiredRemoved > 0 || localRetired > 0 ? 'changed' : 'already current',
|
|
336
422
|
});
|
|
337
423
|
|
|
338
424
|
print('');
|
|
@@ -344,6 +430,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
344
430
|
const report = buildUpgradeReport({
|
|
345
431
|
installedBefore, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
|
|
346
432
|
updaterVersion, coreVersion: corePkg.version, updaterPluginVersion: pins.plugin_version,
|
|
433
|
+
retiredArtifacts: { present: retiredPlan.rows.filter((r) => r.state === 'stale').map((r) => r.relPath), removed: retiredRemoved, refused: retiredPlan.refused },
|
|
434
|
+
localRetired,
|
|
347
435
|
});
|
|
348
436
|
const reportPath = writeUpgradeReport(physicalRoot, report);
|
|
349
437
|
print('');
|
package/src/upgradeReport.mjs
CHANGED
|
@@ -32,6 +32,7 @@ export function versionOrNull(v) {
|
|
|
32
32
|
export function buildUpgradeReport({
|
|
33
33
|
installedBefore = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
|
|
34
34
|
updaterVersion = null, coreVersion = null, updaterPluginVersion = null,
|
|
35
|
+
retiredArtifacts = null, localRetired = 0,
|
|
35
36
|
}) {
|
|
36
37
|
const seedRow = (filePlan || []).find((f) => f.seed);
|
|
37
38
|
return {
|
|
@@ -57,6 +58,11 @@ export function buildUpgradeReport({
|
|
|
57
58
|
: { backfilled: 0, present: 0, skipped: 0, total: 0 },
|
|
58
59
|
permissions_policy: seedRow ? (seedRow.action === 'create' ? 'created' : 'kept') : 'absent',
|
|
59
60
|
drifted: (filePlan || []).filter((f) => f.action === 'drifted').map((f) => f.relPath),
|
|
61
|
+
// hotfix-v1.17.4: what the sweep found (paths are workspace-relative catalogue entries, never free text)
|
|
62
|
+
retired_artifacts: retiredArtifacts
|
|
63
|
+
? { present: retiredArtifacts.present, removed: retiredArtifacts.removed, refused: retiredArtifacts.refused }
|
|
64
|
+
: { present: [], removed: 0, refused: 0 },
|
|
65
|
+
settings_local_retired: localRetired,
|
|
60
66
|
};
|
|
61
67
|
}
|
|
62
68
|
|