drydock-cli 3.0.72__tar.gz → 3.0.74__tar.gz

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 (97) hide show
  1. {drydock_cli-3.0.72/drydock_cli.egg-info → drydock_cli-3.0.74}/PKG-INFO +1 -1
  2. drydock_cli-3.0.74/drydock/cci.py +109 -0
  3. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/rmf_graph.py +13 -5
  4. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/stig.py +29 -0
  5. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/app.py +29 -27
  6. {drydock_cli-3.0.72 → drydock_cli-3.0.74/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/SOURCES.txt +2 -0
  8. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/pyproject.toml +1 -1
  9. drydock_cli-3.0.74/tests/test_cci.py +82 -0
  10. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_mcp.py +30 -0
  11. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_rmf_graph.py +26 -0
  12. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_stig.py +48 -0
  13. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_web_tools.py +33 -0
  14. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_xccdf.py +29 -0
  15. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/LICENSE +0 -0
  16. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/NOTICE +0 -0
  17. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/README.md +0 -0
  18. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/__init__.py +0 -0
  19. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/__main__.py +0 -0
  20. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/agent.py +0 -0
  21. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/bash_safety.py +0 -0
  22. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/__init__.py +0 -0
  23. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-categorize.md +0 -0
  24. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-control.md +0 -0
  25. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-poam.md +0 -0
  26. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/rmf-review.md +0 -0
  27. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-assess.md +0 -0
  28. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/builtin_skills/stig-remediate.md +0 -0
  29. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/cli.py +0 -0
  30. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/compaction.py +0 -0
  31. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/config.py +0 -0
  32. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/detect.py +0 -0
  33. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/extract.py +0 -0
  34. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/gittools.py +0 -0
  35. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/graphrag.py +0 -0
  36. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/guards.py +0 -0
  37. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/loop_detect.py +0 -0
  38. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/mcp.py +0 -0
  39. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/providers.py +0 -0
  40. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/rmf.py +0 -0
  41. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/skills.py +0 -0
  42. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tool_registry.py +0 -0
  43. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tools/__init__.py +0 -0
  44. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/__init__.py +0 -0
  45. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/approval.py +0 -0
  46. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/messages.py +0 -0
  47. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tui/widgets.py +0 -0
  48. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/tuning.py +0 -0
  49. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock/web.py +0 -0
  50. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/dependency_links.txt +0 -0
  51. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/entry_points.txt +0 -0
  52. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/requires.txt +0 -0
  53. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/drydock_cli.egg-info/top_level.txt +0 -0
  54. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/setup.cfg +0 -0
  55. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_approval.py +0 -0
  56. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_back_command.py +0 -0
  57. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_output_bounding.py +0 -0
  58. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_process_group.py +0 -0
  59. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_safety.py +0 -0
  60. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_bash_timeout_network.py +0 -0
  61. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_cli_agents.py +0 -0
  62. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_compact_command.py +0 -0
  63. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_compaction.py +0 -0
  64. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_config.py +0 -0
  65. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_config_migration.py +0 -0
  66. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_context_limit_config.py +0 -0
  67. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_detect.py +0 -0
  68. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_dispatch.py +0 -0
  69. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_e2e_connected.py +0 -0
  70. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_empty_response.py +0 -0
  71. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_extract.py +0 -0
  72. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_failure_loop.py +0 -0
  73. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_first_run_setup.py +0 -0
  74. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_gittools.py +0 -0
  75. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_graphrag.py +0 -0
  76. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_guards_and_tools.py +0 -0
  77. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_hallucinated_tools.py +0 -0
  78. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_leaked_tool_call.py +0 -0
  79. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_loop_detect.py +0 -0
  80. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_oneshot_unreachable.py +0 -0
  81. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_plan_autocontinue.py +0 -0
  82. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_providers_unreachable.py +0 -0
  83. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_read_index.py +0 -0
  84. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_rmf.py +0 -0
  85. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_rmf_stig_graph.py +0 -0
  86. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_runaway_repetition.py +0 -0
  87. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_skills.py +0 -0
  88. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_stop.py +0 -0
  89. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_streaming_newlines.py +0 -0
  90. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_subagent.py +0 -0
  91. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_system_prompt_help.py +0 -0
  92. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_todo.py +0 -0
  93. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tool_arg_parsing.py +0 -0
  94. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tools_undo.py +0 -0
  95. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tui.py +0 -0
  96. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_tuning.py +0 -0
  97. {drydock_cli-3.0.72 → drydock_cli-3.0.74}/tests/test_vision_input.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.72
3
+ Version: 3.0.74
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1,109 @@
1
+ """CCI → NIST 800-53 control mapping.
2
+
3
+ DISA's Control Correlation Identifiers (CCIs) are the bridge between a STIG rule
4
+ and the NIST control it enforces: every STIG rule cites a CCI, and the CCI maps
5
+ to an 800-53 control. This builds that map from DISA's `U_CCI_List.xml` so
6
+ `/stig graph` can auto-create `Control —SATISFIED_BY→ STIG-Rule` edges.
7
+
8
+ The mappings are U.S. Government public-domain data (facts, not copyrightable).
9
+ The list is fetched once and cached locally; offline-safe. Stdlib only.
10
+
11
+ All logic original to Drydock.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import urllib.error
18
+ import urllib.request
19
+ import xml.etree.ElementTree as ET
20
+ from pathlib import Path
21
+
22
+ # DISA U_CCI_List.xml (official list, mirrored). Government public-domain data.
23
+ _CCI_URL = ("https://raw.githubusercontent.com/CyberSecDef/Cyber.Trackr.Live/master/"
24
+ "cyber.trackr.live/resources/data/cci/U_CCI_List.xml")
25
+ _UA = "drydock-cli"
26
+ _RE_CONTROL = re.compile(r"\s*([A-Za-z]{2})-(\d+)(?:\s*\(?(\d+)\)?|\.(\d+))?")
27
+
28
+
29
+ def cache_path(cwd: str) -> Path:
30
+ return Path(cwd) / ".drydock" / "rmf" / "cci_map.json"
31
+
32
+
33
+ def _ln(el) -> str:
34
+ return el.tag.split("}")[-1]
35
+
36
+
37
+ def _norm_control(index: str) -> str | None:
38
+ """'AC-10' -> 'ac-10'; 'AC-2 (1)' / 'AC-2.1' -> 'ac-2.1'; junk -> None."""
39
+ m = _RE_CONTROL.match(index or "")
40
+ if not m:
41
+ return None
42
+ fam, num = m.group(1).lower(), m.group(2)
43
+ enh = m.group(3) or m.group(4)
44
+ return f"{fam}-{num}.{enh}" if enh else f"{fam}-{num}"
45
+
46
+
47
+ def parse_cci_list(path: str | Path) -> dict[str, str]:
48
+ """Parse U_CCI_List.xml -> {CCI-id: control-id}. Picks the highest 800-53
49
+ revision reference (excludes 800-53A, which lists assessment procedures)."""
50
+ root = ET.parse(path).getroot()
51
+ out: dict[str, str] = {}
52
+ for item in (e for e in root.iter() if _ln(e) == "cci_item"):
53
+ cid = item.get("id")
54
+ best: str | None = None
55
+ best_ver = -1
56
+ for ref in item.iter():
57
+ if _ln(ref) != "reference":
58
+ continue
59
+ title = ref.get("title", "")
60
+ if not title.startswith("NIST SP 800-53") or "800-53A" in title:
61
+ continue
62
+ try:
63
+ ver = int(ref.get("version") or 0)
64
+ except ValueError:
65
+ ver = 0
66
+ ctl = _norm_control(ref.get("index", ""))
67
+ if ctl and ver >= best_ver:
68
+ best, best_ver = ctl, ver
69
+ if cid and best:
70
+ out[cid] = best
71
+ return out
72
+
73
+
74
+ def fetch_to(path: str | Path, *, timeout: float = 60.0) -> None:
75
+ req = urllib.request.Request(_CCI_URL, headers={"User-Agent": _UA})
76
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted host)
77
+ data = r.read()
78
+ Path(path).write_bytes(data)
79
+
80
+
81
+ def load_map(cwd: str, *, refresh: bool = False) -> dict[str, str]:
82
+ """Return the cached {CCI: control} map, building it from U_CCI_List.xml on
83
+ first use (or --refresh). Offline-safe: returns the cache if the fetch fails,
84
+ or {} if there's no cache and no network."""
85
+ cp = cache_path(cwd)
86
+ if cp.exists() and not refresh:
87
+ try:
88
+ return json.loads(cp.read_text("utf-8"))
89
+ except (OSError, ValueError):
90
+ pass
91
+ cp.parent.mkdir(parents=True, exist_ok=True) # ensure .drydock/rmf/ exists before writing
92
+ raw = cp.with_suffix(".xml")
93
+ try:
94
+ fetch_to(raw)
95
+ except (urllib.error.URLError, OSError):
96
+ if cp.exists():
97
+ try:
98
+ return json.loads(cp.read_text("utf-8"))
99
+ except (OSError, ValueError):
100
+ return {}
101
+ return {}
102
+ mapping = parse_cci_list(raw)
103
+ cp.parent.mkdir(parents=True, exist_ok=True)
104
+ cp.write_text(json.dumps(mapping), "utf-8")
105
+ try:
106
+ raw.unlink()
107
+ except OSError:
108
+ pass
109
+ return mapping
@@ -126,11 +126,11 @@ def rule_node(rid: str) -> str:
126
126
  return f"stigrule:{rid.strip().lower()}"
127
127
 
128
128
 
129
- def ingest_checklist(g: "RmfGraph", checklist) -> dict:
129
+ def ingest_checklist(g: "RmfGraph", checklist, cci_map: dict | None = None) -> dict:
130
130
  """Add a parsed STIG Checklist to the typed graph: STIG + STIG-Rule nodes,
131
- PART_OF (rule→stig), APPLIES_TO (stig→host), EVALUATES (rule→host). Returns
132
- {rules, host}. The Control —SATISFIED_BY→ STIG-Rule link is asserted
133
- separately (via GraphAdd 'satisfies') since it needs the CCI→control map."""
131
+ PART_OF (rule→stig), APPLIES_TO (stig→host), EVALUATES (rule→host). When a
132
+ CCI→control map is supplied, also auto-creates Control —SATISFIED_BY→ STIG-Rule
133
+ edges via each rule's CCI. Returns {rules, host, linked}."""
134
134
  host = (checklist.asset.get("HOST_NAME") or checklist.asset.get("host_name") or "target")
135
135
  hnode = component_id(host)
136
136
  g.add_node(hnode, "Component", name=host, ip=checklist.asset.get("HOST_IP"))
@@ -138,6 +138,7 @@ def ingest_checklist(g: "RmfGraph", checklist) -> dict:
138
138
  snode = stig_id(sname)
139
139
  g.add_node(snode, "STIG", name=sname, version=checklist.stig_version)
140
140
  g.add_edge(snode, "APPLIES_TO", hnode)
141
+ linked = 0
141
142
  for r in checklist.rules:
142
143
  rid = r.rule_id or r.group_id
143
144
  if not rid:
@@ -147,7 +148,14 @@ def ingest_checklist(g: "RmfGraph", checklist) -> dict:
147
148
  severity=r.severity, status=r.status, cci=r.cci, title=r.title)
