drydock-cli 3.0.64__tar.gz → 3.0.65__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 (90) hide show
  1. {drydock_cli-3.0.64/drydock_cli.egg-info → drydock_cli-3.0.65}/PKG-INFO +1 -1
  2. drydock_cli-3.0.65/drydock/stig.py +173 -0
  3. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tools/__init__.py +139 -10
  4. {drydock_cli-3.0.64 → drydock_cli-3.0.65/drydock_cli.egg-info}/PKG-INFO +1 -1
  5. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/SOURCES.txt +2 -0
  6. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/pyproject.toml +1 -1
  7. drydock_cli-3.0.65/tests/test_stig.py +70 -0
  8. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/LICENSE +0 -0
  9. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/NOTICE +0 -0
  10. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/README.md +0 -0
  11. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/agent.py +0 -0
  14. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/__init__.py +0 -0
  16. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-categorize.md +0 -0
  17. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-control.md +0 -0
  18. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-poam.md +0 -0
  19. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/builtin_skills/rmf-review.md +0 -0
  20. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/cli.py +0 -0
  21. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/compaction.py +0 -0
  22. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/config.py +0 -0
  23. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/detect.py +0 -0
  24. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/extract.py +0 -0
  25. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/gittools.py +0 -0
  26. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/graphrag.py +0 -0
  27. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/guards.py +0 -0
  28. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/loop_detect.py +0 -0
  29. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/mcp.py +0 -0
  30. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/providers.py +0 -0
  31. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/rmf.py +0 -0
  32. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/rmf_graph.py +0 -0
  33. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/skills.py +0 -0
  34. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tool_registry.py +0 -0
  35. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/__init__.py +0 -0
  36. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/app.py +0 -0
  37. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/approval.py +0 -0
  38. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/messages.py +0 -0
  39. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tui/widgets.py +0 -0
  40. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/tuning.py +0 -0
  41. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock/web.py +0 -0
  42. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/dependency_links.txt +0 -0
  43. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/entry_points.txt +0 -0
  44. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/requires.txt +0 -0
  45. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/drydock_cli.egg-info/top_level.txt +0 -0
  46. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/setup.cfg +0 -0
  47. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_approval.py +0 -0
  48. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_back_command.py +0 -0
  49. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_output_bounding.py +0 -0
  50. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_process_group.py +0 -0
  51. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_safety.py +0 -0
  52. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_bash_timeout_network.py +0 -0
  53. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_cli_agents.py +0 -0
  54. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_compact_command.py +0 -0
  55. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_compaction.py +0 -0
  56. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_config.py +0 -0
  57. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_config_migration.py +0 -0
  58. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_context_limit_config.py +0 -0
  59. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_detect.py +0 -0
  60. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_dispatch.py +0 -0
  61. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_empty_response.py +0 -0
  62. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_extract.py +0 -0
  63. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_failure_loop.py +0 -0
  64. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_first_run_setup.py +0 -0
  65. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_gittools.py +0 -0
  66. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_graphrag.py +0 -0
  67. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_guards_and_tools.py +0 -0
  68. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_hallucinated_tools.py +0 -0
  69. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_leaked_tool_call.py +0 -0
  70. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_loop_detect.py +0 -0
  71. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_mcp.py +0 -0
  72. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_oneshot_unreachable.py +0 -0
  73. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_plan_autocontinue.py +0 -0
  74. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_providers_unreachable.py +0 -0
  75. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_read_index.py +0 -0
  76. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_rmf.py +0 -0
  77. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_rmf_graph.py +0 -0
  78. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_runaway_repetition.py +0 -0
  79. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_skills.py +0 -0
  80. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_stop.py +0 -0
  81. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_streaming_newlines.py +0 -0
  82. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_subagent.py +0 -0
  83. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_system_prompt_help.py +0 -0
  84. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_todo.py +0 -0
  85. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tool_arg_parsing.py +0 -0
  86. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tools_undo.py +0 -0
  87. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tui.py +0 -0
  88. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_tuning.py +0 -0
  89. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_vision_input.py +0 -0
  90. {drydock_cli-3.0.64 → drydock_cli-3.0.65}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.64
