drydock-cli 3.0.61__tar.gz → 3.0.63__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 (86) hide show
  1. {drydock_cli-3.0.61/drydock_cli.egg-info → drydock_cli-3.0.63}/PKG-INFO +24 -4
  2. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/README.md +21 -3
  3. drydock_cli-3.0.63/drydock/builtin_skills/__init__.py +0 -0
  4. drydock_cli-3.0.63/drydock/builtin_skills/rmf-categorize.md +16 -0
  5. drydock_cli-3.0.63/drydock/builtin_skills/rmf-control.md +10 -0
  6. drydock_cli-3.0.63/drydock/builtin_skills/rmf-poam.md +11 -0
  7. drydock_cli-3.0.63/drydock/builtin_skills/rmf-review.md +11 -0
  8. drydock_cli-3.0.63/drydock/extract.py +94 -0
  9. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/graphrag.py +17 -6
  10. drydock_cli-3.0.63/drydock/rmf.py +126 -0
  11. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/skills.py +7 -1
  12. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tui/app.py +54 -0
  13. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tui/widgets.py +1 -1
  14. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tuning.py +1 -1
  15. {drydock_cli-3.0.61 → drydock_cli-3.0.63/drydock_cli.egg-info}/PKG-INFO +24 -4
  16. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock_cli.egg-info/SOURCES.txt +9 -0
  17. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock_cli.egg-info/requires.txt +3 -0
  18. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/pyproject.toml +8 -1
  19. drydock_cli-3.0.63/tests/test_extract.py +71 -0
  20. drydock_cli-3.0.63/tests/test_rmf.py +51 -0
  21. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_skills.py +4 -2
  22. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/LICENSE +0 -0
  23. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/NOTICE +0 -0
  24. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/__init__.py +0 -0
  25. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/__main__.py +0 -0
  26. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/agent.py +0 -0
  27. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/bash_safety.py +0 -0
  28. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/cli.py +0 -0
  29. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/compaction.py +0 -0
  30. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/config.py +0 -0
  31. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/detect.py +0 -0
  32. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/gittools.py +0 -0
  33. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/guards.py +0 -0
  34. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/loop_detect.py +0 -0
  35. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/mcp.py +0 -0
  36. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/providers.py +0 -0
  37. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tool_registry.py +0 -0
  38. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tools/__init__.py +0 -0
  39. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tui/__init__.py +0 -0
  40. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tui/approval.py +0 -0
  41. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/tui/messages.py +0 -0
  42. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock/web.py +0 -0
  43. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock_cli.egg-info/dependency_links.txt +0 -0
  44. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock_cli.egg-info/entry_points.txt +0 -0
  45. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/drydock_cli.egg-info/top_level.txt +0 -0
  46. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/setup.cfg +0 -0
  47. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_approval.py +0 -0
  48. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_back_command.py +0 -0
  49. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_bash_output_bounding.py +0 -0
  50. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_bash_process_group.py +0 -0
  51. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_bash_safety.py +0 -0
  52. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_bash_timeout_network.py +0 -0
  53. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_cli_agents.py +0 -0
  54. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_compact_command.py +0 -0
  55. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_compaction.py +0 -0
  56. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_config.py +0 -0
  57. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_config_migration.py +0 -0
  58. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_context_limit_config.py +0 -0
  59. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_detect.py +0 -0
  60. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_dispatch.py +0 -0
  61. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_empty_response.py +0 -0
  62. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_failure_loop.py +0 -0
  63. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_first_run_setup.py +0 -0
  64. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_gittools.py +0 -0
  65. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_graphrag.py +0 -0
  66. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_guards_and_tools.py +0 -0
  67. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_hallucinated_tools.py +0 -0
  68. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_leaked_tool_call.py +0 -0
  69. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_loop_detect.py +0 -0
  70. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_mcp.py +0 -0
  71. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_oneshot_unreachable.py +0 -0
  72. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_plan_autocontinue.py +0 -0
  73. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_providers_unreachable.py +0 -0
  74. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_read_index.py +0 -0
  75. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_runaway_repetition.py +0 -0
  76. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_stop.py +0 -0
  77. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_streaming_newlines.py +0 -0
  78. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_subagent.py +0 -0
  79. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_system_prompt_help.py +0 -0
  80. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_todo.py +0 -0
  81. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_tool_arg_parsing.py +0 -0
  82. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_tools_undo.py +0 -0
  83. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_tui.py +0 -0
  84. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_tuning.py +0 -0
  85. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/tests/test_vision_input.py +0 -0
  86. {drydock_cli-3.0.61 → drydock_cli-3.0.63}/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.61
3
+ Version: 3.0.63
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
@@ -18,6 +18,8 @@ Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-timeout; extra == "dev"
19
19
  Requires-Dist: ruff; extra == "dev"
20
20
  Requires-Dist: pyright; extra == "dev"
21
+ Provides-Extra: pdf
22
+ Requires-Dist: pypdf>=4; extra == "pdf"
21
23
  Dynamic: license-file
22
24
 
23
25
  # ⚓ Drydock
@@ -87,6 +89,8 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
87
89
  | `/<name>` | Run a skill |
88
90
  | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
89
91
  | `/mcp` | List connected MCP servers + their tools |
92
+ | `/rmf bootstrap [families]` | Ingest the NIST SP 800-53 catalog (RMF automation) |
93
+ | `/rmf-control` · `/rmf-categorize` · `/rmf-review` · `/rmf-poam` | Bundled RMF skills |
90
94
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
91
95
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
92
96
  | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