148
149
  g.add_edge(rn, "PART_OF", snode)
149
150
  g.add_edge(rn, "EVALUATES", hnode)
150
- return {"rules": len(checklist.rules), "host": host}
151
+ ctl = (cci_map or {}).get(r.cci)
152
+ if ctl:
153
+ cn = control_id(ctl)
154
+ if not g.get(cn):
155
+ g.add_node(cn, "Control", control_id=ctl.upper())
156
+ g.add_edge(cn, "SATISFIED_BY", rn)
157
+ linked += 1
158
+ return {"rules": len(checklist.rules), "host": host, "linked": linked}
151
159
 
152
160
 
153
161
  def build_from_catalog(catalog: dict, *, families: list[str] | None = None) -> RmfGraph:
@@ -281,3 +281,32 @@ def load(path: str | Path) -> Checklist:
281
281
  # sniff: JSON → cklb, else ckl
282
282
  head = Path(path).read_text("utf-8", "ignore").lstrip()[:1]
283
283
  return parse_cklb(path) if head == "{" else parse_ckl(path)
284
+
285
+
286
+ def summary_lines(cl: "Checklist", label: str, status: str | None = None) -> list[str]:
287
+ """Build the `/stig` summary view: counts, an optional status listing (never
288
+ silently truncated), and scale-aware next-step hints. Pure/presentation so it
289
+ can be unit-tested without the TUI."""
290
+ host = cl.asset.get("HOST_NAME") or cl.asset.get("host_name") or "?"
291
+ c = cl.counts()
292
+ lines = [f"STIG checklist {label} (host: {host}, format: {cl.fmt})",
293
+ f" {len(cl.rules)} rules — " + " · ".join(f"{k}={v}" for k, v in c.items())]
294
+ if status:
295
+ sf = canonical_status(status)
296
+ hits = [r for r in cl.rules if r.status == sf]
297
+ lines.append(f"\n{sf} ({len(hits)}):")
298
+ lines += [f" {r.group_id} ({r.severity}) — {r.title}" for r in hits[:50]]
299
+ if len(hits) > 50: # never silently truncate
300
+ lines.append(f" … showing first 50 of {len(hits)}; "
301
+ "open the .ckl in STIG Viewer for the full list.")
302
+ return lines
303
+ nr = c.get("not_reviewed", 0)
304
+ if nr:
305
+ lines.append(f"\nAssess the {nr} un-reviewed rule(s) — one focused turn each:"
306
+ f"\n /loop {nr} /stig-assess {label}")
307
+ if nr > 40:
308
+ lines.append(f" (large checklist — that's {nr} model turns; you can run it in "
309
+ "batches and re-run /stig to see remaining counts.)")
310
+ if c.get("open"):
311
+ lines.append(f"List open findings to remediate: /stig {label} open")
312
+ return lines
@@ -563,21 +563,10 @@ class DrydockApp(App):
563
563
  return