3
+ Version: 3.0.65
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,173 @@
1
+ """STIG checklist engine — parse + generate DISA `.ckl` (XML) and `.cklb` (JSON).
2
+
3
+ These checklists carry hostnames, IPs, and finding statuses that are almost
4
+ always CUI, so all parsing/generation stays local. The model never sees raw XML:
5
+ this exposes a compact per-rule view (id, severity, check content, fix text,
6
+ status) for the LLM to assess one at a time, and writes results back by editing
7
+ the parsed structure IN PLACE — so regenerating produces a well-formed file that
8
+ re-imports into STIG Viewer / eMASS without breaking the schema.
9
+
10
+ Stdlib only (xml.etree + json). All logic original to Drydock.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import xml.etree.ElementTree as ET
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ # Canonical statuses + how each format spells them.
21
+ STATUSES = ("open", "not_a_finding", "not_applicable", "not_reviewed")
22
+ _CKL_OUT = {"open": "Open", "not_a_finding": "NotAFinding",
23
+ "not_applicable": "Not_Applicable", "not_reviewed": "Not_Reviewed"}
24
+
25
+
26
+ def canonical_status(s: str | None) -> str:
27
+ k = (s or "").strip().lower().replace(" ", "_")
28
+ return {
29
+ "open": "open", "notafinding": "not_a_finding", "not_a_finding": "not_a_finding",
30
+ "not_applicable": "not_applicable", "notapplicable": "not_applicable",
31
+ "not_reviewed": "not_reviewed", "notreviewed": "not_reviewed",
32
+ }.get(k, "not_reviewed")
33
+
34
+
35
+ @dataclass
36
+ class Rule:
37
+ group_id: str # Vuln_Num, e.g. V-230222
38
+ rule_id: str # SV-230222r...
39
+ title: str
40
+ severity: str # high/medium/low (CAT I/II/III)
41
+ check_content: str
42
+ fix_text: str
43
+ status: str # canonical
44
+ finding_details: str = ""
45
+ comments: str = ""
46
+ _raw: Any = field(default=None, repr=False) # ET element or dict, for edit-in-place
47
+
48
+ def summary(self) -> str:
49
+ return f"{self.group_id} ({self.rule_id}) sev={self.severity} status={self.status} — {self.title}"
50
+
51
+
52
+ class Checklist:
53
+ """A parsed checklist with edit-in-place fidelity. Format is 'ckl' or 'cklb'."""
54
+
55
+ def __init__(self, fmt: str) -> None:
56
+ self.fmt = fmt
57
+ self.asset: dict = {}
58
+ self.rules: list[Rule] = []
59
+ self._tree: Any = None # ckl: ElementTree
60
+ self._data: Any = None # cklb: dict
61
+
62
+ def by_id(self, ident: str) -> Rule | None:
63
+ ident = ident.strip().lower()
64
+ for r in self.rules:
65
+ if ident in (r.group_id.lower(), r.rule_id.lower()):
66
+ return r
67
+ # tolerate a base rule id without the trailing revision (SV-230222 vs SV-230222r1)
68
+ for r in self.rules:
69
+ if r.rule_id.lower().startswith(ident) or r.group_id.lower().startswith(ident):
70
+ return r
71
+ return None
72
+
73
+ def update(self, ident: str, *, status: str | None = None,
74
+ finding_details: str | None = None, comments: str | None = None) -> bool:
75
+ r = self.by_id(ident)
76
+ if r is None:
77
+ return False
78
+ if status is not None:
79
+ r.status = canonical_status(status)
80
+ if finding_details is not None:
81
+ r.finding_details = finding_details
82
+ if comments is not None:
83
+ r.comments = comments
84
+ self._write_back(r)
85
+ return True
86
+
87
+ def counts(self) -> dict:
88
+ c = {s: 0 for s in STATUSES}
89
+ for r in self.rules:
90
+ c[r.status] = c.get(r.status, 0) + 1
91
+ return c
92
+
93
+ # ── per-format write-back (edit the raw structure) ──────────────────────
94
+ def _write_back(self, r: Rule) -> None:
95
+ if self.fmt == "cklb":
96
+ d = r._raw
97
+ d["status"] = r.status
98
+ d["finding_details"] = r.finding_details
99
+ d["comments"] = r.comments
100
+ else: # ckl
101
+ vuln = r._raw
102
+ _set_child_text(vuln, "STATUS", _CKL_OUT[r.status])
103
+ _set_child_text(vuln, "FINDING_DETAILS", r.finding_details)
104
+ _set_child_text(vuln, "COMMENTS", r.comments)
105
+
106
+ def to_text(self) -> str:
107
+ if self.fmt == "cklb":
108
+ return json.dumps(self._data, indent=2)
109
+ ET.indent(self._tree, space=" ")
110
+ return ET.tostring(self._tree.getroot(), encoding="unicode")
111
+
112
+ def save(self, path: str | Path) -> None:
113
+ Path(path).write_text(self.to_text(), encoding="utf-8")
114
+
115
+
116
+ def _set_child_text(el: ET.Element, tag: str, text: str) -> None:
117
+ child = el.find(tag)
118
+ if child is None:
119
+ child = ET.SubElement(el, tag)
120
+ child.text = text or ""
121
+
122
+
123
+ # ── parsing ─────────────────────────────────────────────────────────────────
124
+ def parse_ckl(path: str | Path) -> Checklist:
125
+ cl = Checklist("ckl")
126
+ cl._tree = ET.parse(path)
127
+ root = cl._tree.getroot()
128
+ asset = root.find("ASSET")
129
+ if asset is not None:
130
+ cl.asset = {c.tag: (c.text or "") for c in asset}
131
+ for vuln in root.iter("VULN"):
132
+ data: dict[str, str] = {}
133
+ for sd in vuln.findall("STIG_DATA"):
134
+ data[sd.findtext("VULN_ATTRIBUTE") or ""] = sd.findtext("ATTRIBUTE_DATA") or ""
135
+ cl.rules.append(Rule(
136
+ group_id=data.get("Vuln_Num", ""), rule_id=data.get("Rule_ID", ""),
137
+ title=data.get("Rule_Title", ""), severity=data.get("Severity", ""),
138
+ check_content=data.get("Check_Content", ""), fix_text=data.get("Fix_Text", ""),
139
+ status=canonical_status(vuln.findtext("STATUS")),
140
+ finding_details=vuln.findtext("FINDING_DETAILS") or "",
141
+ comments=vuln.findtext("COMMENTS") or "", _raw=vuln,
142
+ ))
143
+ return cl
144
+
145
+
146
+ def parse_cklb(path: str | Path) -> Checklist:
147
+ cl = Checklist("cklb")
148
+ cl._data = json.loads(Path(path).read_text("utf-8"))
149
+ cl.asset = cl._data.get("target_data", {}) or {}
150
+ for stig in cl._data.get("stigs", []) or []:
151
+ for rule in stig.get("rules", []) or []:
152
+ cl.rules.append(Rule(
153
+ group_id=rule.get("group_id", ""), rule_id=rule.get("rule_id", ""),
154
+ title=rule.get("rule_title", rule.get("group_title", "")),
155
+ severity=rule.get("severity", ""),
156
+ check_content=rule.get("check_content", ""), fix_text=rule.get("fix_text", ""),
157
+ status=canonical_status(rule.get("status")),
158
+ finding_details=rule.get("finding_details", ""),
159
+ comments=rule.get("comments", ""), _raw=rule,
160
+ ))
161
+ return cl
162
+
163
+
164
+ def load(path: str | Path) -> Checklist:
165
+ """Parse a .ckl (XML) or .cklb (JSON) checklist."""
166
+ ext = Path(path).suffix.lower()
167
+ if ext == ".cklb":
168
+ return parse_cklb(path)
169
+ if ext == ".ckl":
170
+ return parse_ckl(path)
171
+ # sniff: JSON → cklb, else ckl
172
+ head = Path(path).read_text("utf-8", "ignore").lstrip()[:1]
173
+ return parse_cklb(path) if head == "{" else parse_ckl(path)
@@ -270,6 +270,58 @@ SCHEMAS = [
270
270
  "required": ["message"],
271
271
  },
272
272
  },
273
+ {
274
+ "name": "StigRules",
275
+ "description": (
276
+ "List the rules in a DISA STIG checklist (.ckl / .cklb) with their "
277
+ "status, optionally filtered by status (e.g. not_reviewed). Use it to "
278
+ "see what needs assessing. Pair with StigRule to read one, StigSet to "
279
+ "record a result."
280
+ ),
281
+ "input_schema": {
282
+ "type": "object",
283
+ "properties": {
284
+ "path": {"type": "string", "description": "Path to the .ckl/.cklb file."},
285
+ "status": {"type": "string", "description": "Optional filter: open|not_a_finding|not_applicable|not_reviewed"},
286
+ },
287
+ "required": ["path"],
288
+ },
289
+ },
290
+ {
291
+ "name": "StigRule",
292
+ "description": (
293
+ "Read ONE STIG rule's full detail — Check Content + Fix Text — so you "
294
+ "can assess it against system evidence. Assess one rule at a time."
295
+ ),
296
+ "input_schema": {
297
+ "type": "object",
298
+ "properties": {
299
+ "path": {"type": "string"},
300
+ "rule_id": {"type": "string", "description": "Vuln_Num (V-…) or Rule_ID (SV-…)."},
301
+ },
302
+ "required": ["path", "rule_id"],
303
+ },
304
+ },
305
+ {
306
+ "name": "StigSet",
307
+ "description": (
308
+ "Record an assessment result for a STIG rule and save the checklist: "
309
+ "set status (open / not_a_finding / not_applicable / not_reviewed) and "
310
+ "the finding_details / comments narrative. Regenerates a valid "
311
+ ".ckl/.cklb that re-imports into STIG Viewer / eMASS."
312
+ ),
313
+ "input_schema": {
314
+ "type": "object",
315
+ "properties": {
316
+ "path": {"type": "string"},
317
+ "rule_id": {"type": "string"},
318
+ "status": {"type": "string", "description": "open | not_a_finding | not_applicable | not_reviewed"},
319
+ "finding_details": {"type": "string"},
320
+ "comments": {"type": "string"},
321
+ },
322
+ "required": ["path", "rule_id"],
323
+ },
324
+ },
273
325
  {
274
326
  "name": "GraphQuery",
275
327
  "description": (
@@ -1102,6 +1154,78 @@ def _rmf_graph(config):
1102
1154
  return rmf_graph, rmf_graph.RmfGraph.load(path), path
1103
1155
 
1104
1156
 
1157
+ def _gattrs(g, nid) -> dict:
1158
+ n = g.get(nid)
1159
+ return n["attrs"] if n else {}
1160
+
1161
+
1162
+ def _stig_path(params, config):
1163
+ p = (params.get("path") or "").strip()
1164
+ return _resolve_path(p, config) if p else None
1165
+
1166
+
1167
+ def tool_stigrules(params: dict, config: dict) -> str:
1168
+ """List the rules in a STIG checklist (.ckl/.cklb), optionally by status."""
1169
+ from drydock import stig
1170
+ path = _stig_path(params, config)
1171
+ if not path:
1172
+ return "Error: StigRules needs a `path` to a .ckl/.cklb checklist."
1173
+ try:
1174
+ cl = stig.load(path)
1175
+ except Exception as e: # noqa: BLE001
1176
+ return f"Could not read checklist: {e}"
1177
+ status = params.get("status")
1178
+ sf = stig.canonical_status(status) if status else None
1179
+ rules = [r for r in cl.rules if sf is None or r.status == sf]
1180
+ head = (f"{path}: {len(cl.rules)} rules — " +
1181
+ ", ".join(f"{k}={v}" for k, v in cl.counts().items()))
1182
+ lines = [head] + [f" {r.summary()}" for r in rules[:400]]
1183
+ if len(rules) > 400:
1184
+ lines.append(f" … +{len(rules) - 400} more")
1185
+ return "\n".join(lines)
1186
+
1187
+
1188
+ def tool_stigrule(params: dict, config: dict) -> str:
1189
+ """Full detail of ONE STIG rule (check content + fix text) for assessment."""
1190
+ from drydock import stig
1191
+ path = _stig_path(params, config)
1192
+ rid = (params.get("rule_id") or "").strip()
1193
+ if not path or not rid:
1194
+ return "Error: StigRule needs `path` and `rule_id`."
1195
+ try:
1196
+ cl = stig.load(path)
1197
+ except Exception as e: # noqa: BLE001
1198
+ return f"Could not read checklist: {e}"
1199
+ r = cl.by_id(rid)
1200
+ if r is None:
1201
+ return f"No rule {rid} in {path}."
1202
+ return (f"{r.group_id} ({r.rule_id}) — {r.title}\nSeverity: {r.severity}\n"
1203
+ f"Status: {r.status}\nCheck Content:\n{r.check_content}\n\n"
1204
+ f"Fix Text:\n{r.fix_text}")
1205
+
1206
+
1207
+ def tool_stigset(params: dict, config: dict) -> str:
1208
+ """Set a STIG rule's status + finding details/comments and save the file."""
1209
+ from drydock import stig
1210
+ path = _stig_path(params, config)
1211
+ rid = (params.get("rule_id") or "").strip()
1212
+ if not path or not rid:
1213
+ return "Error: StigSet needs `path` and `rule_id`."
1214
+ status = params.get("status")
1215
+ if status and stig.canonical_status(status) not in stig.STATUSES:
1216
+ return f"status must be one of {stig.STATUSES}."
1217
+ try:
1218
+ cl = stig.load(path)
1219
+ ok = cl.update(rid, status=status, finding_details=params.get("finding_details"),
1220
+ comments=params.get("comments"))
1221
+ if not ok:
1222
+ return f"No rule {rid} in {path}."
1223
+ cl.save(path)
1224
+ except Exception as e: # noqa: BLE001
1225
+ return f"Could not update checklist: {e}"
1226
+ return f"✓ {rid} set to {stig.canonical_status(status) if status else 'unchanged'} in {path}."
1227
+
1228
+
1105
1229
  def tool_graphquery(params: dict, config: dict) -> str:
