arkaos 4.48.0 → 5.0.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.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/THE-ARKAOS-GUIDE.md +2 -2
  3. package/VERSION +1 -1
  4. package/arka/SKILL.md +1 -1
  5. package/config/hooks/_lib/fastpath/engine.cjs +29 -5
  6. package/config/hooks/gate-manifest.json +14 -1
  7. package/core/governance/harness_scanner.py +108 -30
  8. package/core/governance/harness_scanner_cli.py +220 -8
  9. package/core/harness/remediation.py +644 -0
  10. package/core/hooks/gate_manifest.py +12 -0
  11. package/core/hooks/post_tool_use.py +46 -4
  12. package/core/hooks/pre_tool_use.py +36 -3
  13. package/core/workflow/flow_enforcer.py +112 -20
  14. package/core/workflow/frontend_gate.py +64 -5
  15. package/core/workflow/specialist_enforcer.py +48 -15
  16. package/departments/kb/SKILL.md +1 -0
  17. package/departments/kb/skills/research-deep/SKILL.md +192 -0
  18. package/departments/kb/skills/research-deep/THIRD-PARTY-NOTICES.md +15 -0
  19. package/harness/codex/AGENTS.md +2 -2
  20. package/harness/copilot/copilot-instructions.md +2 -2
  21. package/harness/cursor/rules/arkaos.mdc +3 -3
  22. package/harness/gemini/GEMINI.md +2 -2
  23. package/harness/opencode/AGENTS.md +2 -2
  24. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  25. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  26. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  27. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  28. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  29. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  30. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  31. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  32. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  33. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  34. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  35. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  36. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  37. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  38. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  39. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  40. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  41. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  42. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  43. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  44. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  45. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  46. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  47. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  48. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  49. package/harness/zed/.rules +2 -2
  50. package/installer/cli.js +9 -3
  51. package/knowledge/commands-registry.json +25 -3
  52. package/knowledge/skills-manifest.json +14 -1
  53. package/package.json +1 -1
  54. package/pyproject.toml +1 -1