564
564
  # /stig graph <path> — ingest the checklist into the RMF typed graph
565
565
  if parts[0].lower() == "graph" and len(parts) > 1:
566
- from drydock import rmf_graph
567
566
  gp = parts[1] if _os.path.isabs(parts[1]) else _os.path.join(cwd, parts[1])
568
- try:
569
- cl = stig.load(gp)
570
- g = rmf_graph.RmfGraph.load(rmf_graph.graph_path(cwd))
571
- r = rmf_graph.ingest_checklist(g, cl)
572
- g.save(rmf_graph.graph_path(cwd))
573
- except Exception as e: # noqa: BLE001
574
- self._mount(ErrorMessage(f"could not graph checklist: {e}"))
575
- return
576
- self._info(
577
- f"✓ Ingested {r['rules']} STIG rules for host '{r['host']}' into the "
578
- f"RMF graph (STIG/STIG-Rule nodes, PART_OF/APPLIES_TO/EVALUATES). "
579
- "Link rules to controls with GraphAdd satisfies; trace via GraphQuery."
580
- )
567
+ self._info("Ingesting the checklist into the RMF graph "
568
+ "(fetching the DISA CCI→800-53 map on first use)…")
569
+ self.run_worker(lambda: self._stig_graph(cwd, gp), thread=True)
581
570
  return
