arkaos 4.15.0 → 4.17.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.
@@ -0,0 +1,776 @@
1
+ """Harness config scanner — the agent's own configuration as attack surface.
2
+
3
+ Everything ArkaOS ships assumes the harness under it is trustworthy, and
4
+ nothing in the tree ever checked. ``doctor`` asks whether the install is
5
+ *healthy*; ``leak_scanner`` asks whether our *source* leaks client names;
6
+ ``mcp-policy`` is about load performance. None of them read the settings
7
+ file, the hook commands, or the MCP servers that actually execute on the
8
+ operator's machine and ask whether they are safe.
9
+
10
+ The threat is concrete. A ``permissions.allow`` list with no ``deny`` is
11
+ a blank cheque the moment auto mode is on. A hook command that
12
+ interpolates an agent-controlled variable into a shell string is command
13
+ injection with the agent as the delivery mechanism. An MCP server pinned
14
+ to ``@latest`` hands a third party the right to change what runs on the
15
+ machine tomorrow, silently.
16
+
17
+ Read-only by contract. ``scan()`` takes a directory and returns findings
18
+ with a letter grade; it prints nothing, exits nothing, and touches
19
+ nothing. The ``--fix`` path is a later slice and must be able to trust
20
+ that a scan never mutated what it measured.
21
+
22
+ Never raises on hostile input. A settings file that is truncated,
23
+ binary, or full of nulls is a FINDING, not a traceback — a scanner that
24
+ dies on the config it was pointed at reports nothing at all, which is
25
+ the worst possible outcome for a security tool.
26
+
27
+ Engine behind ``npx arkaos shield`` and the ``doctor`` advisory section.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ import stat
35
+ from dataclasses import dataclass, field
36
+ from enum import StrEnum
37
+ from pathlib import Path
38
+
39
+ _MAX_FILE_BYTES = 2 * 1024 * 1024
40
+
41
+
42
+ class Severity(StrEnum):
43
+ """How much a finding costs. Declaration order is report order."""
44
+
45
+ CRITICAL = "critical"
46
+ HIGH = "high"
47
+ MEDIUM = "medium"
48
+ LOW = "low"
49
+
50
+
51
+ # Points deducted from 100, per finding.
52
+ PENALTY: dict[Severity, int] = {
53
+ Severity.CRITICAL: 25,
54
+ Severity.HIGH: 12,
55
+ Severity.MEDIUM: 5,
56
+ Severity.LOW: 2,
57
+ }
58
+
59
+ _GRADE_FLOORS = ((90, "A"), (80, "B"), (70, "C"), (60, "D"))
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Finding:
64
+ """One defect in the harness configuration.
65
+
66
+ ``fix`` is not optional. A severity with no remediation is noise the
67
+ operator learns to scroll past.
68
+ """
69
+
70
+ rule: str
71
+ severity: Severity
72
+ where: str
73
+ detail: str
74
+ fix: str
75
+
76
+ def to_dict(self) -> dict:
77
+ return {
78
+ "rule": self.rule,
79
+ "severity": self.severity.value,
80
+ "where": self.where,
81
+ "detail": self.detail,
82
+ "fix": self.fix,
83
+ }
84
+
85
+
86
+ @dataclass
87
+ class ScanReport:
88
+ """The result of scanning one harness config tree."""
89
+
90
+ root: Path
91
+ files_scanned: int = 0
92
+ findings: list[Finding] = field(default_factory=list)
93
+
94
+ @property
95
+ def score(self) -> int:
96
+ deducted = sum(PENALTY[f.severity] for f in self.findings)
97
+ return max(0, 100 - deducted)
98
+
99
+ @property
100
+ def grade(self) -> str:
101
+ # A single CRITICAL is a fail, whatever the arithmetic says. The
102
+ # letter a human reads and the exit code CI gates on must point
103
+ # the same direction — one CRITICAL scoring 75 (a "C") while the
104
+ # process exits 2 is a mixed signal, and mixed signals get
105
+ # security tools ignored.
106
+ if self.by_severity(Severity.CRITICAL):
107
+ return "F"
108
+ for floor, letter in _GRADE_FLOORS:
109
+ if self.score >= floor:
110
+ return letter
111
+ return "F"
112
+
113
+ def by_severity(self, severity: Severity) -> list[Finding]:
114
+ return [f for f in self.findings if f.severity is severity]
115
+
116
+ def to_dict(self) -> dict:
117
+ return {
118
+ "root": str(self.root),
119
+ "files_scanned": self.files_scanned,
120
+ "score": self.score,
121
+ "grade": self.grade,
122
+ "findings": [f.to_dict() for f in self.findings],
123
+ }
124
+
125
+
126
+ # --- secrets ---------------------------------------------------------------
127
+
128
+ _SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
129
+ ("Anthropic key", re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}")),
130
+ ("OpenAI key", re.compile(r"sk-[A-Za-z0-9]{32,}")),
131
+ ("GitHub token", re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}")),
132
+ ("AWS key id", re.compile(r"AKIA[0-9A-Z]{16}")),
133
+ ("Slack token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}")),
134
+ ("private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
135
+ )
136
+ _SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
137
+ # Name suffixes that mean "a pointer TO the secret", not the secret:
138
+ # GOOGLE_APPLICATION_CREDENTIALS is a path, API_KEY_HELPER is a command.
139
+ _POINTER_NAME = re.compile(
140
+ r"(_FILE|_PATH|_HELPER|_CMD|_COMMAND|_DIR|CREDENTIALS)$", re.I
141
+ )
142
+ # A value bound to a reference, a placeholder, or a filesystem path /
143
+ # command is not a leaked secret.
144
+ _NOT_A_SECRET = re.compile(
145
+ r"^(\$\{?\w+\}?|<[^>]+>|x{3,}|your[-_ ]|changeme|\.{3}|example|"
146
+ r"[~/.]|[A-Za-z]:[\\/])",
147
+ re.IGNORECASE,
148
+ )
149
+
150
+
151
+ def secret_labels(text: str) -> list[str]:
152
+ """Named secret patterns present in a blob of text."""
153
+ if not isinstance(text, str):
154
+ return []
155
+ return [label for label, pat in _SECRET_PATTERNS if pat.search(text)]
156
+
157
+
158
+ def is_secret_binding(name: str, value: object) -> bool:
159
+ """A KEY/TOKEN-named variable bound to a literal secret, not to a
160
+ reference, a placeholder, or a path/command that POINTS at one.
161
+
162
+ The false positive that kills a security tool is flagging
163
+ ``GOOGLE_APPLICATION_CREDENTIALS=/path/sa.json`` — a pointer, not a
164
+ secret. A name-based match only survives if the value also looks
165
+ like an opaque credential; a value matching a real secret pattern
166
+ (``secret_labels``) fires regardless of the name.
167
+ """
168
+ if not isinstance(value, str) or len(value.strip()) < 20:
169
+ return False
170
+ if secret_labels(value):
171
+ return True # value IS a credential — name is irrelevant
172
+ if _NOT_A_SECRET.match(value.strip()):
173
+ return False
174
+ if not isinstance(name, str) or _POINTER_NAME.search(name):
175
+ return False
176
+ return bool(_SECRET_NAME.search(name))
177
+
178
+
179
+ # --- permissions -----------------------------------------------------------
180
+
181
+ # Claude Code permission-rule syntax is `Tool` or `Tool(pattern)`, where
182
+ # a Bash pattern is `command:args` (`Bash(git status:*)`). A rule is
183
+ # UNSCOPED when it grants the whole tool: bare `Bash`, `Bash()`,
184
+ # `Bash(*)`, or `Bash(*:*)` / `Bash(:*)` — the last two are the runtime's
185
+ # own "allow everything" forms, which the first draft missed entirely.
186
+ _UNSCOPED_RULE = re.compile(
187
+ r"^(Bash|Write|Edit|MultiEdit|WebFetch|NotebookEdit|Read)"
188
+ r"(\(\s*\*?\s*(:\s*\*\s*)?\))?$"
189
+ )
190
+ # The command a Bash rule authorises is the part before the first `:`.
191
+ _BASH_RULE = re.compile(r"^Bash\((?P<body>.*)\)$", re.DOTALL)
192
+ # Commands that are dangerous the moment they are allowed at all — a
193
+ # rule like `Bash(rm:*)` grants every rm, so the bare command name in
194
+ # the authorised position is enough. Matched against the command word
195
+ # of a Bash rule (`Bash(<cmd>:args)`), not against free text.
196
+ #
197
+ # Interpreters and shells are the load-bearing entries: allowing one is
198
+ # arbitrary code execution the instant the rule is active
199
+ # (`python -c '...'`, `sh -c '<anything>'`, `node -e`). They are a strict
200
+ # superset of `eval`, which is flagged — and the MCP surface
201
+ # (mcp-shell-command) and hook surface (_EVAL_OF_AGENT_VAR) already treat
202
+ # a raw shell as dangerous, so leaving them clean HERE was the scanner
203
+ # contradicting itself.
204
+ _SHELL_COMMANDS = ("sh", "bash", "zsh", "fish", "dash", "ksh")
205
+ # Lowercase only — the command word is lowercased before lookup, so a
206
+ # capitalized key here can never match (the Rscript bug: an entry the
207
+ # data declared dangerous that the code reported safe).
208
+ _INTERPRETERS = (
209
+ "python", "python3", "node", "deno", "bun", "perl", "ruby", "php",
210
+ "osascript", "rscript",
211
+ )
212
+ _DANGEROUS_COMMANDS = {
213
+ "rm": "delete", "sudo": "privilege escalation", "eval": "eval",
214
+ "chmod": "permission change", "chown": "ownership change",
215
+ "curl": "network fetch", "wget": "network fetch", "nc": "raw socket",
216
+ "ncat": "raw socket", "dd": "raw disk write", "mkfs": "filesystem format",
217
+ "shutdown": "shutdown", "reboot": "reboot",
218
+ **{s: "arbitrary shell execution" for s in _SHELL_COMMANDS},
219
+ **{i: "arbitrary code execution" for i in _INTERPRETERS},
220
+ }
221
+ # Dangerous phrasings that need the full string, not just the command.
222
+ _DANGEROUS_RULE: tuple[tuple[re.Pattern[str], str], ...] = (
223
+ (re.compile(r"\brm\b.*(-[a-z]*r|--recursive)"), "recursive delete"),
224
+ (re.compile(r"\bsudo\b"), "privilege escalation"),
225
+ (re.compile(r"\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh"), "download piped to a shell"),
226
+ (re.compile(r"\bchmod\b.*(777|-R\s+7)"), "world-writable chmod"),
227
+ (re.compile(r"\beval\b"), "eval of arbitrary input"),
228
+ (re.compile(r"\bgit\s+push\s+(--force|-f)\b"), "force push"),
229
+ (re.compile(r"dangerously-skip-permissions"), "permission bypass"),
230
+ (re.compile(r"git\s+reset\s+--hard"), "hard reset"),
231
+ )
232
+
233
+
234
+ def _as_list(value: object) -> list:
235
+ """A config value that should be a list, made safe to iterate.
236
+
237
+ ``permissions.allow`` bound to a scalar is not just invalid — it is
238
+ the shape an attacker or a typo produces, and ``len()``/iteration on
239
+ it is the crash this scanner exists to survive.
240
+ """
241
+ return value if isinstance(value, list) else []
242
+
243
+
244
+ def _as_dict(value: object) -> dict:
245
+ return value if isinstance(value, dict) else {}
246
+
247
+
248
+ def _bash_command_word(rule: str) -> str | None:
249
+ """The command a `Bash(cmd:args)` rule authorises, lowercased."""
250
+ match = _BASH_RULE.match(rule.strip())
251
+ if not match:
252
+ return None
253
+ body = match.group("body").split(":", 1)[0].strip()
254
+ words = body.split()
255
+ if not words:
256
+ return None
257
+ return words[0].rsplit("/", 1)[-1].lower() or None
258
+
259
+
260
+ def _unscoped_finding(rule: str, where: str) -> Finding:
261
+ tool = rule.split("(")[0]
262
+ return Finding(
263
+ rule="settings-unscoped-allow",
264
+ severity=Severity.CRITICAL,
265
+ where=where,
266
+ detail=f"allow rule {rule!r} grants the whole tool — every "
267
+ f"invocation, with no pattern to constrain it.",
268
+ fix=f"Scope it: {tool}({_SCOPE_HINT.get(tool, '<pattern>')}).",
269
+ )
270
+ _BYPASS_MODES = {"bypassPermissions", "acceptEdits"}
271
+ # A remediation the operator can paste. A generic "<pattern>" for a tool
272
+ # whose patterns are paths is a hint nobody acts on.
273
+ _SCOPE_HINT = {
274
+ "Bash": "git status*",
275
+ "Write": "src/**",
276
+ "Edit": "src/**",
277
+ "MultiEdit": "src/**",
278
+ "NotebookEdit": "notebooks/**",
279
+ "WebFetch": "domain:docs.anthropic.com",
280
+ }
281
+
282
+
283
+ def _no_deny_finding(allow: list, where: str) -> Finding:
284
+ return Finding(
285
+ rule="settings-no-deny",
286
+ severity=Severity.HIGH,
287
+ where=where,
288
+ detail=(
289
+ f"{len(allow)} allow rules and no deny list. An allow list "
290
+ f"cannot express 'never do this even when a broader rule "
291
+ f"matches' — in auto mode that is a blank cheque."
292
+ ),
293
+ fix="Add a permissions.deny list — see the hard-deny defaults in "
294
+ "the ArkaOS install, or Claude Code's permission docs.",
295
+ )
296
+
297
+
298
+ def _bypass_mode_finding(mode: str, where: str) -> Finding:
299
+ return Finding(
300
+ rule="settings-bypass-mode",
301
+ severity=Severity.CRITICAL,
302
+ where=where,
303
+ detail=f"defaultMode is {mode!r} with no permissions.deny list to "
304
+ f"stop it.",
305
+ fix='Set defaultMode to "default", or add a permissions.deny list.',
306
+ )
307
+
308
+
309
+ def _check_permissions(perms: dict, where: str) -> list[Finding]:
310
+ """allow/deny/defaultMode — the blast radius of auto mode."""
311
+ findings: list[Finding] = []
312
+ allow = _as_list(perms.get("allow"))
313
+ deny = _as_list(perms.get("deny"))
314
+ if allow and not deny:
315
+ findings.append(_no_deny_finding(allow, where))
316
+ if perms.get("defaultMode") in _BYPASS_MODES and not deny:
317
+ findings.append(_bypass_mode_finding(perms["defaultMode"], where))
318
+ findings.extend(_check_allow_rules(allow, where))
319
+ return findings
320
+
321
+
322
+ def _dangerous_allow_finding(rule: str, where: str) -> Finding | None:
323
+ """A CRITICAL if the allow rule authorises a dangerous command,
324
+ whether by its command word (`Bash(rm:*)`) or its phrasing."""
325
+ command = _bash_command_word(rule)
326
+ if command in _DANGEROUS_COMMANDS:
327
+ return Finding(
328
+ rule="settings-dangerous-allow",
329
+ severity=Severity.CRITICAL,
330
+ where=where,
331
+ detail=f"allow rule {rule!r} authorises `{command}` "
332
+ f"({_DANGEROUS_COMMANDS[command]}) — every invocation "
333
+ f"of it, whatever the arguments.",
334
+ fix="Remove it, or narrow the pattern to the exact "
335
+ "invocations you trust.",
336
+ )
337
+ for pattern, label in _DANGEROUS_RULE:
338
+ if pattern.search(rule):
339
+ return Finding(
340
+ rule="settings-dangerous-allow",
341
+ severity=Severity.CRITICAL,
342
+ where=where,
343
+ detail=f"allow rule {rule!r} permits {label}.",
344
+ fix="Remove it. If it is genuinely needed, gate it behind "
345
+ "an explicit prompt rather than an allow.",
346
+ )
347
+ return None
348
+
349
+
350
+ def _check_allow_rules(allow: list, where: str) -> list[Finding]:
351
+ findings: list[Finding] = []
352
+ for rule in allow:
353
+ if not isinstance(rule, str):
354
+ continue
355
+ if _UNSCOPED_RULE.match(rule.strip()):
356
+ findings.append(_unscoped_finding(rule, where))
357
+ continue
358
+ danger = _dangerous_allow_finding(rule, where)
359
+ if danger is not None:
360
+ findings.append(danger)
361
+ return findings
362
+
363
+
364
+ def _check_env(env: dict, where: str) -> list[Finding]:
365
+ """Secrets bound literally into the config."""
366
+ return [
367
+ Finding(
368
+ rule="settings-secret-in-env",
369
+ severity=Severity.CRITICAL,
370
+ where=where,
371
+ detail=f"{name} is bound to a literal value in the config file.",
372
+ fix="Move it to the OS keychain or ~/.arkaos/keys.json and "
373
+ "reference it, then rotate the exposed value.",
374
+ )
375
+ for name, value in env.items()
376
+ if is_secret_binding(str(name), value)
377
+ ]
378
+
379
+
380
+ # --- hooks -----------------------------------------------------------------
381
+
382
+ # An agent-controlled value interpolated into a shell string, unquoted.
383
+ # Everything the agent touches — the file it edited, the command it ran —
384
+ # reaches a hook through one of these.
385
+ _AGENT_VAR = r"\$\{?(CLAUDE_[A-Z_]+|TOOL_[A-Z_]+|file|file_path)\b"
386
+ _UNQUOTED_INTERPOLATION = re.compile(rf"""(?<!['"]){_AGENT_VAR}""")
387
+ # `eval "$X"` / `sh -c "$X"` runs the value even when it IS quoted —
388
+ # the quoting protects the surrounding shell, not the eval'd string.
389
+ _EVAL_OF_AGENT_VAR = re.compile(
390
+ rf"""\b(eval|(ba|z)?sh\s+-c)\b[^\n]*{_AGENT_VAR}"""
391
+ )
392
+ _SILENCED = re.compile(r"(2>\s*/dev/null|\|\|\s*true)\s*$")
393
+
394
+
395
+ def _check_hook_command(command: str, where: str) -> list[Finding]:
396
+ findings: list[Finding] = []
397
+ if _UNQUOTED_INTERPOLATION.search(command) or \
398
+ _EVAL_OF_AGENT_VAR.search(command):
399
+ findings.append(Finding(
400
+ rule="hook-command-injection",
401
+ severity=Severity.CRITICAL,
402
+ where=where,
403
+ detail=(
404
+ "an agent-controlled variable is interpolated into the "
405
+ "shell command unquoted — a filename containing `; rm -rf ~` "
406
+ "executes."
407
+ ),
408
+ fix='Quote it: "$CLAUDE_FILE_PATH". Better: read the payload '
409
+ "from stdin instead of the environment.",
410
+ ))
411
+ if _SILENCED.search(command):
412
+ findings.append(Finding(
413
+ rule="hook-silences-errors",
414
+ severity=Severity.MEDIUM,
415
+ where=where,
416
+ detail="the hook discards its own errors, so a hook that has "
417
+ "stopped working looks identical to one that works.",
418
+ fix="Let it fail loudly, or log to a file you actually read.",
419
+ ))
420
+ return findings
421
+
422
+
423
+ def _check_hook_script(command: str, where: str) -> list[Finding]:
424
+ """The file the hook executes — does it exist, can anyone rewrite it?"""
425
+ path = _script_path(command)
426
+ if path is None:
427
+ return []
428
+ if not path.exists():
429
+ return [Finding(
430
+ rule="hook-script-missing",
431
+ severity=Severity.HIGH,
432
+ where=where,
433
+ detail=f"the hook points at {path}, which does not exist — the "
434
+ f"hook silently does nothing.",
435
+ fix="Restore the script, or remove the hook. If it is an "
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
+ )]
448
+ return []
449
+
450
+
451
+ def _script_path(command: str) -> Path | None:
452
+ """The leading absolute path of a hook command, when it has one.
453
+
454
+ ``expanduser`` on ``~unknownuser/x`` raises RuntimeError on some
455
+ platforms — and a ``~baduser`` path is exactly the attacker-authored
456
+ input this scanner must survive, not crash on.
457
+ """
458
+ words = command.strip().split()
459
+ first = words[0] if words else ""
460
+ if not first.startswith(("/", "~")):
461
+ return None
462
+ try:
463
+ return Path(first).expanduser()
464
+ except (RuntimeError, ValueError):
465
+ return Path(first)
466
+
467
+
468
+ def _is_group_or_world_writable(path: Path) -> bool:
469
+ try:
470
+ mode = path.stat().st_mode
471
+ except OSError:
472
+ return False
473
+ return bool(mode & (stat.S_IWGRP | stat.S_IWOTH))
474
+
475
+
476
+ def _check_hooks(hooks: dict, where: str) -> list[Finding]:
477
+ findings: list[Finding] = []
478
+ for event, entries in _as_dict(hooks).items():
479
+ for command in _hook_commands(entries):
480
+ at = f"{where} [{event}]"
481
+ findings.extend(_check_hook_command(command, at))
482
+ findings.extend(_check_hook_script(command, at))
483
+ return findings
484
+
485
+
486
+ def _hook_commands(entries: object) -> list[str]:
487
+ """Every `command` string under one hook event, however nested."""
488
+ found: list[str] = []
489
+ if isinstance(entries, dict):
490
+ command = entries.get("command")
491
+ if isinstance(command, str):
492
+ found.append(command)
493
+ for value in entries.values():
494
+ found.extend(_hook_commands(value))
495
+ elif isinstance(entries, list):
496
+ for item in entries:
497
+ found.extend(_hook_commands(item))
498
+ return found
499
+
500
+
501
+ # --- MCP servers -----------------------------------------------------------
502
+
503
+ _RUNNERS = {"npx", "bunx", "pnpm", "uvx"}
504
+ _SHELLS = {"sh", "bash", "zsh", "fish", "dash"}
505
+ # A version pin at the END of the package spec — `@1.2.3`, `@^2`. A
506
+ # leading `@` (npm scope like `@1password/...`) is NOT a version, so the
507
+ # pattern must not match a scope that merely starts with a digit.
508
+ _PINNED = re.compile(r"[^@/]@[\dvV^~*x]")
509
+
510
+
511
+ def _check_mcp_server(name: str, config: dict, where: str) -> list[Finding]:
512
+ findings: list[Finding] = []
513
+ command = str(config.get("command") or "")
514
+ args = [str(a) for a in _as_list(config.get("args"))]
515
+ if command in _SHELLS and "-c" in args:
516
+ findings.append(Finding(
517
+ rule="mcp-shell-command",
518
+ severity=Severity.HIGH,
519
+ where=f"{where} [{name}]",
520
+ detail="the server is a shell running an arbitrary string.",
521
+ fix="Point the server at an executable, not at `sh -c`.",
522
+ ))
523
+ findings.extend(_check_mcp_package(name, command, args, where))
524
+ findings.extend(
525
+ Finding(
526
+ rule="mcp-secret-in-env",
527
+ severity=Severity.CRITICAL,
528
+ where=f"{where} [{name}]",
529
+ detail=f"{key} is bound to a literal value in the MCP config.",
530
+ fix="Reference it as ${VAR} and rotate the exposed value.",
531
+ )
532
+ for key, value in _as_dict(config.get("env")).items()
533
+ if is_secret_binding(str(key), value)
534
+ )
535
+ return findings
536
+
537
+
538
+ def _check_mcp_package(
539
+ name: str, command: str, args: list[str], where: str
540
+ ) -> list[Finding]:
541
+ """Supply chain: what version of someone else's code runs tomorrow."""
542
+ if command not in _RUNNERS:
543
+ return []
544
+ at = f"{where} [{name}]"
545
+ findings: list[Finding] = []
546
+ if any(a in ("-y", "--yes") for a in args):
547
+ findings.append(Finding(
548
+ rule="mcp-auto-install",
549
+ severity=Severity.MEDIUM,
550
+ where=at,
551
+ detail=f"`{command} -y` installs without asking.",
552
+ fix="Drop -y, or vendor the server.",
553
+ ))
554
+ package = next((a for a in args if not a.startswith("-")), None)
555
+ if package and _is_unpinned(package):
556
+ findings.append(_unpinned_finding(package, at))
557
+ return findings
558
+
559
+
560
+ def _is_unpinned(package: str) -> bool:
561
+ return package.endswith("@latest") or not _PINNED.search(package)
562
+
563
+
564
+ def _unpinned_finding(package: str, at: str) -> Finding:
565
+ return Finding(
566
+ rule="mcp-unpinned-package",
567
+ severity=Severity.HIGH,
568
+ where=at,
569
+ detail=(
570
+ f"{package!r} is not pinned to a version. Whatever the "
571
+ f"publisher ships tomorrow runs on this machine, silently, "
572
+ f"with the agent's permissions."
573
+ ),
574
+ fix=f"Pin it: {package.split('@latest')[0]}@<version>.",
575
+ )
576
+
577
+
578
+ # --- instruction files -----------------------------------------------------
579
+
580
+ _INJECTION = (
581
+ re.compile(r"ignore\s+(all\s+)?(previous|prior|above)\s+instructions", re.I),
582
+ re.compile(r"disregard\s+(all\s+)?(previous|prior|your)\b", re.I),
583
+ re.compile(r"you\s+are\s+now\s+in\s+developer\s+mode", re.I),
584
+ )
585
+ # Zero-width and bidirectional-override characters: an invisible payload
586
+ # in a file a human reviewed and believed they had read.
587
+ _INVISIBLE = re.compile(r"[​-‏‪-‮⁦-⁩]")
588
+
589
+
590
+ def _instruction_secrets(text: str, where: str) -> list[Finding]:
591
+ return [
592
+ Finding(
593
+ rule="instructions-secret",
594
+ severity=Severity.CRITICAL,
595
+ where=where,
596
+ detail=f"a {label} appears in an instruction file the agent "
597
+ f"loads into context on every session.",
598
+ fix="Remove it and rotate the key.",
599
+ )
600
+ for label in secret_labels(text)
601
+ ]
602
+
603
+
604
+ def _check_instruction_file(path: Path, where: str) -> list[Finding]:
605
+ text = _read_text(path)
606
+ if text is None:
607
+ return []
608
+ findings = _instruction_secrets(text, where)
609
+ if any(pattern.search(text) for pattern in _INJECTION):
610
+ findings.append(Finding(
611
+ rule="instructions-injection",
612
+ severity=Severity.HIGH,
613
+ where=where,
614
+ detail="the file contains prompt-injection phrasing.",
615
+ fix="Delete the injection lines. Instruction files are trusted "
616
+ "input — text pasted from elsewhere does not belong here.",
617
+ ))
618
+ if _INVISIBLE.search(text):
619
+ findings.append(Finding(
620
+ rule="instructions-invisible-characters",
621
+ severity=Severity.HIGH,
622
+ where=where,
623
+ detail="the file contains zero-width or bidirectional-override "
624
+ "characters — instructions a reviewer cannot see.",
625
+ fix="Strip them. No legitimate instruction needs them.",
626
+ ))
627
+ return findings
628
+
629
+
630
+ # --- reading ---------------------------------------------------------------
631
+
632
+
633
+ def _read_text(path: Path) -> str | None:
634
+ """File contents, or None when unreadable. Never raises."""
635
+ try:
636
+ if path.stat().st_size > _MAX_FILE_BYTES:
637
+ return None
638
+ return path.read_text(encoding="utf-8", errors="replace")
639
+ except OSError:
640
+ return None
641
+
642
+
643
+ def _unparseable(where: str, detail: str) -> Finding:
644
+ return Finding(
645
+ rule="config-unparseable",
646
+ severity=Severity.HIGH,
647
+ where=where,
648
+ detail=detail,
649
+ fix="Fix the JSON, or reinstall with `npx arkaos install --force`.",
650
+ )
651
+
652
+
653
+ def _parse_json(text: str, where: str) -> tuple[dict | None, list[Finding]]:
654
+ try:
655
+ data = json.loads(text)
656
+ except (json.JSONDecodeError, ValueError) as exc:
657
+ return None, [_unparseable(where, (
658
+ f"the file is not valid JSON ({exc}). The runtime is either "
659
+ f"ignoring it or failing on it — either way, nothing in it is "
660
+ f"in force."
661
+ ))]
662
+ except RecursionError:
663
+ # Deeply nested JSON under the size cap. json can parse it into a
664
+ # structure Python cannot walk — hostile, not fatal.
665
+ return None, [_unparseable(
666
+ where, "the file nests too deeply to process safely."
667
+ )]
668
+ if not isinstance(data, dict):
669
+ return None, [
670
+ _unparseable(where, "the file parses but is not an object.")
671
+ ]
672
+ return data, []
673
+
674
+
675
+ def _read_json(path: Path, where: str) -> tuple[dict | None, list[Finding]]:
676
+ """Parsed JSON, or a finding.
677
+
678
+ A config we cannot read is itself a finding. Skipping it silently
679
+ would report a clean bill of health for a file nobody has validated
680
+ — the one outcome a security tool must never produce.
681
+ """
682
+ text = _read_text(path)
683
+ if text is None:
684
+ return None, [Finding(
685
+ rule="config-unreadable",
686
+ severity=Severity.HIGH,
687
+ where=where,
688
+ detail="the file exists but cannot be read (too large, or "
689
+ "permissions).",
690
+ fix="Check the file size and its permissions.",
691
+ )]
692
+ return _parse_json(text, where)
693
+
694
+
695
+ # --- the scan --------------------------------------------------------------
696
+
697
+ SETTINGS_FILES = ("settings.json", "settings.local.json")
698
+ MCP_FILES = (".mcp.json", "mcp.json")
699
+ INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md", "GEMINI.md")
700
+
701
+
702
+ def _scan_settings(path: Path, where: str) -> list[Finding]:
703
+ data, problems = _read_json(path, where)
704
+ if data is None:
705
+ return problems
706
+ findings: list[Finding] = []
707
+ perms = data.get("permissions")
708
+ if isinstance(perms, dict):
709
+ findings.extend(_check_permissions(perms, where))
710
+ env = data.get("env")
711
+ if isinstance(env, dict):
712
+ findings.extend(_check_env(env, where))
713
+ hooks = data.get("hooks")
714
+ if isinstance(hooks, dict):
715
+ findings.extend(_check_hooks(hooks, where))
716
+ return findings
717
+
718
+
719
+ def _scan_mcp(path: Path, where: str) -> list[Finding]:
720
+ data, problems = _read_json(path, where)
721
+ if data is None:
722
+ return problems
723
+ servers = data.get("mcpServers")
724
+ if not isinstance(servers, dict):
725
+ return []
726
+ return [
727
+ finding
728
+ for name, config in servers.items()
729
+ if isinstance(config, dict)
730
+ for finding in _check_mcp_server(str(name), config, where)
731
+ ]
732
+
733
+
734
+ def _safe_scan_file(
735
+ scanner, path: Path, name: str
736
+ ) -> list[Finding]:
737
+ """Run one file scanner behind a backstop.
738
+
739
+ Every input path is type-guarded upstream, but "never raises" is a
740
+ contract, not a hope: an unforeseen input must degrade to a finding,
741
+ never to a traceback that the CLI reads as exit 1 and the doctor
742
+ advisory reads as silence. This is the last line.
743
+ """
744
+ try:
745
+ return scanner(path, name)
746
+ except Exception as exc: # deliberate backstop — never let a file crash the scan
747
+ return [Finding(
748
+ rule="scanner-error",
749
+ severity=Severity.LOW,
750
+ where=name,
751
+ detail=f"the scanner could not process this file ({type(exc).__name__}). "
752
+ f"It was skipped — treat as unaudited, not as clean.",
753
+ fix="Report this config shape to the ArkaOS maintainers.",
754
+ )]
755
+
756
+
757
+ def scan(root: Path) -> ScanReport:
758
+ """Scan a harness config tree. Read-only. Never raises."""
759
+ try:
760
+ root = Path(root).expanduser()
761
+ except (RuntimeError, ValueError):
762
+ root = Path(root)
763
+ report = ScanReport(root=root)
764
+ groups = (
765
+ (SETTINGS_FILES, _scan_settings),
766
+ (MCP_FILES, _scan_mcp),
767
+ (INSTRUCTION_FILES, _check_instruction_file),
768
+ )
769
+ for names, scanner in groups:
770
+ for name in names:
771
+ path = root / name
772
+ if path.is_file():
773
+ report.files_scanned += 1
774
+ report.findings.extend(_safe_scan_file(scanner, path, name))
775
+ report.findings.sort(key=lambda f: (-PENALTY[f.severity], f.rule))
776
+ return report