1106
1230
  """Traverse the RMF typed ontology graph (read-only)."""
1107
1231
  rmf_graph, g, _ = _rmf_graph(config)
@@ -1115,8 +1239,8 @@ def tool_graphquery(params: dict, config: dict) -> str:
1115
1239
  n = g.get(node)
1116
1240
  if not n:
1117
1241
  return f"No control {ident} in the graph."
1118
- objs = [g.get(o)["attrs"].get("prose", "") for o in g.neighbors(node, "ASSESSES", direction="in")]
1119
- impl = [g.get(c)["attrs"].get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
1242
+ objs = [_gattrs(g, o).get("prose", "") for o in g.neighbors(node, "ASSESSES", direction="in")]
1243
+ impl = [_gattrs(g, c).get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
1120
1244
  out = [f"{n['attrs'].get('control_id', ident)} — {n['attrs'].get('title','')} "
1121
1245
  f"(Family: {n['attrs'].get('family','')})"]
1122
1246
  if objs:
@@ -1124,28 +1248,28 @@ def tool_graphquery(params: dict, config: dict) -> str:
1124
1248
  out.append("Implemented by: " + (", ".join(impl) if impl else "(no components recorded)"))
1125
1249
  return "\n".join(out)
1126
1250
  if op == "family":
1127
- ctrls = [g.get(c)["attrs"].get("control_id", c)
1251
+ ctrls = [_gattrs(g, c).get("control_id", c)
1128
1252
  for c in g.of_type("Control")
1129
- if g.get(c)["attrs"].get("family", "").lower().startswith(ident.lower())
1130
- or ident.lower() in g.get(c)["attrs"].get("family", "").lower()]
1253
+ if _gattrs(g, c).get("family", "").lower().startswith(ident.lower())
1254
+ or ident.lower() in _gattrs(g, c).get("family", "").lower()]
1131
1255
  return f"Controls in '{ident}': " + (", ".join(sorted(ctrls)) or "(none)")
1132
1256
  if op in ("component", "implementers", "inherited"):
1133
1257
  comp = rmf_graph.component_id(ident)
1134
1258
  if op == "implementers":
1135
1259
  node = rmf_graph.control_id(ident)
1136
- comps = [g.get(c)["attrs"].get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
1260
+ comps = [_gattrs(g, c).get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
1137
1261
  return f"Components implementing {ident}: " + (", ".join(comps) or "(none)")
1138
1262
  if op == "inherited":
1139
1263
  inh = g.inherited_controls(comp)
1140
- names = [g.get(c)["attrs"].get("control_id", c) if g.get(c) else c for c in inh]
1264
+ names = [_gattrs(g, c).get("control_id", c) if g.get(c) else c for c in inh]
1141
1265
  return (f"{ident} inherits {len(names)} control(s) from its parent system(s): "
1142
1266
  + (", ".join(names) or "(none — no RESIDES_ON recorded)"))
1143
1267
  n = g.get(comp)
1144
1268
  if not n:
1145
1269
  return f"No component '{ident}' in the graph (add it with GraphAdd)."
1146
- impl = [g.get(c)["attrs"].get("control_id", c) for c in g.neighbors(comp, "IMPLEMENTS", direction="out")]
1147
- res = [g.get(p)["attrs"].get("name", p) for p in g.neighbors(comp, "RESIDES_ON", direction="out")]
1148
- vulns = [g.get(v)["attrs"].get("vuln_id", v) for v in g.neighbors(comp, "AFFECTS", direction="in")]
1270
+ impl = [_gattrs(g, c).get("control_id", c) for c in g.neighbors(comp, "IMPLEMENTS", direction="out")]
1271
+ res = [_gattrs(g, p).get("name", p) for p in g.neighbors(comp, "RESIDES_ON", direction="out")]
1272
+ vulns = [_gattrs(g, v).get("vuln_id", v) for v in g.neighbors(comp, "AFFECTS", direction="in")]
1149
1273
  return (f"Component {ident} ({n['attrs'].get('os','')}): implements "
1150
1274
  f"{', '.join(impl) or 'none'}; resides on {', '.join(res) or 'nothing'}; "
1151
1275
  f"flaws {', '.join(vulns) or 'none'}.")
@@ -1258,6 +1382,9 @@ _TOOLS = [
1258
1382
  ("Knowledge", tool_knowledge, True),
1259
1383
  ("GraphQuery", tool_graphquery, True),
1260
1384
  ("GraphAdd", tool_graphadd, False),
1385
+ ("StigRules", tool_stigrules, True),
1386
+ ("StigRule", tool_stigrule, True),
1387
+ ("StigSet", tool_stigset, False),
1261
1388
  ("WebSearch", tool_websearch, True),
1262
1389
  ("WebFetch", tool_webfetch, True),
1263
1390
  ("GitStatus", tool_gitstatus, True),
@@ -1275,6 +1402,7 @@ def register_all():
1275
1402
  "todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
1276
1403
  "Knowledge": tool_knowledge,
1277
1404
  "GraphQuery": tool_graphquery, "GraphAdd": tool_graphadd,
1405
+ "StigRules": tool_stigrules, "StigRule": tool_stigrule, "StigSet": tool_stigset,
1278
1406
  "WebSearch": tool_websearch, "WebFetch": tool_webfetch,
1279
1407
  "GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
1280
1408
  "GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
@@ -1283,6 +1411,7 @@ def register_all():
1283
1411
  # GitCommit + GraphAdd write).
1284
1412
  read_only = name in (
1285
1413
  "Read", "Glob", "Grep", "task", "Dispatch", "Knowledge", "GraphQuery",
1414
+ "StigRules", "StigRule",
1286
1415
  "WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
1287
1416
  )
1288
1417
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.64
3
+ Version: 3.0.65
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
@@ -20,6 +20,7 @@ drydock/providers.py
20
20
  drydock/rmf.py
21
21
  drydock/rmf_graph.py
22
22
  drydock/skills.py
23
+ drydock/stig.py
23
24
  drydock/tool_registry.py
24
25
  drydock/tuning.py
25
26
  drydock/web.py
@@ -73,6 +74,7 @@ tests/test_rmf.py
73
74
  tests/test_rmf_graph.py
74
75
  tests/test_runaway_repetition.py
75
76
  tests/test_skills.py
77
+ tests/test_stig.py
76
78
  tests/test_stop.py
77
79
  tests/test_streaming_newlines.py
78
80
  tests/test_subagent.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.64"
10
+ version = "3.0.65"
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,70 @@
1
+ """STIG .ckl/.cklb engine — parse, edit-in-place, round-trip; + the tools."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from drydock import stig
7
+ from drydock.tools import tool_stigrules, tool_stigrule, tool_stigset
8
+
9
+ _CKL = '''<?xml version="1.0" encoding="UTF-8"?>
10
+ <CHECKLIST><ASSET><HOST_NAME>web01</HOST_NAME><HOST_IP>10.0.0.5</HOST_IP></ASSET>
11
+ <STIGS><iSTIG><STIG_INFO></STIG_INFO>
12
+ <VULN>
13
+ <STIG_DATA><VULN_ATTRIBUTE>Vuln_Num</VULN_ATTRIBUTE><ATTRIBUTE_DATA>V-230222</ATTRIBUTE_DATA></STIG_DATA>
14
+ <STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-230222r1</ATTRIBUTE_DATA></STIG_DATA>
15
+ <STIG_DATA><VULN_ATTRIBUTE>Severity</VULN_ATTRIBUTE><ATTRIBUTE_DATA>medium</ATTRIBUTE_DATA></STIG_DATA>
16
+ <STIG_DATA><VULN_ATTRIBUTE>Rule_Title</VULN_ATTRIBUTE><ATTRIBUTE_DATA>Enable FIPS</ATTRIBUTE_DATA></STIG_DATA>
17
+ <STIG_DATA><VULN_ATTRIBUTE>Check_Content</VULN_ATTRIBUTE><ATTRIBUTE_DATA>Run fips-mode-setup --check</ATTRIBUTE_DATA></STIG_DATA>
18
+ <STIG_DATA><VULN_ATTRIBUTE>Fix_Text</VULN_ATTRIBUTE><ATTRIBUTE_DATA>Run fips-mode-setup --enable</ATTRIBUTE_DATA></STIG_DATA>
19
+ <STATUS>Not_Reviewed</STATUS><FINDING_DETAILS></FINDING_DETAILS><COMMENTS></COMMENTS></VULN>
20
+ </iSTIG></STIGS></CHECKLIST>'''
21
+
22
+ _CKLB = {"title": "RHEL9", "target_data": {"host_name": "web01"}, "stigs": [{"stig_name": "RHEL 9",
23
+ "rules": [{"group_id": "V-230222", "rule_id": "SV-230222r1", "rule_title": "Enable FIPS",
24
+ "severity": "medium", "check_content": "fips-mode-setup --check",
25
+ "fix_text": "fips-mode-setup --enable", "status": "not_reviewed",
26
+ "finding_details": "", "comments": ""}]}]}
27
+
28
+
29
+ def test_ckl_roundtrip_edit_in_place(tmp_path):
30
+ p = tmp_path / "r.ckl"; p.write_text(_CKL)
31
+ cl = stig.load(p)
32
+ assert cl.fmt == "ckl" and cl.asset["HOST_NAME"] == "web01"
33
+ assert cl.rules[0].check_content == "Run fips-mode-setup --check"
34
+ assert cl.update("V-230222", status="open", finding_details="FIPS off.", comments="by drydock")
35
+ cl.save(p)
36
+ re = stig.load(p)
37
+ assert re.rules[0].status == "open" and re.rules[0].finding_details == "FIPS off."
38
+ # other STIG_DATA preserved (schema fidelity)
39
+ assert "Vuln_Num" in p.read_text() and "<STATUS>Open</STATUS>" in p.read_text()
40
+
41
+
42
+ def test_cklb_roundtrip(tmp_path):
43
+ p = tmp_path / "r.cklb"; p.write_text(json.dumps(_CKLB))
44
+ cl = stig.load(p)
45
+ assert cl.fmt == "cklb"
46
+ cl.update("SV-230222r1", status="not_a_finding", finding_details="FIPS on.")
47
+ cl.save(p)
48
+ data = json.loads(p.read_text()) # still valid JSON
49
+ assert data["stigs"][0]["rules"][0]["status"] == "not_a_finding"
50
+ assert stig.load(p).rules[0].finding_details == "FIPS on."
51
+
52
+
53
+ def test_status_canonicalization():
54
+ assert stig.canonical_status("NotAFinding") == "not_a_finding"
55
+ assert stig.canonical_status("Not_Applicable") == "not_applicable"
56
+ assert stig.canonical_status("garbage") == "not_reviewed"
57
+
58
+
59
+ def test_stig_tools(tmp_path):
60
+ p = tmp_path / "r.ckl"; p.write_text(_CKL); cfg = {"cwd": str(tmp_path)}
61
+ out = tool_stigrules({"path": "r.ckl"}, cfg)
62
+ assert "V-230222" in out and "not_reviewed=1" in out
63
+ detail = tool_stigrule({"path": "r.ckl", "rule_id": "V-230222"}, cfg)
64
+ assert "Check Content" in detail and "fips-mode-setup --check" in detail
65
+ set_out = tool_stigset({"path": "r.ckl", "rule_id": "V-230222", "status": "open",
66
+ "finding_details": "FIPS disabled."}, cfg)
67
+ assert "set to open" in set_out
68
+ assert stig.load(p).rules[0].status == "open"
69
+ # filter by status
70
+ assert "not_reviewed=0" in tool_stigrules({"path": "r.ckl"}, cfg)
File without changes
File without changes
File without changes
File without changes