@@ -103,9 +107,10 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
103
107
 
104
108
  Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
105
109
  tool) when a question touches your material. Ingests text formats
106
- (`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
107
- (e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
108
- `<project>/.drydock/graphrag.json` clean-room, stdlib-only, no embeddings.
110
+ (`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
111
+ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
112
+ `pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
113
+ `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
109
114
 
110
115
  ### Custom skills
111
116
 
@@ -117,6 +122,21 @@ tool) when a question touches your material. Ingests text formats
117
122
  Skills are markdown files in `~/.drydock/skills/` (personal) or
118
123
  `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
119
124
 
125
+ ### RMF automation (NIST SP 800-53)
126
+
127
+ For Risk Management Framework work, Drydock can ingest the NIST SP 800-53 Rev 5
128
+ control catalog into the knowledge base and ships four RMF skills — all
129
+ **100% local** for CUI/sensitive systems.
130
+
131
+ ```
132
+ /rmf bootstrap # one-time: fetch + ingest the 800-53 catalog (offline after)
133
+ /graphrag build ./ssp # ingest your own SSP/POA&M (PDF/Word/text)
134
+ /rmf-control AC-2 # look up a control
135
+ /rmf-categorize ... # FIPS 199 categorization + tailored baseline
136
+ /rmf-review AC-2 # review an SSP implementation statement vs 800-53A
137
+ /rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
138
+ ```
139
+
120
140
  ## Install
121
141
 
122
142
  ```bash
@@ -65,6 +65,8 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
65
65
  | `/<name>` | Run a skill |
66
66
  | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
67
67
  | `/mcp` | List connected MCP servers + their tools |
68
+ | `/rmf bootstrap [families]` | Ingest the NIST SP 800-53 catalog (RMF automation) |
69
+ | `/rmf-control` · `/rmf-categorize` · `/rmf-review` · `/rmf-poam` | Bundled RMF skills |
68
70
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
69
71
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
70
72
  | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
@@ -81,9 +83,10 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
81
83
 
82
84
  Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
83
85
  tool) when a question touches your material. Ingests text formats
84
- (`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
85
- (e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
86
- `<project>/.drydock/graphrag.json` clean-room, stdlib-only, no embeddings.
86
+ (`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
87
+ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
88
+ `pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
89
+ `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
87
90
 
88
91
  ### Custom skills
89
92
 
@@ -95,6 +98,21 @@ tool) when a question touches your material. Ingests text formats
95
98
  Skills are markdown files in `~/.drydock/skills/` (personal) or
96
99
  `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
97
100
 
101
+ ### RMF automation (NIST SP 800-53)
102
+
103
+ For Risk Management Framework work, Drydock can ingest the NIST SP 800-53 Rev 5
104
+ control catalog into the knowledge base and ships four RMF skills — all
105
+ **100% local** for CUI/sensitive systems.
106
+
107
+ ```
108
+ /rmf bootstrap # one-time: fetch + ingest the 800-53 catalog (offline after)
109
+ /graphrag build ./ssp # ingest your own SSP/POA&M (PDF/Word/text)
110
+ /rmf-control AC-2 # look up a control
111
+ /rmf-categorize ... # FIPS 199 categorization + tailored baseline
112
+ /rmf-review AC-2 # review an SSP implementation statement vs 800-53A
113
+ /rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
114
+ ```
115
+
98
116
  ## Install
99
117
 
100
118
  ```bash
