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.
- package/README.md +2 -2
- package/THE-ARKAOS-GUIDE.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/config/hooks/_lib/fastpath/engine.cjs +29 -5
- package/config/hooks/gate-manifest.json +14 -1
- package/core/governance/harness_scanner.py +108 -30
- package/core/governance/harness_scanner_cli.py +220 -8
- package/core/harness/remediation.py +644 -0
- package/core/hooks/gate_manifest.py +12 -0
- package/core/hooks/post_tool_use.py +46 -4
- package/core/hooks/pre_tool_use.py +36 -3
- package/core/workflow/flow_enforcer.py +112 -20
- package/core/workflow/frontend_gate.py +64 -5
- package/core/workflow/specialist_enforcer.py +48 -15
- package/departments/kb/SKILL.md +1 -0
- package/departments/kb/skills/research-deep/SKILL.md +192 -0
- package/departments/kb/skills/research-deep/THIRD-PARTY-NOTICES.md +15 -0
- package/harness/codex/AGENTS.md +2 -2
- package/harness/copilot/copilot-instructions.md +2 -2
- package/harness/cursor/rules/arkaos.mdc +3 -3
- package/harness/gemini/GEMINI.md +2 -2
- package/harness/opencode/AGENTS.md +2 -2
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +2 -2
- package/installer/cli.js +9 -3
- package/knowledge/commands-registry.json +25 -3
- package/knowledge/skills-manifest.json +14 -1
- package/package.json +1 -1
- 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"],
|