582
571
  path = parts[0]
583
572
  if not _os.path.isabs(path):
@@ -587,19 +576,8 @@ class DrydockApp(App):
587
576
  except Exception as e: # noqa: BLE001
588
577
  self._mount(ErrorMessage(f"could not read checklist: {e}"))
589
578
  return
590
- host = cl.asset.get("HOST_NAME") or cl.asset.get("host_name") or "?"
591
- c = cl.counts()
592
- lines = [f"STIG checklist {path} (host: {host}, format: {cl.fmt})",
593
- f" {len(cl.rules)} rules — " + " · ".join(f"{k}={v}" for k, v in c.items())]
594
- if len(parts) > 1:
595
- sf = stig.canonical_status(parts[1])
596
- hits = [r for r in cl.rules if r.status == sf]
597
- lines.append(f"\n{sf} ({len(hits)}):")
598
- lines += [f" {r.group_id} ({r.severity}) — {r.title}" for r in hits[:50]]
599
- else:
600
- lines.append("Assess un-reviewed rules: /loop "
601
- f"{c.get('not_reviewed', 0) or 1} /stig-assess {parts[0]}")
602
- self._info("\n".join(lines))
579
+ status = parts[1] if len(parts) > 1 else None
580
+ self._info("\n".join(stig.summary_lines(cl, parts[0], status)))
603
581
 
604
582
  def _cmd_rmf(self, arg: str) -> None:
