arkaos 4.49.0 → 5.1.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/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -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/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- 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 +1 -1
- package/installer/claude-md.js +242 -0
- package/installer/cli.js +9 -3
- package/installer/index.js +10 -8
- package/installer/update.js +12 -7
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/THE-ARKAOS-GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The ArkaOS Guide
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> v5.1.0 — 89 agents, 17 departments, 333 skills, 298 commands, 19 ADRs.
|
|
4
4
|
> One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
|
|
5
5
|
|
|
6
6
|
## What it is
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5.1.0
|
|
@@ -19,10 +19,12 @@ with a letter grade; it prints nothing, exits nothing, and touches
|
|
|
19
19
|
nothing. The ``--fix`` path is a later slice and must be able to trust
|
|
20
20
|
that a scan never mutated what it measured.
|
|
21
21
|
|
|
22
|
-
Never raises on hostile
|
|
22
|
+
Never raises on hostile CONTENT. A settings file that is truncated,
|
|
23
23
|
binary, or full of nulls is a FINDING, not a traceback — a scanner that
|
|
24
24
|
dies on the config it was pointed at reports nothing at all, which is
|
|
25
|
-
the worst possible outcome for a security tool.
|
|
25
|
+
the worst possible outcome for a security tool. A config it could not
|
|
26
|
+
READ is the opposite case and raises deliberately, so the caller can
|
|
27
|
+
refuse it by name instead of grading what it never opened; see `scan`.
|
|
26
28
|
|
|
27
29
|
Engine behind ``npx arkaos shield`` and the ``doctor`` advisory section.
|
|
28
30
|
"""
|
|
@@ -39,6 +41,34 @@ from pathlib import Path
|
|
|
39
41
|
_MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
40
42
|
|
|
41
43
|
|
|
44
|
+
def is_readable_file(path: Path) -> bool:
|
|
45
|
+
"""Is `path` a regular file — and RAISE when we could not find out.
|
|
46
|
+
|
|
47
|
+
`Path.is_file()` cannot carry this contract across interpreters.
|
|
48
|
+
Python 3.14 reimplemented it to delegate to `os.path.isfile()`,
|
|
49
|
+
which swallows every OSError and answers False, so an unreadable
|
|
50
|
+
config read as "not there": the scanner counted zero files and
|
|
51
|
+
printed "Grade A (100/100) — nothing to report." for a directory it
|
|
52
|
+
never opened, on exactly the fresh machines and CI containers where
|
|
53
|
+
no venv exists and an ambient python3 is selected. The root and the
|
|
54
|
+
config file reach this call as `root / name`, and their two refusals
|
|
55
|
+
are built on this raise. `_check_hook_script` repeats the same
|
|
56
|
+
raising stat but renders EACCES as a HIGH `hook-script-unreadable`
|
|
57
|
+
finding, which grades B and exits 0 — the same discipline, and
|
|
58
|
+
deliberately NOT a refusal, since a refusal here means exit 2 (QG C3
|
|
59
|
+
r9 and r10, Francisca, reproduced side by side on 3.13 and 3.14;
|
|
60
|
+
vocabulary corrected at r15 by Marta, who executed the exit code).
|
|
61
|
+
|
|
62
|
+
ENOENT and ENOTDIR mean the file is absent, which is a real answer.
|
|
63
|
+
Every other errno — EACCES, EPERM, ELOOP — means we could not look,
|
|
64
|
+
which is not an answer and must never be graded.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
return stat.S_ISREG(path.stat().st_mode)
|
|
68
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
42
72
|
class Severity(StrEnum):
|
|
43
73
|
"""How much a finding costs. Declaration order is report order."""
|
|
44
74
|
|
|
@@ -421,33 +451,69 @@ def _check_hook_command(command: str, where: str) -> list[Finding]:
|
|
|
421
451
|
|
|
422
452
|
|
|
423
453
|
def _check_hook_script(command: str, where: str) -> list[Finding]:
|
|
424
|
-
"""The file the hook executes — does it exist, can anyone rewrite it?
|
|
454
|
+
"""The file the hook executes — does it exist, can anyone rewrite it?
|
|
455
|
+
|
|
456
|
+
ONE stat, three named outcomes. `path.exists()` could not carry this:
|
|
457
|
+
it raises on 3.13 and answers False on 3.14, and the raise reached
|
|
458
|
+
the per-file backstop, which throws away every finding already found
|
|
459
|
+
for that settings file and leaves one LOW `scanner-error`. Measured
|
|
460
|
+
on one config holding a CRITICAL dangerous-allow plus a hook whose
|
|
461
|
+
target sat in a mode-000 directory: 3.14 graded F (51/100, 3
|
|
462
|
+
findings, exit 2), 3.13 graded **A (98/100), exit 0**, rules
|
|
463
|
+
`['scanner-error']` — and that is the JSON `installer/doctor.js`
|
|
464
|
+
reads. One unstat-able hook target laundered every CRITICAL in the
|
|
465
|
+
file (QG C3 r10, Francisca B1, reproduced side by side).
|
|
466
|
+
"""
|
|
425
467
|
path = _script_path(command)
|
|
426
468
|
if path is None:
|
|
427
469
|
return []
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
"ArkaOS hook, `npx arkaos install --force` reinstalls it.",
|
|
437
|
-
)]
|
|
438
|
-
if _is_group_or_world_writable(path):
|
|
439
|
-
return [Finding(
|
|
440
|
-
rule="hook-world-writable",
|
|
441
|
-
severity=Severity.HIGH,
|
|
442
|
-
where=where,
|
|
443
|
-
detail=f"{path} is writable by group or others — any local "
|
|
444
|
-
f"process can rewrite what the agent executes on every "
|
|
445
|
-
f"tool call.",
|
|
446
|
-
fix=f"chmod go-w {path}",
|
|
447
|
-
)]
|
|
470
|
+
try:
|
|
471
|
+
mode = path.stat().st_mode
|
|
472
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
473
|
+
return [_hook_script_missing(path, where)]
|
|
474
|
+
except OSError as exc:
|
|
475
|
+
return [_hook_script_unreadable(path, where, exc)]
|
|
476
|
+
if _is_group_or_world_writable(mode):
|
|
477
|
+
return [_hook_world_writable(path, where)]
|
|
448
478
|
return []
|
|
449
479
|
|
|
450
480
|
|
|
481
|
+
def _hook_script_missing(path: Path, where: str) -> Finding:
|
|
482
|
+
return Finding(
|
|
483
|
+
rule="hook-script-missing",
|
|
484
|
+
severity=Severity.HIGH,
|
|
485
|
+
where=where,
|
|
486
|
+
detail=f"the hook points at {path}, which does not exist — the "
|
|
487
|
+
f"hook silently does nothing.",
|
|
488
|
+
fix="Restore the script, or remove the hook. If it is an "
|
|
489
|
+
"ArkaOS hook, `npx arkaos install --force` reinstalls it.",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _hook_script_unreadable(path: Path, where: str, exc: OSError) -> Finding:
|
|
494
|
+
return Finding(
|
|
495
|
+
rule="hook-script-unreadable",
|
|
496
|
+
severity=Severity.HIGH,
|
|
497
|
+
where=where,
|
|
498
|
+
detail=f"the hook points at {path}, which could not be read "
|
|
499
|
+
f"({exc.strerror}) — its permissions and its contents are "
|
|
500
|
+
f"UNAUDITED. Treat as unaudited, not as clean.",
|
|
501
|
+
fix=f"Make {path} readable by the account that runs the agent, or "
|
|
502
|
+
f"point the hook somewhere auditable.",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _hook_world_writable(path: Path, where: str) -> Finding:
|
|
507
|
+
return Finding(
|
|
508
|
+
rule="hook-world-writable",
|
|
509
|
+
severity=Severity.HIGH,
|
|
510
|
+
where=where,
|
|
511
|
+
detail=f"{path} is writable by group or others — any local process "
|
|
512
|
+
f"can rewrite what the agent executes on every tool call.",
|
|
513
|
+
fix=f"chmod go-w {path}",
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
451
517
|
def _script_path(command: str) -> Path | None:
|
|
452
518
|
"""The leading absolute path of a hook command, when it has one.
|
|
453
519
|
|
|
@@ -465,11 +531,13 @@ def _script_path(command: str) -> Path | None:
|
|
|
465
531
|
return Path(first)
|
|
466
532
|
|
|
467
533
|
|
|
468
|
-
def _is_group_or_world_writable(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
534
|
+
def _is_group_or_world_writable(mode: int) -> bool:
|
|
535
|
+
"""Mode bits only. The stat that produced them owns the error path.
|
|
536
|
+
|
|
537
|
+
This used to stat the file itself and swallow OSError into False —
|
|
538
|
+
"we could not tell" answering "safe", the exact direction a security
|
|
539
|
+
check must never take (QG C3 r10, Francisca B1).
|
|
540
|
+
"""
|
|
473
541
|
return bool(mode & (stat.S_IWGRP | stat.S_IWOTH))
|
|
474
542
|
|
|
475
543
|
|
|
@@ -755,7 +823,17 @@ def _safe_scan_file(
|
|
|
755
823
|
|
|
756
824
|
|
|
757
825
|
def scan(root: Path) -> ScanReport:
|
|
758
|
-
"""Scan a harness config tree. Read-only.
|
|
826
|
+
"""Scan a harness config tree. Read-only.
|
|
827
|
+
|
|
828
|
+
RAISES, deliberately, when a config cannot be READ: an unreadable
|
|
829
|
+
root or candidate propagates its OSError so the caller can refuse it
|
|
830
|
+
by name. Callers must use `harness_scanner_cli._safe_scan`, which is
|
|
831
|
+
built to catch exactly that. Per-FILE failures do not raise — they
|
|
832
|
+
are absorbed by `_safe_scan_file` into a `scanner-error` finding, so
|
|
833
|
+
one malformed config cannot end the scan (QG C3 r10, Francisca M2:
|
|
834
|
+
"Never raises" became false when the refusal family was built on the
|
|
835
|
+
raise, and the two docstrings said opposite things).
|
|
836
|
+
"""
|
|
759
837
|
try:
|
|
760
838
|
root = Path(root).expanduser()
|
|
761
839
|
except (RuntimeError, ValueError):
|
|
@@ -769,7 +847,7 @@ def scan(root: Path) -> ScanReport:
|
|
|
769
847
|
for names, scanner in groups:
|
|
770
848
|
for name in names:
|
|
771
849
|
path = root / name
|
|
772
|
-
if path
|
|
850
|
+
if is_readable_file(path):
|
|
773
851
|
report.files_scanned += 1
|
|
774
852
|
report.findings.extend(_safe_scan_file(scanner, path, name))
|
|
775
853
|
report.findings.sort(key=lambda f: (-PENALTY[f.severity], f.rule))
|
|
@@ -8,14 +8,24 @@ permissions themselves live in the user settings. A tool that reports a
|
|
|
8
8
|
clean bill of health because it only looked at one of them is worse than
|
|
9
9
|
no tool.
|
|
10
10
|
|
|
11
|
-
Exit codes: 0 = grade A/B, 1 = grade C/D, 2 = grade F
|
|
12
|
-
finding.
|
|
11
|
+
Exit codes: 0 = grade A/B, 1 = grade C/D, 2 = grade F, a CRITICAL
|
|
12
|
+
finding, or a REFUSAL. A refusal is a root we could not read: any root
|
|
13
|
+
the operator NAMED, every root when none was readable, or ANY root —
|
|
14
|
+
the defaults included — that raised while we scanned it (mode 000, a
|
|
15
|
+
root-owned config, a container uid mismatch). Only a MISSING default
|
|
16
|
+
root is exempt: a fresh machine has no ``~/.claude``, so it is noted on
|
|
17
|
+
stderr and the readable roots set the code; failing there would break
|
|
18
|
+
every CI container. CI can gate on the exit code. A refusal is never a
|
|
19
|
+
pass: a config we never opened cannot be graded, and saying "nothing to
|
|
20
|
+
report" about it would be the clean-looking run this tool exists to
|
|
21
|
+
prevent.
|
|
13
22
|
"""
|
|
14
23
|
|
|
15
24
|
from __future__ import annotations
|
|
16
25
|
|
|
17
26
|
import argparse
|
|
18
27
|
import json
|
|
28
|
+
import stat
|
|
19
29
|
import sys
|
|
20
30
|
from pathlib import Path
|
|
21
31
|
|
|
@@ -36,8 +46,17 @@ _ICON = {
|
|
|
36
46
|
|
|
37
47
|
|
|
38
48
|
def merge(reports: list[ScanReport]) -> ScanReport:
|
|
39
|
-
"""One grade for the operator, not one per file they must add up.
|
|
40
|
-
|
|
49
|
+
"""One grade for the operator, not one per file they must add up.
|
|
50
|
+
|
|
51
|
+
Merging nothing raises. An empty list produced a grade-A 100/100
|
|
52
|
+
report, so every caller that forgot to check got a clean bill of
|
|
53
|
+
health for a scan that never happened — twice in this module, and
|
|
54
|
+
the next caller would inherit it (QG C3 r7, Francisca M1). Grading
|
|
55
|
+
nothing is a programming error, not a passing grade.
|
|
56
|
+
"""
|
|
57
|
+
if not reports:
|
|
58
|
+
raise ValueError("merge() of no reports: nothing was scanned")
|
|
59
|
+
merged = ScanReport(root=reports[0].root)
|
|
41
60
|
for report in reports:
|
|
42
61
|
merged.files_scanned += report.files_scanned
|
|
43
62
|
for finding in report.findings:
|
|
@@ -98,7 +117,69 @@ def _roots(args: argparse.Namespace) -> list[Path]:
|
|
|
98
117
|
return [Path.home() / ".claude", Path.cwd()]
|
|
99
118
|
|
|
100
119
|
|
|
101
|
-
def
|
|
120
|
+
def _partition_roots(
|
|
121
|
+
args: argparse.Namespace,
|
|
122
|
+
) -> tuple[list[Path], list[Path], bool]:
|
|
123
|
+
"""Split the requested roots, and say whether the OPERATOR named them.
|
|
124
|
+
|
|
125
|
+
Provenance travels with the partition rather than being re-derived
|
|
126
|
+
from ``args.path`` at each use: the wording and the exit code both
|
|
127
|
+
depend on it, and two derivations five lines apart is the structural
|
|
128
|
+
precondition of every defect this module produced across six rounds
|
|
129
|
+
(QG C3 r7, Francisca M3).
|
|
130
|
+
"""
|
|
131
|
+
requested = _roots(args)
|
|
132
|
+
readable = [root for root in requested if _looks_like_dir(root)]
|
|
133
|
+
unreadable = [root for root in requested if root not in readable]
|
|
134
|
+
return readable, unreadable, bool(args.path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _looks_like_dir(root: Path) -> bool:
|
|
138
|
+
"""Is `root` a directory we should try to scan?
|
|
139
|
+
|
|
140
|
+
"We could not tell" must answer YES, so the root reaches `_safe_scan`
|
|
141
|
+
and comes back as a REFUSAL. Answering NO would file it under
|
|
142
|
+
"skipped", which for a DEFAULT root exits 0 — a clean run over a
|
|
143
|
+
config nobody read. `Path.is_dir()` cannot express that: it raises on
|
|
144
|
+
3.13 (an unhandled traceback out of `main`) and swallows on 3.14
|
|
145
|
+
(silently skipped), so neither interpreter produced the refusal
|
|
146
|
+
(QG C3 r9, Francisca B1, sibling of the scan() probe).
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
return stat.S_ISDIR(root.stat().st_mode)
|
|
150
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
151
|
+
return False # genuinely absent — the exemption case
|
|
152
|
+
except OSError:
|
|
153
|
+
return True # cannot tell: let _safe_scan refuse it by name
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _skipped_notice(readable: list[Path], skipped: list[Path],
|
|
157
|
+
named: bool) -> str:
|
|
158
|
+
"""Name every requested root that was never opened.
|
|
159
|
+
|
|
160
|
+
A root the operator NAMED and we silently dropped is a clean bill of
|
|
161
|
+
health for something we never read: `shield ~/.claude/settings.json`
|
|
162
|
+
— the file rather than its directory, a normal invocation — was
|
|
163
|
+
answered with "Grade A (100/100) — nothing to report." on the read-only
|
|
164
|
+
path and with a bare exit 2 on --fix (QG C3 r5, Francisca B2).
|
|
165
|
+
|
|
166
|
+
REFUSED is used exactly when the run will exit 2 for this reason, so
|
|
167
|
+
the word and the exit code never disagree (QG C3 r6, Francisca B1).
|
|
168
|
+
A missing DEFAULT root is a note instead: a fresh machine has no
|
|
169
|
+
~/.claude and must not fail CI over it. Written to stderr so
|
|
170
|
+
`--json` stdout stays parseable.
|
|
171
|
+
"""
|
|
172
|
+
paths = ", ".join(str(root) for root in skipped)
|
|
173
|
+
subject = ("a readable directory" if len(skipped) == 1
|
|
174
|
+
else "readable directories")
|
|
175
|
+
if not readable:
|
|
176
|
+
return f"REFUSED: no readable config root — {paths}"
|
|
177
|
+
if named:
|
|
178
|
+
return f"REFUSED: not {subject} — {paths}"
|
|
179
|
+
return f"note: skipped, not {subject} — {paths}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
102
183
|
parser = argparse.ArgumentParser(
|
|
103
184
|
prog="arkaos shield",
|
|
104
185
|
description=(
|
|
@@ -107,7 +188,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
107
188
|
"injection, unpinned MCP servers, prompt injection in "
|
|
108
189
|
"instruction files."
|
|
109
190
|
),
|
|
110
|
-
epilog=
|
|
191
|
+
epilog=(
|
|
192
|
+
"Exit: 0 = A/B, 1 = C/D, 2 = F, any CRITICAL finding, or a "
|
|
193
|
+
"root we could not read — any root YOU named, every root "
|
|
194
|
+
"when none was readable, or any root that raised while we "
|
|
195
|
+
"scanned it. Only a MISSING default root (~/.claude on a "
|
|
196
|
+
"fresh machine) is noted on stderr instead of failing the run."
|
|
197
|
+
),
|
|
111
198
|
)
|
|
112
199
|
parser.add_argument(
|
|
113
200
|
"path", nargs="*",
|
|
@@ -117,14 +204,139 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
117
204
|
"--json", action="store_true", dest="as_json",
|
|
118
205
|
help="Machine-readable output.",
|
|
119
206
|
)
|
|
207
|
+
_add_fix_arguments(parser)
|
|
208
|
+
return parser
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _add_fix_arguments(parser: argparse.ArgumentParser) -> None:
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"--fix", action="store_true",
|
|
214
|
+
help=(
|
|
215
|
+
"Plan the mechanical fixes (dangerous-command allow rules, "
|
|
216
|
+
"missing deny list). Prints every finding it cannot fix and "
|
|
217
|
+
"why — unscoped rules need your pattern, so they are named, "
|
|
218
|
+
"never dropped. Writes nothing without --apply."
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"--apply", action="store_true",
|
|
223
|
+
help="With --fix: write the changes, after a timestamped backup.",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv: list[str] | None = None) -> int:
|
|
228
|
+
parser = _build_parser()
|
|
120
229
|
args = parser.parse_args(argv)
|
|
230
|
+
if args.apply and not args.fix:
|
|
231
|
+
# Silently degrading a destructive flag into a read-only scan is
|
|
232
|
+
# a trap: the operator believes they wrote (QG C3 r1, Francisca M1).
|
|
233
|
+
parser.error("--apply requires --fix")
|
|
234
|
+
|
|
235
|
+
roots, skipped, named = _partition_roots(args)
|
|
236
|
+
if skipped:
|
|
237
|
+
print(_skipped_notice(roots, skipped, named), file=sys.stderr)
|
|
238
|
+
if not roots:
|
|
239
|
+
return 2 # nothing was read, so nothing can be graded
|
|
240
|
+
code = _fix(roots, args) if args.fix else _grade(roots, args)
|
|
241
|
+
if skipped and named:
|
|
242
|
+
# A root the OPERATOR NAMED and we never opened is a refusal
|
|
243
|
+
# even when the others reported: the run cannot claim to have
|
|
244
|
+
# graded what it was asked for (QG C3 r6, Francisca B1). Only a
|
|
245
|
+
# MISSING default root is exempt — a fresh machine has no
|
|
246
|
+
# ~/.claude. A default root that EXISTS and cannot be read is
|
|
247
|
+
# refused by _grade/_fix, not here (QG C3 r7, Eduardo B1).
|
|
248
|
+
return 2
|
|
249
|
+
return code
|
|
250
|
+
|
|
121
251
|
|
|
122
|
-
|
|
252
|
+
def _grade(roots: list[Path], args: argparse.Namespace) -> int:
|
|
253
|
+
"""Read-only path: grade what we could read, refuse the rest.
|
|
254
|
+
|
|
255
|
+
Guarded for the same reason the fix path is: `scan` raises on a root
|
|
256
|
+
it cannot list, and leaving one call site bare only moves the
|
|
257
|
+
traceback (QG C3 r4, Francisca B2).
|
|
258
|
+
"""
|
|
259
|
+
reports, refusals = [], []
|
|
260
|
+
for root in roots:
|
|
261
|
+
result = _safe_scan(root)
|
|
262
|
+
if isinstance(result, str):
|
|
263
|
+
refusals.append(result)
|
|
264
|
+
else:
|
|
265
|
+
reports.append(result)
|
|
266
|
+
for refusal in refusals:
|
|
267
|
+
print(refusal, file=sys.stderr)
|
|
268
|
+
if not reports:
|
|
269
|
+
return 2
|
|
270
|
+
report = merge(reports)
|
|
123
271
|
if args.as_json:
|
|
124
272
|
print(json.dumps(report.to_dict(), indent=2))
|
|
125
273
|
else:
|
|
126
274
|
print(render(report))
|
|
127
|
-
return exit_code(report)
|
|
275
|
+
return 2 if refusals else exit_code(report)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _safe_scan(root: Path) -> ScanReport | str:
|
|
279
|
+
"""Scan a root, or say why it could not be scanned.
|
|
280
|
+
|
|
281
|
+
``scan`` is not never-raises: an unreadable ROOT (mode 000, a
|
|
282
|
+
root-owned config, a container uid mismatch) passes the is_dir()
|
|
283
|
+
filter and then raises PermissionError from the first is_file()
|
|
284
|
+
(QG C3 r4, Francisca B2, reproduced end to end). A security tool
|
|
285
|
+
that answers a locked directory with a traceback has told the
|
|
286
|
+
operator nothing.
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
return scan(root)
|
|
290
|
+
except OSError as exc:
|
|
291
|
+
return f"REFUSED: cannot scan {root} — {exc}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _fix(roots: list[Path], args: argparse.Namespace) -> int:
|
|
295
|
+
"""Remediate each root, then grade what is left.
|
|
296
|
+
|
|
297
|
+
The exit code stays the scanner's contract and is computed from the
|
|
298
|
+
RE-SCAN: a fix run that leaves criticals still exits 2.
|
|
299
|
+
"""
|
|
300
|
+
payloads, rendered, graded = [], [], []
|
|
301
|
+
for root in roots:
|
|
302
|
+
payload, text, after = _fix_one(root, args)
|
|
303
|
+
payloads.append(payload)
|
|
304
|
+
rendered.append(text)
|
|
305
|
+
if after is not None:
|
|
306
|
+
graded.append(after)
|
|
307
|
+
if args.as_json:
|
|
308
|
+
print(json.dumps(payloads, indent=2))
|
|
309
|
+
else:
|
|
310
|
+
print("\n\n".join(rendered))
|
|
311
|
+
if not graded or len(graded) < len(roots):
|
|
312
|
+
# `not graded` is not redundant with the caller's guard: without
|
|
313
|
+
# it, exit_code(merge([])) grades an empty read as A and returns
|
|
314
|
+
# 0 — the r5 B2 defect surviving one level down, where the two
|
|
315
|
+
# guards above only masked it (QG C3 r6).
|
|
316
|
+
return 2
|
|
317
|
+
return exit_code(merge(graded))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _fix_one(root: Path,
|
|
321
|
+
args: argparse.Namespace) -> tuple[dict, str, ScanReport | None]:
|
|
322
|
+
"""Remediate one root and re-grade it; None means the re-scan refused.
|
|
323
|
+
|
|
324
|
+
The re-scan happens ONCE and its report is what both the printed
|
|
325
|
+
grade and the exit code are computed from. Scanning a second time to
|
|
326
|
+
derive the exit code graded a different read than the one on screen
|
|
327
|
+
(QG C3 r5, Francisca M1), and dropped what `fix` itself planned when
|
|
328
|
+
the re-scan refused (her M3).
|
|
329
|
+
"""
|
|
330
|
+
from core.harness import remediation
|
|
331
|
+
|
|
332
|
+
fix_report = remediation.fix(root, apply=args.apply)
|
|
333
|
+
after = _safe_scan(root)
|
|
334
|
+
if isinstance(after, str):
|
|
335
|
+
return ({"root": str(root), **fix_report.to_dict(), "refused": after},
|
|
336
|
+
f"{root}\n{remediation.render(fix_report)}\n{after}", None)
|
|
337
|
+
return ({"root": str(root), **fix_report.to_dict(),
|
|
338
|
+
"grade_after": after.grade},
|
|
339
|
+
f"{root}\n{remediation.render(fix_report, after)}", after)
|
|
128
340
|
|
|
129
341
|
|
|
130
342
|
if __name__ == "__main__":
|