@@ -0,0 +1,644 @@
1
+ """Apply the fixes the harness scanner already names (PR-C3).
2
+
3
+ The scanner grades the harness and puts a ``fix`` on every finding, but
4
+ nothing applies them — the operator reads grade F and edits JSON by
5
+ hand. This module closes that loop for the findings that can be fixed
6
+ MECHANICALLY and SAFELY, and reports every finding it did not touch
7
+ with the reason it is not mechanically fixable.
8
+
9
+ The design rule that matters: **a remediator that skips silently is
10
+ worse than none**, because it turns an unfixed vulnerability into a
11
+ clean-looking run. Every scanner rule is classified here. A rule this
12
+ module has never heard of is reported as ``unknown-rule`` — loudly —
13
+ rather than dropped, so adding a scanner rule can never quietly widen
14
+ the set of things nobody fixes.
15
+
16
+ Scope is deliberately narrow: settings allow/deny lists, the one place
17
+ where a fix is unambiguous and strictly privilege-reducing. Secrets,
18
+ hook findings, unusable config files and the patterns only the operator
19
+ can choose are named ``needs-human``; MCP manifests and instruction
20
+ files are named ``out-of-scope``. The two are different promises and
21
+ this module never collapses them.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import shutil
27
+ from dataclasses import dataclass, field
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+
31
+ from core.governance.harness_scanner import (
32
+ _DANGEROUS_COMMANDS,
33
+ SETTINGS_FILES,
34
+ Finding,
35
+ ScanReport,
36
+ _bash_command_word,
37
+ _dangerous_allow_finding,
38
+ scan,
39
+ )
40
+ from core.harness import json_store
41
+ from core.harness.spec import HARD_DENY_RULES
42
+
43
+ # ─── Classification ─────────────────────────────────────────────────────
44
+
45
+ # Rules this module can fix, and how.
46
+ # drop-allow — remove the offending rule from permissions.allow
47
+ # seed-deny — add the ArkaOS hard-deny defaults to permissions.deny
48
+ FIXABLE: dict[str, str] = {
49
+ "settings-dangerous-allow": "drop-allow",
50
+ "settings-no-deny": "seed-deny",
51
+ "settings-bypass-mode": "seed-deny",
52
+ }
53
+
54
+ # Why every other rule is NOT mechanically fixable. The taxonomy is the
55
+ # point: "needs-human" and "out-of-scope" are different promises to the
56
+ # operator, and collapsing them into one bucket hides which findings
57
+ # will still be there tomorrow.
58
+ NON_FIXABLE: dict[str, tuple[str, str]] = {
59
+ # The scanner's own fix for this one is "scope it", and only you know
60
+ # the pattern. Dropping the rule instead would be a DIFFERENT fix
61
+ # from the one named — and for a read-only tool like Read(*) it
62
+ # costs real UX for no execution risk. So: named, never guessed.
63
+ "settings-unscoped-allow": (
64
+ "needs-human",
65
+ "the fix is to narrow the rule to a pattern, and only you know "
66
+ "which one — dropping it outright is a different decision",
67
+ ),
68
+ "settings-secret-in-env": (
69
+ "needs-human",
70
+ "only you know whether the value is a real credential and where "
71
+ "it should live instead",
72
+ ),
73
+ "config-unparseable": (
74
+ "needs-human",
75
+ "the file must be valid JSON before anything can edit it safely",
76
+ ),
77
+ "config-unreadable": (
78
+ "needs-human",
79
+ "the file cannot be read — check its size (2 MB cap) and its "
80
+ "permissions",
81
+ ),
82
+ "hook-command-injection": (
83
+ "needs-human",
84
+ "rewriting a hook command changes what it does; the safe form "
85
+ "depends on intent",
86
+ ),
87
+ "hook-silences-errors": (
88
+ "needs-human",
89
+ "removing the silencer may make a hook fail loudly, which is the "
90
+ "point but not a decision to take for you",
91
+ ),
92
+ "hook-script-missing": (
93
+ "needs-human",
94
+ "the script has to be restored or the registration removed — "
95
+ "which one depends on whether you still want the hook",
96
+ ),
97
+ "hook-world-writable": (
98
+ "needs-human",
99
+ "chmod on a file this tool does not own; run it yourself once "
100
+ "you have checked who else writes there",
101
+ ),
102
+ "hook-script-unreadable": (
103
+ "needs-human",
104
+ "the hook target could not be read, so its permissions and its "
105
+ "contents are unaudited — only you can say whether that path is "
106
+ "meant to be private or is simply wrong",
107
+ ),
108
+ "mcp-secret-in-env": ("out-of-scope", "MCP manifests are not edited here"),
109
+ "mcp-unpinned-package": (
110
+ "out-of-scope", "MCP manifests are not edited here"
111
+ ),
112
+ "mcp-auto-install": ("out-of-scope", "MCP manifests are not edited here"),
113
+ "mcp-shell-command": ("out-of-scope", "MCP manifests are not edited here"),
114
+ "instructions-injection": (
115
+ "out-of-scope", "instruction files are prose, not configuration"
116
+ ),
117
+ "instructions-secret": (
118
+ "out-of-scope", "instruction files are prose, not configuration"
119
+ ),
120
+ "instructions-invisible-characters": (
121
+ "out-of-scope", "instruction files are prose, not configuration"
122
+ ),
123
+ "scanner-error": (
124
+ "needs-human", "the scanner itself failed on this path"
125
+ ),
126
+ }
127
+
128
+
129
+ @dataclass
130
+ class Skipped:
131
+ """A finding this module did not fix, and why."""
132
+
133
+ rule: str
134
+ where: str
135
+ reason: str # needs-human | out-of-scope | unknown-rule
136
+ detail: str
137
+
138
+ def to_dict(self) -> dict:
139
+ return {
140
+ "rule": self.rule, "where": self.where,
141
+ "reason": self.reason, "detail": self.detail,
142
+ }
143
+
144
+
145
+ @dataclass
146
+ class FixReport:
147
+ """What a --fix run did, would do, or refused to do."""
148
+
149
+ applied: bool
150
+ removed_allow: list[str] = field(default_factory=list)
151
+ seeded_deny: list[str] = field(default_factory=list)
152
+ skipped: list[Skipped] = field(default_factory=list)
153
+ backups: list[Path] = field(default_factory=list)
154
+ refused: str | None = None
155
+ settings_paths: list[Path] = field(default_factory=list)
156
+ # Per-file outcome. A single `applied` bool cannot describe a run
157
+ # that wrote one file and failed the next, and reporting that state
158
+ # as "nothing was written" is a lie about the operator's disk.
159
+ written: list[str] = field(default_factory=list)
160
+ failed: list[str] = field(default_factory=list)
161
+ # The staged files themselves, so the render can attribute every
162
+ # removal, seed and backup to the file it belongs to.
163
+ states: list[_FileState] = field(default_factory=list)
164
+
165
+ @property
166
+ def partial(self) -> bool:
167
+ """Some files were written and some were not."""
168
+ return bool(self.written and self.failed)
169
+
170
+ @property
171
+ def changed(self) -> bool:
172
+ return bool(self.removed_allow or self.seeded_deny)
173
+
174
+ @property
175
+ def backup(self) -> Path | None:
176
+ """The first backup taken, for single-file callers."""
177
+ return self.backups[0] if self.backups else None
178
+
179
+ def absorb(self, states: list[_FileState]) -> None:
180
+ """Roll the per-file outcomes up, in the SAME units.
181
+
182
+ `written` and `failed` both name settings files: mixing a
183
+ resolved target into one and a file name into the other put two
184
+ units in one sentence (QG C3 r3, Francisca M2).
185
+ """
186
+ for state in states:
187
+ if state.written:
188
+ self.written.append(state.name)
189
+ else:
190
+ self.failed.append(state.name)
191
+ if state.backup is not None:
192
+ self.backups.append(state.backup)
193
+ failures = [s.failure for s in states if s.failure]
194
+ self.refused = "; ".join(failures) if failures else None
195
+ self.applied = bool(self.written) and not self.failed
196
+
197
+ def to_dict(self) -> dict:
198
+ return {
199
+ "applied": self.applied,
200
+ "changed": self.changed,
201
+ # Which files reached disk. A consumer that only sees
202
+ # `applied: false` concludes nothing was written, while a
203
+ # partial run has already replaced one of them.
204
+ "written": list(self.written),
205
+ "failed": list(self.failed),
206
+ "partial": self.partial,
207
+ "settings_paths": [str(p) for p in self.settings_paths],
208
+ "backups": [str(p) for p in self.backups],
209
+ "refused": self.refused,
210
+ "removed_allow": list(self.removed_allow),
211
+ "seeded_deny": list(self.seeded_deny),
212
+ # Per file, so a consumer can tell WHICH rules survived a
213
+ # partial apply — the run-level lists above are the PLAN.
214
+ "files": [
215
+ {"name": s.name, "written": s.written,
216
+ "removed": list(s.removed), "seeded": list(s.seeded),
217
+ "backup": str(s.backup) if s.backup else None,
218
+ "failure": s.failure}
219
+ for s in self.states
220
+ ],
221
+ "skipped": [s.to_dict() for s in self.skipped],
222
+ }
223
+
224
+
225
+ def classify(finding: Finding) -> tuple[str, str]:
226
+ """Return ``(kind, reason)`` for one finding.
227
+
228
+ ``kind`` is the FIXABLE action, or one of the non-fixable reasons.
229
+ An unrecognised rule is ``unknown-rule`` — never silently dropped.
230
+ """
231
+ action = FIXABLE.get(finding.rule)
232
+ if action:
233
+ return action, ""
234
+ known = NON_FIXABLE.get(finding.rule)
235
+ if known:
236
+ return known[0], known[1]
237
+ return (
238
+ "unknown-rule",
239
+ "this remediator has no classification for that scanner rule — "
240
+ "report it so it gets one",
241
+ )
242
+
243
+
244
+ # ─── Planning ───────────────────────────────────────────────────────────
245
+
246
+
247
+ def is_dangerous_allow(rule: object) -> bool:
248
+ """True when ``rule`` grants a dangerous COMMAND.
249
+
250
+ Only the scanner's command-word branch counts here. Its second
251
+ branch matches free text against the whole rule string with no tool
252
+ guard, so ``Read(core/eval/**)`` and ``Edit(docs/sudo-setup.md)``
253
+ trip it on their PATHS. Under the scanner that was a cosmetic false
254
+ positive; a remediator would convert it into deletion of a benign,
255
+ correctly-scoped read grant (QG C3 r1, Francisca B4 — reproduced).
256
+ Free-text-only matches are classified needs-human instead.
257
+ """
258
+ if not isinstance(rule, str):
259
+ return False
260
+ return _bash_command_word(rule) in _DANGEROUS_COMMANDS
261
+
262
+
263
+ def dangerous_rules(allow: list) -> list[str]:
264
+ """The dangerous-command allow rules in a LIVE allow list.
265
+
266
+ Asked of the scanner's own predicate against the live list, never
267
+ parsed back out of the finding's prose: a rule containing quotes
268
+ cannot be sliced back out of the sentence that embedded its repr(),
269
+ and deriving a fact from prose instead of from code is how a
270
+ remediator ends up deleting the wrong line.
271
+ """
272
+ return [rule for rule in allow if is_dangerous_allow(rule)]
273
+
274
+
275
+ def _free_text_danger(rule: object) -> bool:
276
+ """Flagged dangerous by the scanner, but not by its command word."""
277
+ return (
278
+ isinstance(rule, str)
279
+ and not is_dangerous_allow(rule)
280
+ and _dangerous_allow_finding(rule, "x") is not None
281
+ )
282
+
283
+
284
+ def _skip_for(
285
+ finding: Finding, kind: str, reason: str, scanner_detail: str = ""
286
+ ) -> Skipped:
287
+ """Carry the scanner's own detail alongside our reason.
288
+
289
+ Without it, two unscoped findings in one file render as identical
290
+ lines and the operator cannot tell WHICH rules they are (QG C3 r1,
291
+ Francisca M2).
292
+ """
293
+ detail = f"{scanner_detail} — {reason}" if scanner_detail else reason
294
+ return Skipped(finding.rule, finding.where, kind, detail)
295
+
296
+
297
+ def _free_text_skips(allow: list, where: str) -> list[Skipped]:
298
+ """One Skipped per RULE the scanner flags only by wording.
299
+
300
+ Derived from the live allow list, not from the findings: a finding
301
+ does not name its rule in a machine-readable way, and emitting one
302
+ skip per finding when any rule matches inflates the count — the
303
+ accounting has to be exact for the anti-silence invariant to mean
304
+ anything.
305
+ """
306
+ skips = []
307
+ for rule in allow:
308
+ if not _free_text_danger(rule):
309
+ continue
310
+ # Carry the scanner's OWN label. Without it a rule like
311
+ # `--dangerously-skip-permissions`, whose label is "permission
312
+ # bypass", was described to the operator as probably benign —
313
+ # the tool nudging them to keep a genuine critical (QG C3 r2,
314
+ # Francisca M1).
315
+ finding = _dangerous_allow_finding(rule, where)
316
+ detail = finding.detail if finding else ""
317
+ skips.append(Skipped(
318
+ "settings-dangerous-allow", where, "needs-human",
319
+ f"{detail} This remediator will not drop it: the scanner "
320
+ f"matched the wording, not the command word, so the "
321
+ f"decision is yours.",
322
+ ))
323
+ return skips
324
+
325
+
326
+ # ─── Applying ───────────────────────────────────────────────────────────
327
+
328
+
329
+ def _backup(path: Path) -> Path:
330
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
331
+ dest = path.with_name(f"{path.name}.arkaos-backup-{stamp}")
332
+ shutil.copy2(path, dest)
333
+ return dest
334
+
335
+
336
+ def _resolve_target(path: Path) -> Path:
337
+ """The file a write must land on.
338
+
339
+ A symlinked settings.json is the standard dotfiles/stow/chezmoi
340
+ layout. ``os.replace`` would replace the LINK, leaving the
341
+ authoritative file still carrying the dangerous rule while the tool
342
+ reported success (QG C3 r1, Francisca B3 — reproduced). Writing
343
+ through to the target is the only write that tells the truth.
344
+ """
345
+ return path.resolve() if path.is_symlink() else path
346
+
347
+
348
+ @dataclass
349
+ class _FileState:
350
+ """One settings file this run considered.
351
+
352
+ Removals, seeds and the backup are recorded HERE, per file. A
353
+ run-level union cannot say which file a rule was removed from, so a
354
+ partial apply printed rules under "removed" that were still live in
355
+ the file that failed (QG C3 r3, both reviewers, reproduced).
356
+ """
357
+
358
+ name: str
359
+ path: Path
360
+ target: Path
361
+ settings: dict | None = None
362
+ perms: dict | None = None
363
+ allow: list = field(default_factory=list)
364
+ refusal: str | None = None
365
+ removed: list[str] = field(default_factory=list)
366
+ seeded: list[str] = field(default_factory=list)
367
+ backup: Path | None = None
368
+ written: bool = False
369
+ failure: str | None = None
370
+
371
+
372
+ def _read_file(root: Path, name: str) -> _FileState:
373
+ path = root / name
374
+ state = _FileState(name=name, path=path, target=_resolve_target(path))
375
+ if not path.exists():
376
+ return state
377
+ loaded = json_store.load_json(state.target)
378
+ if not loaded.ok or not isinstance(loaded.data, dict):
379
+ state.refusal = f"{name} not usable ({loaded.error})"
380
+ return state
381
+ state.settings = loaded.data
382
+ perms = loaded.data.get("permissions")
383
+ if perms is not None and not isinstance(perms, dict):
384
+ state.refusal = f"{name}: permissions is not an object"
385
+ return state
386
+ state.perms = perms if isinstance(perms, dict) else None
387
+ allow = state.perms.get("allow") if state.perms else None
388
+ state.allow = allow if isinstance(allow, list) else []
389
+ return state
390
+
391
+
392
+ def _findings_for(report: ScanReport, name: str) -> list[Finding]:
393
+ """Findings whose ``where`` names this settings file.
394
+
395
+ The scanner qualifies ``where`` with the file name; a finding from
396
+ another file must never drive a write here.
397
+ """
398
+ return [f for f in report.findings if name in f.where]
399
+
400
+
401
+ def plan_file(
402
+ report: ScanReport, state: _FileState
403
+ ) -> tuple[list[str], bool, list[Skipped]]:
404
+ """(rules to drop, seed deny?, skipped) for ONE settings file."""
405
+ to_drop = dangerous_rules(state.allow)
406
+ seed_deny = False
407
+ # One skip per free-text-only rule, from the live list. The
408
+ # drop-allow findings themselves are accounted for by
409
+ # to_drop + these, so they are not re-counted below.
410
+ skipped: list[Skipped] = _free_text_skips(state.allow, state.name)
411
+ for finding in _findings_for(report, state.name):
412
+ kind, reason = classify(finding)
413
+ if kind == "drop-allow":
414
+ continue
415
+ if kind == "seed-deny":
416
+ seed_deny = True
417
+ else:
418
+ skipped.append(_skip_for(finding, kind, reason, finding.detail))
419
+ return to_drop, seed_deny, skipped
420
+
421
+
422
+ def _unaccounted(report: ScanReport, seen: set[str]) -> list[Skipped]:
423
+ """Findings from files this run never opened.
424
+
425
+ The anti-silence invariant lives here: a finding the remediator did
426
+ not consider is reported, never dropped on the floor.
427
+ """
428
+ return [
429
+ _skip_for(
430
+ f, "out-of-scope",
431
+ f"{f.where} is not a settings file this remediator opens",
432
+ f.detail,
433
+ )
434
+ for f in report.findings
435
+ if not any(name in f.where for name in seen)
436
+ ]
437
+
438
+
439
+ # ─── Applying ───────────────────────────────────────────────────────────
440
+
441
+
442
+ def _commit(state: _FileState) -> bool:
443
+ """Back up and write ONE file. False when either step failed.
444
+
445
+ The backup is recorded on the state only when it actually exists:
446
+ when ``_backup`` is itself what raised, the file has no backup and
447
+ the report must not promise one (QG C3 r3, both reviewers).
448
+ """
449
+ try:
450
+ state.backup = _backup(state.target)
451
+ json_store.write_json_atomic(state.target, state.settings)
452
+ except OSError as exc:
453
+ state.failure = f"{state.name}: {exc}"
454
+ return False
455
+ state.written = True
456
+ return True
457
+
458
+
459
+ def fix(root: Path, apply: bool = False) -> FixReport:
460
+ """Plan (and optionally apply) the mechanical fixes under ``root``.
461
+
462
+ Every settings file the scanner reads is considered, and every
463
+ finding is either acted on or named. Never raises: an unreadable or
464
+ corrupt file is a refusal with a reason, not a traceback.
465
+ """
466
+ report = FixReport(applied=False)
467
+ try:
468
+ scan_report = scan(root)
469
+ except Exception as exc: # a scan failure is a refusal, not a crash
470
+ report.refused = f"scan failed: {type(exc).__name__}"
471
+ return report
472
+
473
+ seen, pending = _stage_all(Path(root).expanduser(), scan_report, report)
474
+ report.skipped.extend(_unaccounted(scan_report, seen))
475
+ report.states = pending
476
+ if not report.changed or not apply:
477
+ return report
478
+ for state in pending:
479
+ _commit(state)
480
+ report.absorb(pending)
481
+ return report
482
+
483
+
484
+ def _stage_all(
485
+ root: Path, scan_report: ScanReport, report: FixReport
486
+ ) -> tuple[set[str], list[_FileState]]:
487
+ """Read, plan and stage every settings file. Nothing is written."""
488
+ seen: set[str] = set()
489
+ pending: list[_FileState] = []
490
+ for name in SETTINGS_FILES:
491
+ state = _read_file(root, name)
492
+ if not state.path.exists():
493
+ continue
494
+ seen.add(name)
495
+ report.settings_paths.append(state.target)
496
+ # The findings of an unusable file are classified BEFORE the
497
+ # refusal short-circuits it: skipping straight past them is the
498
+ # same anti-silence hole this module exists to close, one file
499
+ # over (test_corrupt_settings_never_mutates_and_is_named).
500
+ to_drop, seed_deny, skipped = plan_file(scan_report, state)
501
+ report.skipped.extend(skipped)
502
+ if state.refusal:
503
+ report.skipped.append(
504
+ Skipped("config-unusable", name, "needs-human", state.refusal)
505
+ )
506
+ elif _stage(state, to_drop, seed_deny, report):
507
+ pending.append(state)
508
+ return seen, pending
509
+
510
+
511
+ def _stage(
512
+ state: _FileState, to_drop: list[str], seed_deny: bool, report: FixReport
513
+ ) -> bool:
514
+ """Apply the plan to the in-memory settings. True when it changed."""
515
+ if not (to_drop or seed_deny):
516
+ return False
517
+ if state.perms is None:
518
+ report.skipped.append(Skipped(
519
+ "config-unusable", state.name, "needs-human",
520
+ f"{state.name} has fixable findings but no permissions object",
521
+ ))
522
+ return False
523
+ _plan_removals(state, to_drop)
524
+ if seed_deny:
525
+ _plan_deny(state)
526
+ if state.removed or state.seeded:
527
+ state.settings["permissions"] = state.perms
528
+ report.removed_allow.extend(state.removed)
529
+ report.seeded_deny.extend(state.seeded)
530
+ return True
531
+ return False
532
+
533
+
534
+ def _plan_removals(state: _FileState, to_drop: list[str]) -> None:
535
+ allow = state.perms.get("allow")
536
+ if not isinstance(allow, list) or not to_drop:
537
+ return
538
+ drop = set(to_drop)
539
+ state.removed = [r for r in allow if isinstance(r, str) and r in drop]
540
+ state.perms["allow"] = [
541
+ r for r in allow if not (isinstance(r, str) and r in drop)
542
+ ]
543
+
544
+
545
+ def _plan_deny(state: _FileState) -> None:
546
+ existing = state.perms.get("deny")
547
+ existing = existing if isinstance(existing, list) else []
548
+ merged = json_store.merge_unique(
549
+ [r for r in existing if isinstance(r, str)], HARD_DENY_RULES
550
+ )
551
+ added = [r for r in merged if r not in existing]
552
+ if added:
553
+ state.seeded = added
554
+ state.perms["deny"] = merged
555
+
556
+
557
+ # ─── Rendering ──────────────────────────────────────────────────────────
558
+
559
+
560
+ def _file_lines(state: _FileState, applied: bool) -> list[str]:
561
+ """One file's removals and seeds, in the tense that file earned.
562
+
563
+ A run-level verb cannot speak for N files: a partial apply printed
564
+ the failed file's rules under "removed" while they were still live
565
+ on disk, which ends the operator's attention on a CRITICAL grant
566
+ (QG C3 r3, both reviewers, reproduced).
567
+ """
568
+ if not (state.removed or state.seeded):
569
+ return []
570
+ wrote = applied and state.written
571
+ lines = [f"{state.name}:"]
572
+ if state.removed:
573
+ verb = "removed" if wrote else "would remove"
574
+ lines.append(f" {verb} {len(state.removed)} allow rule(s):")
575
+ lines += [f" - {r}" for r in state.removed]
576
+ if state.seeded:
577
+ verb = "seeded" if wrote else "would seed"
578
+ lines.append(f" {verb} {len(state.seeded)} hard-deny rule(s)")
579
+ if wrote and state.backup:
580
+ lines.append(f" backup: {state.backup}")
581
+ elif state.failure:
582
+ lines.append(f" NOT WRITTEN — {state.failure}")
583
+ lines.append(
584
+ f" backup: {state.backup} (the untouched original)"
585
+ if state.backup
586
+ else " no backup was taken; the file is unchanged on disk"
587
+ )
588
+ return lines
589
+
590
+
591
+ def _render_actions(report: FixReport) -> list[str]:
592
+ """One block per file, then the run-level line: a refusal, or
593
+ which files reached disk on a partial apply, or that nothing did."""
594
+ lines: list[str] = []
595
+ for state in report.states:
596
+ lines += _file_lines(state, bool(report.written))
597
+ if report.refused and not report.failed:
598
+ # A run-level refusal (a scan that raised) has no states to
599
+ # render, so without this line the whole report is the empty
600
+ # string — the one failure meaning "I examined nothing at all"
601
+ # printing nothing at all (QG C3 r4, Francisca B1, reproduced).
602
+ # The guard keeps it from duplicating the per-file NOT WRITTEN
603
+ # text, which already names each failure.
604
+ lines.append(f"REFUSED: {report.refused}")
605
+ if not report.changed and not report.refused:
606
+ lines.append("nothing mechanically fixable found")
607
+ if report.partial:
608
+ lines.append(
609
+ "PARTIAL APPLY — written: " + ", ".join(report.written)
610
+ + " | NOT written: " + ", ".join(report.failed)
611
+ + ". Only the rules listed under a written file are gone; the "
612
+ "rest are still live on disk."
613
+ )
614
+ elif report.failed and not report.written:
615
+ lines.append(
616
+ "nothing was written — every file is unchanged on disk."
617
+ )
618
+ return lines
619
+
620
+
621
+ def _render_skipped(report: FixReport) -> list[str]:
622
+ if not report.skipped:
623
+ return []
624
+ lines = ["", f"NOT fixed automatically ({len(report.skipped)}):"]
625
+ for s in report.skipped:
626
+ lines.append(f" [{s.reason}] {s.rule} @ {s.where}")
627
+ lines.append(f" {s.detail}")
628
+ return lines
629
+
630
+
631
+ def render(report: FixReport, rescan: ScanReport | None = None) -> str:
632
+ """Operator-facing text. Every skipped finding is printed."""
633
+ lines = _render_actions(report) + _render_skipped(report)
634
+ if rescan is not None:
635
+ if report.partial:
636
+ label = "grade after a PARTIAL apply"
637
+ elif report.written:
638
+ label = "grade after fix"
639
+ else:
640
+ label = "grade now (unchanged — nothing applied)"
641
+ lines += ["", f"{label}: {rescan.grade} ({rescan.score}/100)"]
642
+ if not report.written and report.changed and not report.failed:
643
+ lines += ["", "DRY RUN — re-run with --apply to write these changes."]
644
+ return "\n".join(lines)
@@ -249,6 +249,18 @@ def _flags_and_budget() -> dict:
249
249
  "on_corrupt": False,
250
250
  "coercion": "python-truthy",
251
251
  },
252
+ "shadowDeny": {
253
+ "path": ["hooks", "shadowDeny"],
254
+ # flow_enforcer.shadow_deny_on: DEFAULT ON — missing file,
255
+ # corrupt JSON, or missing key all mean ON (PR-A5a telemetry
256
+ # collects by default); an explicit value is python-truthy.
257
+ # The engine additionally treats a MISSING MANIFEST ENTRY as
258
+ # ON: delegation is the degrade-to-latency direction.
259
+ "on_missing_file": True,
260
+ "on_corrupt": True,
261
+ "on_missing_key": True,
262
+ "coercion": "python-truthy",
263
+ },
252
264
  "budget": {
253
265
  "section": "budget",
254
266
  "cap_keys": ["hardCapUsd", "dailyCapUsd"],