605
583
  """RMF automation: bootstrap the NIST 800-53 catalog into the knowledge
@@ -654,6 +632,30 @@ class DrydockApp(App):
654
632
  "catalog download; after that it works offline.)")
655
633
  self.call_from_thread(self._info, msg)
656
634
 
635
+ def _stig_graph(self, cwd: str, path: str) -> None:
636
+ """Worker-thread body: ingest a checklist into the RMF graph, auto-linking
637
+ rules to NIST controls via the DISA CCI map. Reports back on the UI."""
638
+ from drydock import cci, rmf_graph, stig
639
+
640
+ try:
641
+ cl = stig.load(path)
642
+ cci_map = cci.load_map(cwd) # fetch+cache once; offline-safe ({} on failure)
643
+ g = rmf_graph.RmfGraph.load(rmf_graph.graph_path(cwd))
644
+ r = rmf_graph.ingest_checklist(g, cl, cci_map)
645
+ g.save(rmf_graph.graph_path(cwd))
646
+ link_note = (
647
+ f"auto-linked {r['linked']}/{r['rules']} rules to NIST controls via CCI "
648
+ "(Control —SATISFIED_BY→ rule). Trace with GraphQuery control <id>."
649
+ if r["linked"] else
650
+ "(no CCI→control links — the CCI map was unavailable offline; rules are "
651
+ "still in the graph. Re-run online, or use GraphAdd satisfies.)"
652
+ )
653
+ msg = (f"✓ Ingested {r['rules']} STIG rules for host '{r['host']}' into the "
654
+ f"RMF graph (STIG/STIG-Rule + PART_OF/APPLIES_TO/EVALUATES); {link_note}")
655
+ except Exception as e: # noqa: BLE001
656
+ msg = f"could not graph checklist: {e}"
657
+ self.call_from_thread(self._info, msg)
658
+
657
659
  def _cmd_mcp(self) -> None:
658
660
  """List connected MCP servers and the tools they expose."""
659
661
  from drydock import mcp
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.72
3
+ Version: 3.0.74
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -6,6 +6,7 @@ drydock/__init__.py
6
6
  drydock/__main__.py
7
7
  drydock/agent.py
8
8
  drydock/bash_safety.py
9
+ drydock/cci.py
9
10
  drydock/cli.py
10
11
  drydock/compaction.py
11
12
  drydock/config.py
@@ -49,6 +50,7 @@ tests/test_bash_output_bounding.py
49
50
  tests/test_bash_process_group.py
50
51
  tests/test_bash_safety.py
51
52
  tests/test_bash_timeout_network.py
53
+ tests/test_cci.py
52
54
  tests/test_cli_agents.py
53
55
  tests/test_compact_command.py
54
56
  tests/test_compaction.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.72"
10
+ version = "3.0.74"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,82 @@
1
+ """CCI → NIST 800-53 control map (the STIG-rule ↔ control bridge)."""
2
+ from __future__ import annotations
3
+
4
+ from drydock import cci, rmf_graph, stig
5
+
6
+ _CCI_XML = '''<?xml version="1.0"?><cci_list><cci_items>
7
+ <cci_item id="CCI-000366"><references>
8
+ <reference title="NIST SP 800-53" version="3" index="CM-6"/>
9
+ <reference title="NIST SP 800-53A" version="1" index="CM-6.1 (ii)"/>
10
+ <reference title="NIST SP 800-53 Revision 4" version="4" index="CM-6 b"/></references></cci_item>
11
+ <cci_item id="CCI-000054"><references>
12
+ <reference title="NIST SP 800-53 Revision 5" version="5" index="AC-10"/></references></cci_item>
13
+ <cci_item id="CCI-001133"><references>
14
+ <reference title="NIST SP 800-53 Revision 4" version="4" index="SC-10 (1)"/></references></cci_item>
15
+ <cci_item id="CCI-999999"><references>
16
+ <reference title="NIST SP 800-53A" version="1" index="ZZ-9.9"/></references></cci_item>
17
+ </cci_items></cci_list>'''
18
+
19
+
20
+ def test_parse_picks_highest_rev_excludes_53A(tmp_path):
21
+ p = tmp_path / "cci.xml"; p.write_text(_CCI_XML)
22
+ m = cci.parse_cci_list(p)
23
+ assert m["CCI-000366"] == "cm-6" # rev4 base, 800-53A excluded
24
+ assert m["CCI-000054"] == "ac-10"
25
+ assert m["CCI-001133"] == "sc-10.1" # enhancement normalized
26
+ assert "CCI-999999" not in m # only 800-53A ref → no control
27
+
28
+
29
+ def test_norm_control_formats():
30
+ assert cci._norm_control("AC-10") == "ac-10"
31
+ assert cci._norm_control("AC-2 (1)") == "ac-2.1"
32
+ assert cci._norm_control("AC-2.1") == "ac-2.1"
33
+ assert cci._norm_control("garbage") is None
34
+
35
+
36
+ def test_load_map_caches_and_is_offline_safe(tmp_path, monkeypatch):
37
+ # no network: with no cache, returns {} (never raises)
38
+ def boom(*a, **k):
39
+ raise OSError("offline")
40
+ monkeypatch.setattr(cci, "fetch_to", boom)
41
+ assert cci.load_map(str(tmp_path)) == {}
42
+ # seed a cache → load_map returns it without fetching
43
+ cp = cci.cache_path(str(tmp_path)); cp.parent.mkdir(parents=True, exist_ok=True)
44
+ cp.write_text('{"CCI-000366": "cm-6"}')
45
+ assert cci.load_map(str(tmp_path)) == {"CCI-000366": "cm-6"}
46
+
47
+
48
+ def test_ingest_auto_links_via_cci(tmp_path):
49
+ (tmp_path / "s.ckl").write_text(
50
+ '<?xml version="1.0"?><CHECKLIST><ASSET><HOST_NAME>h1</HOST_NAME></ASSET><STIGS><iSTIG>'
51
+ '<STIG_INFO></STIG_INFO><VULN>'
52
+ '<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-9</ATTRIBUTE_DATA></STIG_DATA>'
53
+ '<STIG_DATA><VULN_ATTRIBUTE>CCI_REF</VULN_ATTRIBUTE><ATTRIBUTE_DATA>CCI-000366</ATTRIBUTE_DATA></STIG_DATA>'
54
+ '<STATUS>Open</STATUS></VULN></iSTIG></STIGS></CHECKLIST>')
55
+ cl = stig.load(tmp_path / "s.ckl")
56
+ g = rmf_graph.RmfGraph()
57
+ r = rmf_graph.ingest_checklist(g, cl, {"CCI-000366": "cm-6"})
58
+ assert r["linked"] == 1
59
+ assert rmf_graph.rule_node("SV-9") in g.neighbors(rmf_graph.control_id("cm-6"), "SATISFIED_BY")
60
+
61
+
62
+ def test_ingest_without_map_does_not_link(tmp_path):
63
+ (tmp_path / "s.ckl").write_text(
64
+ '<?xml version="1.0"?><CHECKLIST><STIGS><iSTIG><STIG_INFO></STIG_INFO><VULN>'
65
+ '<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-9</ATTRIBUTE_DATA></STIG_DATA>'
66
+ '<STATUS>Open</STATUS></VULN></iSTIG></STIGS></CHECKLIST>')
67
+ cl = stig.load(tmp_path / "s.ckl")
68
+ r = rmf_graph.ingest_checklist(rmf_graph.RmfGraph(), cl)
69
+ assert r["linked"] == 0
70
+
71
+
72
+ def test_load_map_creates_dir_and_caches(tmp_path, monkeypatch):
73
+ """Regression: the fetch must create .drydock/rmf/ before writing — a real
74
+ fetch failed because the dir didn't exist yet (found via hands-on TUI test)."""
75
+ def fake_fetch(path, **kw):
76
+ # a real fetch writes to .drydock/rmf/cci_map.xml — the dir must exist
77
+ from pathlib import Path
78
+ Path(path).write_text(_CCI_XML)
79
+ monkeypatch.setattr(cci, "fetch_to", fake_fetch)
80
+ m = cci.load_map(str(tmp_path))
81
+ assert m["CCI-000366"] == "cm-6" # parsed from the "fetched" list
82
+ assert cci.cache_path(str(tmp_path)).exists() # cached as json
@@ -55,3 +55,33 @@ def test_bad_server_is_skipped(tmp_path):
55
55
  schemas = mcp.connect_all(str(tmp_path), log=logs.append)