File without changes
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: rmf-categorize
3
+ description: Categorize a system (FIPS 199) and recommend a tailored 800-53 baseline
4
+ ---
5
+ Help categorize a system for RMF Steps 1-3. If the user has not given the system
6
+ description and the data types it handles, ask for them first. Then:
7
+ 1. Recommend a FIPS 199 security categorization (Low / Moderate / High) for
8
+ confidentiality, integrity, and availability, each with a brief justification,
9
+ and the resulting overall (high-water-mark) impact level.
10
+ 2. Recommend the tailored initial NIST SP 800-53 baseline for that level — use the
11
+ Knowledge tool to cite the relevant control families and key control IDs.
12
+ 3. Note controls likely tailored OUT given the system boundary (e.g., wireless
13
+ access controls for an air-gapped network).
14
+ Be concrete and cite control IDs.
15
+
16
+ System details: $ARGS
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: rmf-control
3
+ description: Look up a NIST SP 800-53 control from the knowledge base
4
+ ---
5
+ Your FIRST action must be to call the Knowledge tool with the query "$ARGS" —
6
+ do this before writing anything; do not answer from memory. From the passages it
7
+ returns, summarize the control: ID and title, family, the control statement, and
8
+ its key assessment objectives. ONLY if the Knowledge tool returns no passages at
9
+ all may you say the control is not in the knowledge base (then suggest /rmf
10
+ bootstrap). Control to look up: $ARGS
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: rmf-poam
3
+ description: Generate a POA&M entry from a finding / vulnerability / failed STIG
4
+ ---
5
+ First call the Knowledge tool to map this finding to the impacted system
6
+ component(s) and the specific NIST SP 800-53 control(s) it makes non-compliant:
7
+ $ARGS. Then generate a formatted POA&M (Plan of Action & Milestones) entry with:
8
+ Weakness description; Source (scan / STIG / assessment); Affected component(s);
9
+ Non-compliant control(s); Severity; Recommended remediation with concrete
10
+ technical milestones; estimated remediation timeline. Be specific (e.g., "Apply
11
+ patch X to Server Y to satisfy SI-2"). Consult the Knowledge tool before writing.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: rmf-review
3
+ description: Review an SSP implementation statement against 800-53A objectives
4
+ ---
5
+ First call the Knowledge tool to retrieve the control statement and assessment
6
+ objectives (NIST SP 800-53A) for: $ARGS. Then review the implementation
7
+ statement(s) for those control(s): flag where they are incomplete, lack
8
+ specificity (who / what / how / how often), fail to address part of the control,
9
+ or omit continuous-monitoring requirements. Output: a one-line verdict
10
+ (adequate / needs work), then bulleted gaps, each mapped to the assessment
11
+ objective it fails to satisfy. Always consult the Knowledge tool before judging.
@@ -0,0 +1,94 @@
1
+ """Document text extraction for GraphRAG ingestion — PDF + Word (.docx).
2
+
3
+ Kept clean-room and dependency-light:
4
+ • .docx is a zip of XML, so we read it with STDLIB only (zipfile + ElementTree)
5
+ — no dependency.
6
+ • .pdf uses whatever the system already has: the `pdftotext` binary (poppler)
7
+ if present, else the optional `pypdf` package (`pip install drydock-cli[pdf]`).
8
+
9
+ Returns None when a document can't be read (unsupported, corrupt, or no PDF
10
+ backend available) so ingestion skips it cleanly instead of failing.
11
+
12
+ All logic original to Drydock.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ import xml.etree.ElementTree as ET
18
+ import zipfile
19
+ from pathlib import Path
20
+
21
+ # Extensions we can pull text out of (beyond the plain-text formats GraphRAG
22
+ # already reads directly).
23
+ EXTRACTABLE_EXT = {".pdf", ".docx"}
24
+
25
+ _W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
26
+
27
+
28
+ def _extract_docx(path: str | Path) -> str | None:
29
+ """Pull paragraph text from a .docx (stdlib only). Each <w:p> is a paragraph;
30
+ its text lives in <w:t> runs."""
31
+ try:
32
+ with zipfile.ZipFile(path) as z:
33
+ xml = z.read("word/document.xml")
34
+ except (zipfile.BadZipFile, KeyError, OSError):
35
+ return None
36
+ try:
37
+ root = ET.fromstring(xml)
38
+ except ET.ParseError:
39
+ return None
40
+ paras: list[str] = []
41
+ for p in root.iter(f"{_W}p"):
42
+ runs = [t.text for t in p.iter(f"{_W}t") if t.text]
43
+ if runs:
44
+ paras.append("".join(runs))
45
+ text = "\n\n".join(paras).strip()
46
+ return text or None
47
+
48
+
49
+ def _extract_pdf(path: str | Path) -> str | None:
50
+ """Extract text from a PDF: try the `pdftotext` binary first, then `pypdf`.
51
+ Returns None if neither is available or the PDF has no extractable text."""
52
+ # 1) pdftotext (poppler) — fast and common on Linux/macOS.
53
+ try:
54
+ r = subprocess.run(
55
+ ["pdftotext", "-q", str(path), "-"],
56
+ capture_output=True, text=True, timeout=120,
57
+ )
58
+ if r.returncode == 0 and r.stdout.strip():
59
+ return r.stdout
60
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
61
+ pass
62
+ # 2) pypdf (optional dependency: drydock-cli[pdf]).
63
+ try:
64
+ import pypdf
65
+
66
+ reader = pypdf.PdfReader(str(path))
67
+ text = "\n\n".join((pg.extract_text() or "") for pg in reader.pages).strip()
68
+ return text or None
69
+ except Exception: # noqa: BLE001 — optional dep missing / unreadable PDF
70
+ return None
71
+
72
+
73
+ def extract_document(path: str | Path) -> str | None:
74
+ """Extract text from a supported document (.pdf/.docx), or None."""
75
+ ext = Path(path).suffix.lower()
76
+ if ext == ".docx":
77
+ return _extract_docx(path)
78
+ if ext == ".pdf":
79
+ return _extract_pdf(path)
80
+ return None
81
+
82
+
83
+ def pdf_backend_available() -> bool:
84
+ """Whether a PDF can be extracted at all (pdftotext binary or pypdf)."""
85
+ from shutil import which
86
+
87
+ if which("pdftotext"):
88
+ return True
89
+ try:
90
+ import pypdf # noqa: F401
91
+
92
+ return True
93
+ except ImportError:
94
+ return False
@@ -26,6 +26,8 @@ import json
26
26
  import os
27
27
  import re
28
28
  from collections import Counter, defaultdict
29
+
30
+ from drydock import extract
29
31
  from pathlib import Path
30
32
 
31
33
  # Files we ingest as text. Everything else (binaries, images) is skipped.
@@ -50,6 +52,9 @@ _RE_IDENT = re.compile(r"\b([a-zA-Z_][a-zA-Z0-9_]*(?:[_./][a-zA-Z0-9_]+)+)\b")
50
52
  _RE_CAMEL = re.compile(r"\b([A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+)\b")
51
53
  _RE_PROPER = re.compile(r"\b([A-Z][a-zA-Z0-9]+(?:[ \t]+[A-Z][a-zA-Z0-9]+){0,3})\b")
52
54
  _RE_ACRONYM = re.compile(r"\b([A-Z]{2,6})\b")
55
+ # Dotted/hyphenated codes like control IDs (AC-2, AC-2.1, SI-4), CVEs, STIG IDs —
56
+ # these tokenize into too-short pieces otherwise, so a lookup by the code misses.
57
+ _RE_CODE = re.compile(r"\b([A-Za-z]{1,6}-\d+(?:\.\d+)*)\b")
53
58
  _RE_WORD = re.compile(r"[a-zA-Z0-9_]+")
54
59
 
55
60
 
@@ -61,7 +66,7 @@ def default_store_path(cwd: str) -> Path:
61
66
  def extract_entities(text: str) -> list[str]:
62
67
  """Heuristic entity extraction → normalized (lowercased) entity keys."""
63
68
  found: set[str] = set()
64
- for rx in (_RE_BACKTICK, _RE_IDENT, _RE_CAMEL, _RE_PROPER, _RE_ACRONYM):
69
+ for rx in (_RE_BACKTICK, _RE_IDENT, _RE_CAMEL, _RE_PROPER, _RE_ACRONYM, _RE_CODE):
65
70
  for m in rx.findall(text):
66
71
  e = m.strip().lower()
67
72
  # Drop trivial / stopword-only entities and overlong noise.
@@ -106,7 +111,8 @@ def _iter_text_files(paths: list[str]):
106
111
  dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
107
112
  for f in files:
108
113
  fp = Path(root) / f
109
- if fp.suffix.lower() in _TEXT_EXT or fp.suffix == "":
114
+ ext = fp.suffix.lower()
115
+ if ext in _TEXT_EXT or ext in extract.EXTRACTABLE_EXT or fp.suffix == "":
110
116
  yield fp
111
117
 
112
118
 
@@ -119,10 +125,15 @@ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
119
125
  rel = os.path.relpath(str(fp), cwd)
120
126
  if rel in skip_sources:
121
127
  continue
122
- try:
123
- text = fp.read_text("utf-8", "ignore")
124
- except OSError:
125
- continue
128
+ if fp.suffix.lower() in extract.EXTRACTABLE_EXT:
129
+ text = extract.extract_document(fp) # PDF/Word → text (or None)
130
+ if not text:
131
+ continue # unreadable / no PDF backend — skip cleanly
132
+ else:
133
+ try:
134
+ text = fp.read_text("utf-8", "ignore")
135
+ except OSError:
136
+ continue
126
137
  if not text.strip():
127
138
  continue
128
139
  added += 1
@@ -0,0 +1,126 @@
1
+ """RMF automation — NIST SP 800-53 catalog ingestion for GraphRAG.
2
+
3
+ Local-first: fetch the machine-readable OSCAL catalog once (over the network),
4
+ flatten each control to readable text (id, family, statement, guidance,
5
+ assessment objective), and ingest it into the project GraphRAG knowledge base so
6
+ the agent can look up and reason over controls offline via the `Knowledge` tool.
7
+
8
+ Pairs with the bundled RMF skills (rmf-categorize / rmf-review / rmf-poam /
9
+ rmf-control) and the user's own ingested SSP / POA&M / scan artifacts.
10
+
11
+ Stdlib only (urllib + json). All logic original to Drydock.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ # NIST's official OSCAL content (public domain). Rev 5 catalog.
21
+ DEFAULT_CATALOG_URL = (
22
+ "https://raw.githubusercontent.com/usnistgov/oscal-content/main/"
23
+ "nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json"
24
+ )
25
+
26
+ _INSERT = re.compile(r"\{\{\s*insert:\s*param,\s*([A-Za-z0-9_.\-]+)\s*\}\}")
27
+ # Part names worth surfacing to the model, in order, with a readable label.
28
+ _PART_LABELS = [
29
+ ("statement", "Statement"),
30
+ ("guidance", "Guidance"),
31
+ ("assessment-objective", "Assessment objective"),
32
+ ]
33
+
34
+
35
+ def fetch_catalog(dest: str | Path, *, url: str = DEFAULT_CATALOG_URL,
36
+ timeout: float = 60.0) -> Path:
37
+ """Download the OSCAL catalog JSON to dest. Returns the path."""
38
+ dest = Path(dest)
39
+ dest.parent.mkdir(parents=True, exist_ok=True)
40
+ req = urllib.request.Request(url, headers={"User-Agent": "drydock-rmf"})
41
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (NIST, trusted)
42
+ dest.write_bytes(r.read())
43
+ return dest
44
+
45
+
46
+ def _resolve(prose: str, params: dict) -> str:
47
+ """Replace OSCAL {{ insert: param, id }} markers with the param's label."""
48
+ def sub(m):
49
+ p = params.get(m.group(1))
50
+ return f"[{p}]" if p else "[assignment]"
51
+ return _INSERT.sub(sub, prose or "")
52
+
53
+
54
+ def _part_prose(part: dict, params: dict) -> str:
55
+ """All prose in a part and its sub-parts, joined."""
56
+ bits = []
57
+ if part.get("prose"):
58
+ bits.append(_resolve(part["prose"], params))
59
+ for sub in part.get("parts", []) or []:
60
+ t = _part_prose(sub, params)
61
+ if t:
62
+ bits.append(t)
63
+ return "\n".join(bits).strip()
64
+
65
+
66
+ def flatten_control(control: dict, family: str) -> str:
67
+ """Render one control as a readable text block for ingestion."""
68
+ params = {p["id"]: p.get("label") or (p.get("select") or {}).get("how-many")
69
+ for p in control.get("params", []) or []}
70
+ cid = control.get("id", "").upper()
71
+ lines = [f"{cid} — {control.get('title', '')} (Family: {family})"]
72
+ parts = {p.get("name"): p for p in control.get("parts", []) or []}
73
+ for name, label in _PART_LABELS:
74
+ if name in parts:
75
+ prose = _part_prose(parts[name], params)
76
+ if prose:
77
+ lines.append(f"{label}: {prose}")
78
+ return "\n".join(lines)
79
+
80
+
81
+ def catalog_to_family_docs(catalog: dict, dest_dir: str | Path,
82
+ families: list[str] | None = None) -> list[Path]:
83
+ """Write one markdown doc per control family (e.g. ac.md). Returns paths.
84
+ `families` (lowercase ids like 'ac') filters which to write; None = all."""
85
+ dest_dir = Path(dest_dir)
86
+ dest_dir.mkdir(parents=True, exist_ok=True)
87
+ want = {f.lower() for f in families} if families else None
88
+ written: list[Path] = []
89
+ for group in catalog.get("catalog", {}).get("groups", []) or []:
90
+ fid = group.get("id", "")
91
+ if want is not None and fid.lower() not in want:
92
+ continue
93
+ title = group.get("title", fid)
94
+ blocks = [f"# {title} ({fid.upper()}) — NIST SP 800-53 Rev 5\n"]
95
+ for control in group.get("controls", []) or []:
96
+ blocks.append(flatten_control(control, title))
97
+ # nested enhancements
98
+ for enh in control.get("controls", []) or []:
99
+ blocks.append(flatten_control(enh, title))
100
+ out = dest_dir / f"{fid}.md"
101
+ out.write_text("\n\n".join(blocks), encoding="utf-8")
102
+ written.append(out)
103
+ return written
104
+
105
+
106
+ def rmf_dir(cwd: str) -> Path:
107
+ return Path(cwd) / ".drydock" / "rmf"
108
+
109
+
110
+ def bootstrap(cwd: str, *, families: list[str] | None = None,
111
+ source: str | Path | None = None) -> dict:
112
+ """Fetch (if needed) + flatten the 800-53 catalog into <cwd>/.drydock/rmf and
113
+ ingest it into the GraphRAG KB. Returns {families, controls_docs, **kb_stats}.
114
+ `source` reuses an already-downloaded catalog JSON (offline)."""
115
+ from drydock import graphrag
116
+
117
+ base = rmf_dir(cwd)
118
+ cat_path = Path(source) if source else base / "catalog.json"
119
+ if not cat_path.exists():
120
+ fetch_catalog(cat_path)
121
+ catalog = json.loads(Path(cat_path).read_text("utf-8"))
122
+ docs = catalog_to_family_docs(catalog, base / "800-53", families)
123
+ store = graphrag.default_store_path(cwd)
124
+ stats = graphrag.add_to_index([str(d) for d in docs], store, cwd=cwd)
125
+ stats["family_docs"] = len(docs)
126
+ return stats
@@ -74,7 +74,13 @@ def _parse(path: Path) -> Skill | None:
74
74
 
75
75
 
76
76
  def skills_dirs(cwd: str) -> list[Path]:
77
- return [Path.home() / ".drydock" / "skills", Path(cwd) / ".drydock" / "skills"]
77
+ # Built-in skills ship with the package (e.g. the RMF skills); user + project
78
+ # dirs come AFTER so they override a built-in of the same name.
79
+ return [
80
+ Path(__file__).parent / "builtin_skills",
81
+ Path.home() / ".drydock" / "skills",
82
+ Path(cwd) / ".drydock" / "skills",
83
+ ]
78
84
 
79
85
 
80
86
  def create_skill(name: str, body: str, *, description: str = "", scope: str = "user",
@@ -495,12 +495,15 @@ class DrydockApp(App):
495
495
  " /skills list skills · /skills new <name> <prompt> to create one\n"
496
496
  " /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
497
497
  " /mcp list connected MCP servers and their tools\n"
498
+ " /rmf RMF automation — /rmf bootstrap, then /rmf-control etc.\n"
498
499
  " /clear reset the conversation\n"
499
500
  " /quit exit\n"
500
501
  "Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
501
502
  )
502
503
  elif cmd == "/mcp":
503
504
  self._cmd_mcp()
505
+ elif cmd == "/rmf":
506
+ self._cmd_rmf(arg)
504
507
  elif cmd == "/loop":
505
508
  self._cmd_loop(arg)
506
509
  elif cmd == "/skills":
@@ -510,6 +513,57 @@ class DrydockApp(App):
510
513
  else:
511
514
  self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
512
515
 
516
+ def _cmd_rmf(self, arg: str) -> None:
517
+ """RMF automation: bootstrap the NIST 800-53 catalog into the knowledge
518
+ base and surface the bundled RMF skills."""
519
+ from drydock import rmf
520
+
521
+ parts = arg.split()
522
+ sub = parts[0].lower() if parts else ""
523
+ cwd = self.config.get("cwd") or "."
524
+ if sub == "bootstrap":
525
+ families = [f.lower() for f in parts[1:]] or None
526
+ scope = ("families " + ", ".join(families)) if families else "all 20 families"
527
+ self._info(
528
+ f"Ingesting the NIST SP 800-53 Rev 5 catalog ({scope}) into the "
529
+ "knowledge base — one-time download + index, this can take ~30s…"
530
+ )
531
+ self.run_worker(lambda: self._rmf_bootstrap(cwd, families), thread=True)
532
+ elif sub in ("", "help", "status"):
533
+ cat = rmf.rmf_dir(cwd) / "catalog.json"
534
+ have = "downloaded" if cat.exists() else "not downloaded yet"
535
+ self._info(
536
+ "RMF automation — automate Risk Management Framework work.\n"
537
+ f" Catalog (NIST 800-53 Rev 5): {have} ({cat})\n"
538
+ " /rmf bootstrap [families…] ingest the control catalog (e.g. "
539
+ "/rmf bootstrap ac si, or no args for all)\n"
540
+ "Skills (run as /<name>):\n"
541
+ " /rmf-control <id> look up a control (e.g. /rmf-control AC-2)\n"
542
+ " /rmf-categorize … FIPS 199 categorization + tailored baseline\n"
543
+ " /rmf-review <control> review an SSP implementation statement\n"
544
+ " /rmf-poam <finding> generate a POA&M entry\n"
545
+ "Ingest your own SSPs/POA&Ms (PDF/Word/text) with /graphrag build "
546
+ "<path> so the skills can cross-reference them."
547
+ )
548
+ else:
549
+ self._info("usage: /rmf bootstrap [families…] · /rmf status")
550
+
551
+ def _rmf_bootstrap(self, cwd: str, families) -> None:
552
+ """Worker-thread body: fetch + ingest the catalog, report back on the UI."""
553
+ from drydock import rmf
554
+
555
+ try:
556
+ stats = rmf.bootstrap(cwd, families=families)
557
+ msg = (
558
+ f"✓ RMF catalog ingested: {stats['family_docs']} family doc(s), "
559
+ f"{stats['chunks']} chunks total in the knowledge base. Controls are "
560
+ "now queryable — try /rmf-control AC-2 or ask about a control."
561
+ )
562
+ except Exception as e: # noqa: BLE001
563
+ msg = (f"RMF bootstrap failed: {e}. (Needs internet for the one-time "
564
+ "catalog download; after that it works offline.)")
565
+ self.call_from_thread(self._info, msg)
566
+
513
567
  def _cmd_mcp(self) -> None:
514
568
  """List connected MCP servers and the tools they expose."""
515
569
  from drydock import mcp
@@ -153,7 +153,7 @@ class PromptHistory:
153
153
  # completes the prefix.
154
154
  SLASH_COMMANDS = [
155
155
  "/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
156
- "/compact", "/graphrag", "/skills", "/loop", "/mcp", "/clear", "/quit",
156
+ "/compact", "/graphrag", "/skills", "/loop", "/mcp", "/rmf", "/clear", "/quit",
157
157
  ]
158
158
 
159
159
 
@@ -217,7 +217,7 @@ _DRYDOCK_COMMANDS_HELP = (
217
217
  "folder), `/graphrag add <path>` to add more, `/graphrag query <q>` to test "
218
218
  "it, `/graphrag status`, `/graphrag clear`. Once built, you automatically use "
219
219
  "the `Knowledge` tool to draw on it. It ingests text formats (md/txt/code/"
220
- "json/yaml/…); PDFs/Word must be converted to text first.\n"
220
+ "json/yaml/…) plus PDF and Word (.docx) directly.\n"
221
221
  "- Custom skills (reusable `/<name>` prompts): `/skills new <name> <prompt "
222
222
  "text>` creates one (use $ARGS in the prompt for trailing input); `/skills` "
223
223
  "lists them; then they run it as `/<name>`.\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.61
3
+ Version: 3.0.63
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
@@ -18,6 +18,8 @@ Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-timeout; extra == "dev"
19
19
  Requires-Dist: ruff; extra == "dev"
20
20
  Requires-Dist: pyright; extra == "dev"
21
+ Provides-Extra: pdf
22
+ Requires-Dist: pypdf>=4; extra == "pdf"
21
23
  Dynamic: license-file
22
24
 
23
25
  # ⚓ Drydock
@@ -87,6 +89,8 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
87
89
  | `/<name>` | Run a skill |
88
90
  | `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
89
91
  | `/mcp` | List connected MCP servers + their tools |
92
+ | `/rmf bootstrap [families]` | Ingest the NIST SP 800-53 catalog (RMF automation) |
93
+ | `/rmf-control` · `/rmf-categorize` · `/rmf-review` · `/rmf-poam` | Bundled RMF skills |
90
94
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
91
95
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
92
96
  | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
@@ -103,9 +107,10 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
103
107
 
104
108
  Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
105
109
  tool) when a question touches your material. Ingests text formats
106
- (`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
107
- (e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
108
- `<project>/.drydock/graphrag.json` clean-room, stdlib-only, no embeddings.
110
+ (`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
111
+ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
112
+ `pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
113
+ `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
109
114
 
110
115
  ### Custom skills
111
116
 
@@ -117,6 +122,21 @@ tool) when a question touches your material. Ingests text formats
117
122
  Skills are markdown files in `~/.drydock/skills/` (personal) or
118
123
  `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
119
124
 
125
+ ### RMF automation (NIST SP 800-53)
126
+
127
+ For Risk Management Framework work, Drydock can ingest the NIST SP 800-53 Rev 5
128
+ control catalog into the knowledge base and ships four RMF skills — all
129
+ **100% local** for CUI/sensitive systems.
130
+
131
+ ```
132
+ /rmf bootstrap # one-time: fetch + ingest the 800-53 catalog (offline after)
133
+ /graphrag build ./ssp # ingest your own SSP/POA&M (PDF/Word/text)
134
+ /rmf-control AC-2 # look up a control
135
+ /rmf-categorize ... # FIPS 199 categorization + tailored baseline
136
+ /rmf-review AC-2 # review an SSP implementation statement vs 800-53A
137
+ /rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
138
+ ```
139
+
120
140
  ## Install
121
141
 
122
142
  ```bash
@@ -10,16 +10,23 @@ drydock/cli.py
10
10
  drydock/compaction.py
11
11
  drydock/config.py
12
12
  drydock/detect.py
13
+ drydock/extract.py
13
14
  drydock/gittools.py
14
15
  drydock/graphrag.py
15
16
  drydock/guards.py
16
17
  drydock/loop_detect.py
17
18
  drydock/mcp.py
18
19
  drydock/providers.py
20
+ drydock/rmf.py
19
21
  drydock/skills.py
20
22
  drydock/tool_registry.py
21
23
  drydock/tuning.py
22
24
  drydock/web.py
25
+ drydock/builtin_skills/__init__.py
26
+ drydock/builtin_skills/rmf-categorize.md
27
+ drydock/builtin_skills/rmf-control.md
28
+ drydock/builtin_skills/rmf-poam.md
29
+ drydock/builtin_skills/rmf-review.md
23
30
  drydock/tools/__init__.py
24
31
  drydock/tui/__init__.py
25
32
  drydock/tui/app.py
@@ -47,6 +54,7 @@ tests/test_context_limit_config.py
47
54
  tests/test_detect.py
48
55
  tests/test_dispatch.py
49
56
  tests/test_empty_response.py
57
+ tests/test_extract.py
50
58
  tests/test_failure_loop.py
51
59
  tests/test_first_run_setup.py
52
60
  tests/test_gittools.py
@@ -60,6 +68,7 @@ tests/test_oneshot_unreachable.py
60
68
  tests/test_plan_autocontinue.py
61
69
  tests/test_providers_unreachable.py
62
70
  tests/test_read_index.py
71
+ tests/test_rmf.py
63
72
  tests/test_runaway_repetition.py
64
73
  tests/test_skills.py
65
74
  tests/test_stop.py
@@ -6,3 +6,6 @@ pytest
6
6
  pytest-timeout
7
7
  ruff
8
8
  pyright
9
+
10
+ [pdf]
11
+ pypdf>=4
@@ -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.61"
10
+ version = "3.0.63"
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"
@@ -24,11 +24,18 @@ Issues = "https://github.com/fbobe321/drydock/issues"
24
24
  [tool.setuptools.packages.find]
25
25
  include = ["drydock*"]
26
26
 
27
+ [tool.setuptools.package-data]
28
+ # Ship the bundled skill prompts (RMF, etc.) inside the wheel.
29
+ "drydock.builtin_skills" = ["*.md"]
30
+
27
31
  [project.scripts]
28
32
  drydock = "drydock.cli:main"
29
33
 
30
34
  [project.optional-dependencies]
31
35
  dev = ["pytest", "pytest-timeout", "ruff", "pyright"]
36
+ # PDF ingestion fallback when the `pdftotext` binary (poppler) isn't installed.
37
+ # .docx needs nothing extra (stdlib zip/xml). `pip install drydock-cli[pdf]`.
38
+ pdf = ["pypdf>=4"]
32
39
 
33
40
  [tool.ruff]
34
41
  target-version = "py312"
@@ -0,0 +1,71 @@
1
+ """PDF + Word (.docx) document extraction for GraphRAG ingestion."""
2
+ from __future__ import annotations
3
+
4
+ import zipfile
5
+
6
+ import pytest
7
+
8
+ from drydock import extract, graphrag
9
+
10
+
11
+ def _make_docx(path, text):
12
+ doc = ('<?xml version="1.0"?><w:document xmlns:w='
13
+ '"http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
14
+ f'<w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>')
15
+ with zipfile.ZipFile(path, "w") as z:
16
+ z.writestr("word/document.xml", doc)
17
+
18
+
19
+ def _make_pdf(path, text):
20
+ objs = [b"<< /Type /Catalog /Pages 2 0 R >>",
21
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
22
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
23
+ b"/Resources << /Font << /F1 5 0 R >> >> >>"]
24
+ stream = b"BT /F1 18 Tf 72 700 Td (" + text.encode() + b") Tj ET"
25
+ objs.append(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
26
+ objs.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
27
+ out = b"%PDF-1.4\n"; offs = []
28
+ for i, o in enumerate(objs, 1):
29
+ offs.append(len(out)); out += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
30
+ x = len(out); out += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
31
+ for o in offs:
32
+ out += ("%010d 00000 n \n" % o).encode()
33
+ out += (b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\n"
34
+ b"startxref\n" + str(x).encode() + b"\n%%EOF")
35
+ path.write_bytes(out)
36
+
37
+
38
+ def test_docx_extraction(tmp_path):
39
+ p = tmp_path / "d.docx"
40
+ _make_docx(p, "The Nimbus service uses the Falcon-Token header.")
41
+ assert extract.extract_document(p) == "The Nimbus service uses the Falcon-Token header."
42
+
43
+
44
+ def test_pdf_extraction(tmp_path):
45
+ if not extract.pdf_backend_available():
46
+ pytest.skip("no PDF backend (pdftotext/pypdf) available")
47
+ p = tmp_path / "m.pdf"
48
+ _make_pdf(p, "Refunds via the Zephyr gateway.")
49
+ out = extract.extract_document(p)
50
+ assert out and "Zephyr gateway" in out
51
+
52
+
53
+ def test_unsupported_and_corrupt_return_none(tmp_path):
54
+ assert extract.extract_document(tmp_path / "x.png") is None
55
+ bad = tmp_path / "bad.docx"; bad.write_text("not a zip")
56
+ assert extract.extract_document(bad) is None
57
+
58
+
59
+ def test_graphrag_ingests_docx_and_pdf(tmp_path):
60
+ _make_docx(tmp_path / "spec.docx", "The Nimbus API uses the Falcon-Token header for auth.")
61
+ if extract.pdf_backend_available():
62
+ _make_pdf(tmp_path / "manual.pdf", "Refunds are issued via the Zephyr gateway.")
63
+ store = tmp_path / ".drydock" / "graphrag.json"
64
+ graphrag.build_index(["."], store, cwd=str(tmp_path))
65
+ idx = graphrag.load_index(store)
66
+ srcs = graphrag.sources(idx)
67
+ assert "spec.docx" in srcs
68
+ assert graphrag.query_index(idx, "Falcon-Token header")["chunks"][0]["source"] == "spec.docx"
69
+ if extract.pdf_backend_available():
70
+ assert "manual.pdf" in srcs
71
+ assert graphrag.query_index(idx, "refunds Zephyr gateway")["chunks"][0]["source"] == "manual.pdf"
@@ -0,0 +1,51 @@
1
+ """RMF: NIST 800-53 catalog flatten + ingest. Uses a tiny synthetic OSCAL catalog
2
+ so the test is offline + fast (no 10 MB download)."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from drydock import rmf, graphrag, skills
8
+
9
+ _CATALOG = {"catalog": {"groups": [{
10
+ "id": "ac", "title": "Access Control", "controls": [
11
+ {"id": "ac-2", "title": "Account Management",
12
+ "params": [{"id": "ac-2_prm_1", "label": "organization-defined roles"}],
13
+ "parts": [
14
+ {"name": "statement", "prose": "Manage accounts for {{ insert: param, ac-2_prm_1 }}."},
15
+ {"name": "assessment-objective", "prose": "Determine if accounts are managed."},
16
+ ],
17
+ "controls": [
18
+ {"id": "ac-2.1", "title": "Automated Account Management",
19
+ "parts": [{"name": "statement", "prose": "Support account management with automation."}]},
20
+ ]},
21
+ ]}]}}
22
+
23
+
24
+ def test_flatten_resolves_params_and_parts(tmp_path):
25
+ docs = rmf.catalog_to_family_docs(_CATALOG, tmp_path)
26
+ text = docs[0].read_text()
27
+ assert "AC-2 — Account Management" in text
28
+ assert "[organization-defined roles]" in text # param resolved
29
+ assert "Assessment objective:" in text
30
+ assert "AC-2.1 — Automated Account Management" in text # enhancement included
31
+
32
+
33
+ def test_family_filter(tmp_path):
34
+ assert rmf.catalog_to_family_docs(_CATALOG, tmp_path, families=["si"]) == [] # no SI in catalog
35
+ assert len(rmf.catalog_to_family_docs(_CATALOG, tmp_path, families=["ac"])) == 1
36
+
37
+
38
+ def test_bootstrap_from_local_source_ingests_into_kb(tmp_path):
39
+ src = tmp_path / "cat.json"; src.write_text(json.dumps(_CATALOG))
40
+ stats = rmf.bootstrap(str(tmp_path), source=str(src))
41
+ assert stats["family_docs"] == 1 and stats["chunks"] >= 1
42
+ idx = graphrag.load_index(graphrag.default_store_path(str(tmp_path)))
43
+ assert idx is not None
44
+ res = graphrag.query_index(idx, "account management automation")
45
+ assert res["chunks"] and "ac" in res["chunks"][0]["source"].lower()
46
+
47
+
48
+ def test_rmf_skills_are_bundled():
49
+ sk = skills.load_skills("/tmp")
50
+ for name in ("rmf-control", "rmf-categorize", "rmf-review", "rmf-poam"):
51
+ assert name in sk and sk[name].body.strip()
@@ -52,8 +52,10 @@ def test_empty_body_skipped(tmp_path):
52
52
  assert "empty" not in skills.load_skills(str(tmp_path))
53
53
 
54
54
 
55
- def test_no_skills_dir_is_empty(tmp_path):
56
- assert skills.load_skills(str(tmp_path)) == {}
55
+ def test_no_user_or_project_skills_yields_only_builtins(tmp_path):
56
+ # With no user/project skills, only the bundled built-ins (RMF) are present.
57
+ loaded = skills.load_skills(str(tmp_path))
58
+ assert set(loaded) == {"rmf-control", "rmf-categorize", "rmf-review", "rmf-poam"}
57
59
 
58
60
 
59
61
  def test_create_skill_user_scope(tmp_path, monkeypatch):
File without changes
File without changes
File without changes