56
56
  assert schemas == []
57
57
  assert any("failed to start" in m for m in logs)
58
+
59
+
60
+ def test_content_to_text_joins_text_blocks():
61
+ out = mcp._content_to_text({"content": [{"type": "text", "text": "line1"},
62
+ {"type": "text", "text": "line2"}]})
63
+ assert out == "line1\nline2"
64
+
65
+
66
+ def test_content_to_text_non_text_block_is_json():
67
+ out = mcp._content_to_text({"content": [{"type": "image", "data": "abc"}]})
68
+ assert '"type": "image"' in out and '"data": "abc"' in out
69
+
70
+
71
+ def test_content_to_text_error_and_empty_and_nondict():
72
+ assert mcp._content_to_text({"content": [{"type": "text", "text": "boom"}],
73
+ "isError": True}).startswith("[tool reported an error]")
74
+ assert mcp._content_to_text({"content": []}) == "(empty result)"
75
+ assert mcp._content_to_text("raw string") == "raw string"
76
+
77
+
78
+ def test_load_config_project_overrides_user(tmp_path, monkeypatch):
79
+ monkeypatch.setenv("HOME", str(tmp_path))
80
+ user = tmp_path / ".drydock"; user.mkdir()
81
+ (user / "mcp.json").write_text('{"mcpServers": {"db": {"command": "user-cmd"}}}')
82
+ proj = tmp_path / "proj" / ".drydock"; proj.mkdir(parents=True)
83
+ (proj / "mcp.json").write_text('{"mcpServers": {"db": {"command": "proj-cmd"},'
84
+ ' "nocmd": {"args": []}}}')
85
+ cfg = mcp.load_config(str(tmp_path / "proj"))
86
+ assert cfg["db"]["command"] == "proj-cmd" # project wins
87
+ assert "nocmd" not in cfg # missing command → skipped
@@ -60,3 +60,29 @@ def test_graphadd_and_graphquery_tools(tmp_path):
60
60
 
61
61
  def test_graphquery_empty_graph_is_graceful(tmp_path):
62
62
  assert "empty" in tool_graphquery({"op": "control", "id": "AC-2"}, {"cwd": str(tmp_path)}).lower()
63
+
64
+
65
+ def test_inherited_controls_handles_cycle():
66
+ """A RESIDES_ON cycle (misconfigured topology) must not infinite-loop."""
67
+ g = G.RmfGraph()
68
+ a, b = G.component_id("a"), G.component_id("b")
69
+ g.add_node(a, "Component"); g.add_node(b, "Component")
70
+ g.add_edge(a, "RESIDES_ON", b)
71
+ g.add_edge(b, "RESIDES_ON", a) # cycle
72
+ g.add_edge(b, "IMPLEMENTS", G.control_id("pe-3"))
73
+ got = g.inherited_controls(a) # must terminate
74
+ assert G.control_id("pe-3") in got
75
+
76
+
77
+ def test_inherited_controls_missing_node_is_empty():
78
+ g = G.RmfGraph()
79
+ assert g.inherited_controls(G.component_id("ghost")) == []
80
+
81
+
82
+ def test_neighbors_direction_and_missing():
83
+ g = G.RmfGraph()
84
+ g.add_edge("x", "REL", "y")
85
+ assert g.neighbors("x", "REL") == ["y"]
86
+ assert g.neighbors("y", "REL", direction="in") == ["x"]
87
+ assert g.neighbors("y", "REL", direction="out") == []
88
+ assert g.neighbors("nope") == []
@@ -93,3 +93,51 @@ def test_empty_and_minimal_checklists(tmp_path):
93
93
  assert stig.load(tmp_path / "e.ckl").rules == []
94
94
  (tmp_path / "m.cklb").write_text('{"stigs":[{"rules":[{"group_id":"V-5"}]}]}')
95
95
  assert stig.load(tmp_path / "m.cklb").rules[0].group_id == "V-5"
96
+
97
+
98
+ def test_cklb_writeback_only_changes_status_fields(tmp_path):
99
+ """Editing a .cklb must change ONLY status/finding_details/comments and keep
100
+ everything else byte-stable (eMASS round-trip fidelity)."""
101
+ import json
102
+ data = {"title": "t", "stigs": [{"stig_name": "Demo", "version": "1", "rules": [
103
+ {"group_id": "V-1", "rule_id": "SV-1", "rule_title": "Rule one",
104
+ "severity": "high", "status": "not_reviewed", "check_content": "do x",
105
+ "fix_text": "fix x", "finding_details": "", "comments": "", "weight": "10.0"}]}]}
106
+ p = tmp_path / "a.cklb"; p.write_text(json.dumps(data))
107
+ cl = stig.load(p)
108
+ cl.update("V-1", status="open", finding_details="found it")
109
+ cl.save(p)
110
+ re = json.loads(p.read_text())
111
+ rule = re["stigs"][0]["rules"][0]
112
+ assert rule["status"] == "open" and rule["finding_details"] == "found it"
113
+ # untouched fields preserved exactly
114
+ assert rule["severity"] == "high" and rule["check_content"] == "do x"
115
+ assert rule["weight"] == "10.0" and rule["rule_title"] == "Rule one"
116
+
117
+
118
+ def test_summary_lines_scale_and_no_silent_truncation(tmp_path):
119
+ import json
120
+ # 60 rules: 55 open, 5 not_reviewed → exercises >40 hint, open hint, >50 cap note
121
+ rules = [{"group_id": f"V-{i}", "rule_id": f"SV-{i}", "rule_title": f"r{i}",
122
+ "severity": "medium", "status": "open" if i < 55 else "not_reviewed"}
123
+ for i in range(60)]
124
+ (tmp_path / "b.cklb").write_text(json.dumps(
125
+ {"stigs": [{"stig_name": "S", "version": "1", "rules": rules}]}))
126
+ cl = stig.load(tmp_path / "b.cklb")
127
+ summ = "\n".join(stig.summary_lines(cl, "b.cklb"))
128
+ assert "/loop 5 /stig-assess b.cklb" in summ # exact not_reviewed count
129
+ assert "List open findings" in summ # open hint shown
130
+ open_view = "\n".join(stig.summary_lines(cl, "b.cklb", "open"))
131
+ assert "showing first 50 of 55" in open_view # no silent truncation
132
+ assert open_view.count("V-") <= 51 # capped list + the note
133
+
134
+
135
+ def test_summary_lines_large_checklist_hint(tmp_path):
136
+ import json
137
+ rules = [{"group_id": f"V-{i}", "rule_id": f"SV-{i}", "rule_title": "x",
138
+ "severity": "low", "status": "not_reviewed"} for i in range(286)]
139
+ (tmp_path / "big.cklb").write_text(json.dumps(
140
+ {"stigs": [{"stig_name": "ASD", "version": "6", "rules": rules}]}))
141
+ cl = stig.load(tmp_path / "big.cklb")
142
+ summ = "\n".join(stig.summary_lines(cl, "big.cklb"))
143
+ assert "/loop 286" in summ and "286 model turns" in summ
@@ -70,3 +70,36 @@ def test_search_parses_ddg_html(monkeypatch):
70
70
  assert res and res[0]["title"] == "Example A"
71
71
  assert res[0]["url"] == "https://example.com/a" # uddg redirect unwrapped
72
72
  assert res[0]["snippet"] == "snippet A text"
73
+
74
+
75
+ class _FakeResp:
76
+ def __init__(self, body: bytes, ctype: str):
77
+ self._body = body
78
+ self.headers = {"Content-Type": ctype}
79
+ def read(self, *a): return self._body
80
+ def __enter__(self): return self
81
+ def __exit__(self, *a): return False
82
+
83
+
84
+ def test_fetch_strips_html_script_and_tags(monkeypatch):
85
+ import urllib.request
86
+ html_doc = (b"<html><head><script>var x=1; evil()</script><style>a{}</style></head>"
87
+ b"<body><h1>Title</h1><p>Hello &amp; welcome</p></body></html>")
88
+ monkeypatch.setattr(urllib.request, "urlopen",
89
+ lambda *a, **k: _FakeResp(html_doc, "text/html"))
90
+ out = web.fetch("example.com")
91
+ assert "Title" in out and "Hello & welcome" in out
92
+ assert "evil()" not in out and "<p>" not in out # script + tags gone
93
+
94
+
95
+ def test_fetch_truncates_long_text(monkeypatch):
96
+ import urllib.request
97
+ big = b"<html><body>" + b"x " * 5000 + b"</body></html>"
98
+ monkeypatch.setattr(urllib.request, "urlopen",
99
+ lambda *a, **k: _FakeResp(big, "text/html"))
100
+ out = web.fetch("example.com", max_chars=500)
101
+ assert "truncated" in out and len(out) < 700
102
+
103
+
104
+ def test_format_search_empty():
105
+ assert "No web results" in web.format_search("rare query", [])
@@ -54,3 +54,32 @@ def test_xccdf_to_blank_ckl(tmp_path):
54
54
  assert stig.load(out).rules[0].status == "open"
55
55
  # the regenerated .ckl preserves the DISA status enum
56
56
  assert "<STATUS>Open</STATUS>" in out.read_text()
57
+
58
+
59
+ def test_xccdf_scale_many_rules(tmp_path):
60
+ """A full DISA STIG (e.g. the ASD STIG) has hundreds of rules. Verify the
61
+ XCCDF->.ckl path handles scale: all rules parsed, well-formed, round-trips,
62
+ full id/title fidelity. (Validated live against U_ASD_STIG_V6R1 = 286 rules.)"""
63
+ rules = "".join(
64
+ f'<Group id="V-{i:05d}"><title>SRG-APP-{i:06d}</title>'
65
+ f'<Rule id="SV-{i:05d}r1_rule" severity="medium" weight="10.0">'
66
+ f'<version>APSC-DV-{i:06d}</version><title>Requirement {i}.</title>'
67
+ f'<description>&lt;VulnDiscussion&gt;Discussion {i}.&lt;/VulnDiscussion&gt;</description>'
68
+ f'<ident system="http://cyber.mil/cci">CCI-{i:06d}</ident>'
69
+ f'<fixtext fixref="F-{i}">Fix {i}.</fixtext>'
70
+ f'<check system="C-{i}"><check-content>Check {i}.</check-content></check>'
71
+ f'</Rule></Group>'
72
+ for i in range(250)
73
+ )
74
+ xccdf = ('<?xml version="1.0"?><Benchmark xmlns="http://checklists.nist.gov/xccdf/1.1" '
75
+ 'id="BIG"><title>Big STIG</title><version>1</version>'
76
+ f'<plain-text id="release-info">Release: 1</plain-text>{rules}</Benchmark>')
77
+ p = tmp_path / "big-xccdf.xml"; p.write_text(xccdf)
78
+ cl = stig.xccdf_to_checklist(p, host="srv")
79
+ assert len(cl.rules) == 250
80
+ assert all(r.rule_id and r.title for r in cl.rules) # full fidelity
81
+ out = tmp_path / "big.ckl"; cl.save(out)
82
+ ET.parse(out) # valid at scale
83
+ re = stig.load(out)
84
+ assert len(re.rules) == 250 and re.counts()["not_reviewed"] == 250
85
+ assert re.rules[123].rule_id == "SV-00123r1_rule" # order + content preserved
File without changes
File without changes
File without changes
File without changes