drydock-cli 3.0.62__tar.gz → 3.0.64__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.
- {drydock_cli-3.0.62/drydock_cli.egg-info → drydock_cli-3.0.64}/PKG-INFO +25 -1
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/README.md +24 -0
- drydock_cli-3.0.64/drydock/builtin_skills/__init__.py +0 -0
- drydock_cli-3.0.64/drydock/builtin_skills/rmf-categorize.md +16 -0
- drydock_cli-3.0.64/drydock/builtin_skills/rmf-control.md +10 -0
- drydock_cli-3.0.64/drydock/builtin_skills/rmf-poam.md +11 -0
- drydock_cli-3.0.64/drydock/builtin_skills/rmf-review.md +11 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/graphrag.py +4 -1
- drydock_cli-3.0.64/drydock/rmf.py +132 -0
- drydock_cli-3.0.64/drydock/rmf_graph.py +155 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/skills.py +7 -1
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tools/__init__.py +133 -2
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tui/app.py +56 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tui/widgets.py +1 -1
- {drydock_cli-3.0.62 → drydock_cli-3.0.64/drydock_cli.egg-info}/PKG-INFO +25 -1
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock_cli.egg-info/SOURCES.txt +9 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/pyproject.toml +5 -1
- drydock_cli-3.0.64/tests/test_rmf.py +51 -0
- drydock_cli-3.0.64/tests/test_rmf_graph.py +62 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_skills.py +4 -2
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/LICENSE +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/NOTICE +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/agent.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/cli.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/config.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/detect.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/extract.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/guards.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/providers.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock/web.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/setup.cfg +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_config.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.62 → drydock_cli-3.0.64}/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.
|
|
3
|
+
Version: 3.0.64
|
|
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
|
|
@@ -89,6 +89,8 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
|
|
|
89
89
|
| `/<name>` | Run a skill |
|
|
90
90
|
| `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
|
|
91
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 |
|
|
92
94
|
| `/model` · `/cwd` | Show/set model & endpoint · working directory |
|
|
93
95
|
| `/undo` · `/back` | Revert the last write · rewind the last turn |
|
|
94
96
|
| `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
|
|
@@ -120,6 +122,28 @@ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
|
|
|
120
122
|
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
121
123
|
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
122
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
|
+
|
|
140
|
+
Beyond text retrieval, `/rmf bootstrap` also builds a **typed ontology graph**
|
|
141
|
+
(Control / Component / Vulnerability nodes; IMPLEMENTS / RESIDES_ON / ASSESSES
|
|
142
|
+
edges). The agent records your system topology with `GraphAdd` and traces
|
|
143
|
+
relationships with `GraphQuery` — including **control inheritance** ("which
|
|
144
|
+
servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
|
|
145
|
+
no Neo4j.
|
|
146
|
+
|
|
123
147
|
## Install
|
|
124
148
|
|
|
125
149
|
```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 |
|
|
@@ -96,6 +98,28 @@ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
|
|
|
96
98
|
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
97
99
|
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
98
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
|
+
|
|
116
|
+
Beyond text retrieval, `/rmf bootstrap` also builds a **typed ontology graph**
|
|
117
|
+
(Control / Component / Vulnerability nodes; IMPLEMENTS / RESIDES_ON / ASSESSES
|
|
118
|
+
edges). The agent records your system topology with `GraphAdd` and traces
|
|
119
|
+
relationships with `GraphQuery` — including **control inheritance** ("which
|
|
120
|
+
servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
|
|
121
|
+
no Neo4j.
|
|
122
|
+
|
|
99
123
|
## Install
|
|
100
124
|
|
|
101
125
|
```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.
|
|
@@ -52,6 +52,9 @@ _RE_IDENT = re.compile(r"\b([a-zA-Z_][a-zA-Z0-9_]*(?:[_./][a-zA-Z0-9_]+)+)\b")
|
|
|
52
52
|
_RE_CAMEL = re.compile(r"\b([A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+)\b")
|
|
53
53
|
_RE_PROPER = re.compile(r"\b([A-Z][a-zA-Z0-9]+(?:[ \t]+[A-Z][a-zA-Z0-9]+){0,3})\b")
|
|
54
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")
|
|
55
58
|
_RE_WORD = re.compile(r"[a-zA-Z0-9_]+")
|
|
56
59
|
|
|
57
60
|
|
|
@@ -63,7 +66,7 @@ def default_store_path(cwd: str) -> Path:
|
|
|
63
66
|
def extract_entities(text: str) -> list[str]:
|
|
64
67
|
"""Heuristic entity extraction → normalized (lowercased) entity keys."""
|
|
65
68
|
found: set[str] = set()
|
|
66
|
-
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):
|
|
67
70
|
for m in rx.findall(text):
|
|
68
71
|
e = m.strip().lower()
|
|
69
72
|
# Drop trivial / stopword-only entities and overlong noise.
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
# Phase 2: also build the typed ontology graph (Control + Objective backbone)
|
|
127
|
+
# so the agent can TRACE relationships via GraphQuery, not just retrieve text.
|
|
128
|
+
from drydock import rmf_graph
|
|
129
|
+
g = rmf_graph.build_from_catalog(catalog, families=families)
|
|
130
|
+
g.save(rmf_graph.graph_path(cwd))
|
|
131
|
+
stats["graph"] = g.stats()
|
|
132
|
+
return stats
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""RMF typed ontology graph — schema-typed nodes + relationships for RMF
|
|
2
|
+
traceability (Phase 2 of Operation RMF Automata).
|
|
3
|
+
|
|
4
|
+
Clean-room, stdlib only: a small in-memory typed graph persisted as JSON (NOT an
|
|
5
|
+
external Neo4j) so it stays local-first and dependency-free. It complements the
|
|
6
|
+
GraphRAG text KB: GraphRAG answers "what does the doc say", this answers "trace
|
|
7
|
+
the relationships" — e.g. which controls a component inherits from its parent.
|
|
8
|
+
|
|
9
|
+
Node types: Control · Objective (assessment objective / test case) · Component ·
|
|
10
|
+
Vulnerability · STIG · STIGRule · Boundary
|
|
11
|
+
Edge types: ASSESSES (Objective→Control) · IMPLEMENTS (Component→Control) ·
|
|
12
|
+
RESIDES_ON (Component→Component/Boundary) · AFFECTS (Vuln→Component) ·
|
|
13
|
+
SATISFIED_BY (Control→STIGRule) · EVALUATES (STIGRule→Component) ·
|
|
14
|
+
PART_OF (STIGRule→STIG) · APPLIES_TO (STIG→Component) · MITIGATES
|
|
15
|
+
|
|
16
|
+
All logic original to Drydock.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def graph_path(cwd: str) -> Path:
|
|
25
|
+
return Path(cwd) / ".drydock" / "rmf" / "graph.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RmfGraph:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.nodes: dict[str, dict] = {} # id -> {type, attrs}
|
|
31
|
+
self.edges: list[dict] = [] # {src, rel, dst}
|
|
32
|
+
self._edge_keys: set[tuple] = set()
|
|
33
|
+
|
|
34
|
+
# ── mutation ────────────────────────────────────────────────────────────
|
|
35
|
+
def add_node(self, nid: str, ntype: str, **attrs) -> str:
|
|
36
|
+
nid = nid.strip()
|
|
37
|
+
node = self.nodes.setdefault(nid, {"type": ntype, "attrs": {}})
|
|
38
|
+
node["type"] = ntype
|
|
39
|
+
node["attrs"].update({k: v for k, v in attrs.items() if v is not None})
|
|
40
|
+
return nid
|
|
41
|
+
|
|
42
|
+
def add_edge(self, src: str, rel: str, dst: str) -> None:
|
|
43
|
+
key = (src, rel, dst)
|
|
44
|
+
if key in self._edge_keys:
|
|
45
|
+
return
|
|
46
|
+
self._edge_keys.add(key)
|
|
47
|
+
self.edges.append({"src": src, "rel": rel, "dst": dst})
|
|
48
|
+
|
|
49
|
+
# ── queries ─────────────────────────────────────────────────────────────
|
|
50
|
+
def get(self, nid: str) -> dict | None:
|
|
51
|
+
n = self.nodes.get(nid)
|
|
52
|
+
return {"id": nid, **n} if n else None
|
|
53
|
+
|
|
54
|
+
def of_type(self, ntype: str) -> list[str]:
|
|
55
|
+
return [nid for nid, n in self.nodes.items() if n["type"] == ntype]
|
|
56
|
+
|
|
57
|
+
def neighbors(self, nid: str, rel: str | None = None, *, direction: str = "out") -> list[str]:
|
|
58
|
+
out = []
|
|
59
|
+
for e in self.edges:
|
|
60
|
+
if rel and e["rel"] != rel:
|
|
61
|
+
continue
|
|
62
|
+
if direction == "out" and e["src"] == nid:
|
|
63
|
+
out.append(e["dst"])
|
|
64
|
+
elif direction == "in" and e["dst"] == nid:
|
|
65
|
+
out.append(e["src"])
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
def inherited_controls(self, component: str) -> list[str]:
|
|
69
|
+
"""Controls a component inherits from its ancestors (RESIDES_ON, transitive).
|
|
70
|
+
The PRD's "which servers inherit physical controls" / inheritance logic."""
|
|
71
|
+
inherited: list[str] = []
|
|
72
|
+
seen: set[str] = set()
|
|
73
|
+
frontier = self.neighbors(component, "RESIDES_ON", direction="out")
|
|
74
|
+
while frontier:
|
|
75
|
+
parent = frontier.pop()
|
|
76
|
+
if parent in seen:
|
|
77
|
+
continue
|
|
78
|
+
seen.add(parent)
|
|
79
|
+
inherited.extend(self.neighbors(parent, "IMPLEMENTS", direction="out"))
|
|
80
|
+
frontier.extend(self.neighbors(parent, "RESIDES_ON", direction="out"))
|
|
81
|
+
# de-dup, preserve order
|
|
82
|
+
s: set[str] = set()
|
|
83
|
+
return [c for c in inherited if not (c in s or s.add(c))]
|
|
84
|
+
|
|
85
|
+
# ── persistence ─────────────────────────────────────────────────────────
|
|
86
|
+
def save(self, path: str | Path) -> None:
|
|
87
|
+
p = Path(path)
|
|
88
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
p.write_text(json.dumps({"nodes": self.nodes, "edges": self.edges}), "utf-8")
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def load(cls, path: str | Path) -> "RmfGraph":
|
|
93
|
+
g = cls()
|
|
94
|
+
p = Path(path)
|
|
95
|
+
if p.exists():
|
|
96
|
+
try:
|
|
97
|
+
data = json.loads(p.read_text("utf-8"))
|
|
98
|
+
g.nodes = data.get("nodes", {})
|
|
99
|
+
for e in data.get("edges", []):
|
|
100
|
+
g.add_edge(e["src"], e["rel"], e["dst"])
|
|
101
|
+
except (OSError, ValueError, KeyError):
|
|
102
|
+
pass
|
|
103
|
+
return g
|
|
104
|
+
|
|
105
|
+
def stats(self) -> dict:
|
|
106
|
+
by_type: dict[str, int] = {}
|
|
107
|
+
for n in self.nodes.values():
|
|
108
|
+
by_type[n["type"]] = by_type.get(n["type"], 0) + 1
|
|
109
|
+
return {"nodes": len(self.nodes), "edges": len(self.edges), "by_type": by_type}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── id helpers (stable, typed) ──────────────────────────────────────────────
|
|
113
|
+
def control_id(cid: str) -> str:
|
|
114
|
+
return f"control:{cid.lower()}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def component_id(name: str) -> str:
|
|
118
|
+
return f"component:{name.strip().lower()}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_from_catalog(catalog: dict, *, families: list[str] | None = None) -> RmfGraph:
|
|
122
|
+
"""Deterministically build Control + Objective nodes (and ASSESSES edges) from
|
|
123
|
+
an OSCAL 800-53 catalog. The typed backbone the user's components/vulns attach to."""
|
|
124
|
+
g = RmfGraph()
|
|
125
|
+
want = {f.lower() for f in families} if families else None
|
|
126
|
+
for group in catalog.get("catalog", {}).get("groups", []) or []:
|
|
127
|
+
fid = group.get("id", "")
|
|
128
|
+
if want is not None and fid.lower() not in want:
|
|
129
|
+
continue
|
|
130
|
+
family = group.get("title", fid)
|
|
131
|
+
for control in group.get("controls", []) or []:
|
|
132
|
+
_add_control(g, control, family)
|
|
133
|
+
for enh in control.get("controls", []) or []:
|
|
134
|
+
_add_control(g, enh, family, parent=control.get("id"))
|
|
135
|
+
return g
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _add_control(g: RmfGraph, control: dict, family: str, parent: str | None = None) -> None:
|
|
139
|
+
cid = control.get("id", "")
|
|
140
|
+
if not cid:
|
|
141
|
+
return
|
|
142
|
+
node = control_id(cid)
|
|
143
|
+
g.add_node(node, "Control", control_id=cid.upper(), title=control.get("title", ""),
|
|
144
|
+
family=family)
|
|
145
|
+
if parent:
|
|
146
|
+
g.add_edge(node, "ENHANCES", control_id(parent))
|
|
147
|
+
params = {p["id"]: p.get("label") for p in control.get("params", []) or []}
|
|
148
|
+
for i, part in enumerate(control.get("parts", []) or []):
|
|
149
|
+
if part.get("name") == "assessment-objective":
|
|
150
|
+
from drydock.rmf import _part_prose
|
|
151
|
+
prose = _part_prose(part, params)
|
|
152
|
+
if prose:
|
|
153
|
+
oid = f"objective:{cid.lower()}:{i}"
|
|
154
|
+
g.add_node(oid, "Objective", prose=prose[:500])
|
|
155
|
+
g.add_edge(oid, "ASSESSES", node)
|
|
@@ -74,7 +74,13 @@ def _parse(path: Path) -> Skill | None:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def skills_dirs(cwd: str) -> list[Path]:
|
|
77
|
-
|
|
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",
|
|
@@ -270,6 +270,48 @@ SCHEMAS = [
|
|
|
270
270
|
"required": ["message"],
|
|
271
271
|
},
|
|
272
272
|
},
|
|
273
|
+
{
|
|
274
|
+
"name": "GraphQuery",
|
|
275
|
+
"description": (
|
|
276
|
+
"Query the RMF ontology GRAPH to TRACE relationships (typed: Control, "
|
|
277
|
+
"Component, Vulnerability, Objective). Use it for traceability the text "
|
|
278
|
+
"knowledge base can't give: a control's assessment objectives, which "
|
|
279
|
+
"components implement a control, or which controls a component INHERITS "
|
|
280
|
+
"from its parent system. ops: control <id>, family <id>, component "
|
|
281
|
+
"<name>, implementers <control>, inherited <component>."
|
|
282
|
+
),
|
|
283
|
+
"input_schema": {
|
|
284
|
+
"type": "object",
|
|
285
|
+
"properties": {
|
|
286
|
+
"op": {"type": "string", "description": "control | family | component | implementers | inherited"},
|
|
287
|
+
"id": {"type": "string", "description": "control id, family id, or component name"},
|
|
288
|
+
},
|
|
289
|
+
"required": ["op", "id"],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"name": "GraphAdd",
|
|
294
|
+
"description": (
|
|
295
|
+
"Record a typed fact in the RMF ontology graph as you read an SSP / "
|
|
296
|
+
"scan / checklist, so relationships can be traced later. ops: component "
|
|
297
|
+
"(a system component), implements (component implements a control), "
|
|
298
|
+
"resides_on (component resides on a parent/boundary — enables control "
|
|
299
|
+
"inheritance), vulnerability (a finding affecting a component)."
|
|
300
|
+
),
|
|
301
|
+
"input_schema": {
|
|
302
|
+
"type": "object",
|
|
303
|
+
"properties": {
|
|
304
|
+
"op": {"type": "string", "description": "component | implements | resides_on | vulnerability"},
|
|
305
|
+
"component": {"type": "string"},
|
|
306
|
+
"control": {"type": "string", "description": "control id, for op=implements"},
|
|
307
|
+
"parent": {"type": "string", "description": "parent component/boundary, for op=resides_on"},
|
|
308
|
+
"id": {"type": "string", "description": "vulnerability/STIG/CVE id, for op=vulnerability"},
|
|
309
|
+
"severity": {"type": "string"},
|
|
310
|
+
"os": {"type": "string"}, "ip": {"type": "string"}, "data_type": {"type": "string"},
|
|
311
|
+
},
|
|
312
|
+
"required": ["op"],
|
|
313
|
+
},
|
|
314
|
+
},
|
|
273
315
|
{
|
|
274
316
|
"name": "WebSearch",
|
|
275
317
|
"description": (
|
|
@@ -1053,6 +1095,92 @@ def tool_gitcommit(params: dict, config: dict) -> str:
|
|
|
1053
1095
|
return f"git commit failed: {e}"
|
|
1054
1096
|
|
|
1055
1097
|
|
|
1098
|
+
def _rmf_graph(config):
|
|
1099
|
+
from drydock import rmf_graph
|
|
1100
|
+
cwd = config.get("cwd") or os.getcwd()
|
|
1101
|
+
path = rmf_graph.graph_path(cwd)
|
|
1102
|
+
return rmf_graph, rmf_graph.RmfGraph.load(path), path
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def tool_graphquery(params: dict, config: dict) -> str:
|
|
1106
|
+
"""Traverse the RMF typed ontology graph (read-only)."""
|
|
1107
|
+
rmf_graph, g, _ = _rmf_graph(config)
|
|
1108
|
+
if not g.nodes:
|
|
1109
|
+
return ("The RMF graph is empty. Run /rmf bootstrap to build the control "
|
|
1110
|
+
"backbone, and use GraphAdd to record components/relationships.")
|
|
1111
|
+
op = (params.get("op") or "").strip().lower()
|
|
1112
|
+
ident = (params.get("id") or "").strip()
|
|
1113
|
+
if op == "control":
|
|
1114
|
+
node = rmf_graph.control_id(ident)
|
|
1115
|
+
n = g.get(node)
|
|
1116
|
+
if not n:
|
|
1117
|
+
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")]
|
|
1120
|
+
out = [f"{n['attrs'].get('control_id', ident)} — {n['attrs'].get('title','')} "
|
|
1121
|
+
f"(Family: {n['attrs'].get('family','')})"]
|
|
1122
|
+
if objs:
|
|
1123
|
+
out.append("Assessment objectives:\n" + "\n".join(f" - {o}" for o in objs))
|
|
1124
|
+
out.append("Implemented by: " + (", ".join(impl) if impl else "(no components recorded)"))
|
|
1125
|
+
return "\n".join(out)
|
|
1126
|
+
if op == "family":
|
|
1127
|
+
ctrls = [g.get(c)["attrs"].get("control_id", c)
|
|
1128
|
+
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()]
|
|
1131
|
+
return f"Controls in '{ident}': " + (", ".join(sorted(ctrls)) or "(none)")
|
|
1132
|
+
if op in ("component", "implementers", "inherited"):
|
|
1133
|
+
comp = rmf_graph.component_id(ident)
|
|
1134
|
+
if op == "implementers":
|
|
1135
|
+
node = rmf_graph.control_id(ident)
|
|
1136
|
+
comps = [g.get(c)["attrs"].get("name", c) for c in g.neighbors(node, "IMPLEMENTS", direction="in")]
|
|
1137
|
+
return f"Components implementing {ident}: " + (", ".join(comps) or "(none)")
|
|
1138
|
+
if op == "inherited":
|
|
1139
|
+
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]
|
|
1141
|
+
return (f"{ident} inherits {len(names)} control(s) from its parent system(s): "
|
|
1142
|
+
+ (", ".join(names) or "(none — no RESIDES_ON recorded)"))
|
|
1143
|
+
n = g.get(comp)
|
|
1144
|
+
if not n:
|
|
1145
|
+
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")]
|
|
1149
|
+
return (f"Component {ident} ({n['attrs'].get('os','')}): implements "
|
|
1150
|
+
f"{', '.join(impl) or 'none'}; resides on {', '.join(res) or 'nothing'}; "
|
|
1151
|
+
f"flaws {', '.join(vulns) or 'none'}.")
|
|
1152
|
+
return "GraphQuery ops: control <id> | family <id> | component <name> | implementers <control> | inherited <component>"
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def tool_graphadd(params: dict, config: dict) -> str:
|
|
1156
|
+
"""Record a typed fact in the RMF ontology graph (write)."""
|
|
1157
|
+
rmf_graph, g, path = _rmf_graph(config)
|
|
1158
|
+
op = (params.get("op") or "").strip().lower()
|
|
1159
|
+
comp = (params.get("component") or "").strip()
|
|
1160
|
+
if op == "component" and comp:
|
|
1161
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp,
|
|
1162
|
+
os=params.get("os"), ip=params.get("ip"), data_type=params.get("data_type"))
|
|
1163
|
+
g.save(path); return f"Recorded component {comp}."
|
|
1164
|
+
if op == "implements" and comp and params.get("control"):
|
|
1165
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp)
|
|
1166
|
+
g.add_edge(rmf_graph.component_id(comp), "IMPLEMENTS", rmf_graph.control_id(params["control"]))
|
|
1167
|
+
g.save(path); return f"Recorded: {comp} IMPLEMENTS {params['control'].upper()}."
|
|
1168
|
+
if op == "resides_on" and comp and params.get("parent"):
|
|
1169
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp)
|
|
1170
|
+
g.add_node(rmf_graph.component_id(params["parent"]), "Component", name=params["parent"])
|
|
1171
|
+
g.add_edge(rmf_graph.component_id(comp), "RESIDES_ON", rmf_graph.component_id(params["parent"]))
|
|
1172
|
+
g.save(path); return f"Recorded: {comp} RESIDES_ON {params['parent']}."
|
|
1173
|
+
if op == "vulnerability" and params.get("id"):
|
|
1174
|
+
vid = f"vuln:{params['id'].lower()}"
|
|
1175
|
+
g.add_node(vid, "Vulnerability", vuln_id=params["id"], severity=params.get("severity"))
|
|
1176
|
+
if comp:
|
|
1177
|
+
g.add_node(rmf_graph.component_id(comp), "Component", name=comp)
|
|
1178
|
+
g.add_edge(vid, "AFFECTS", rmf_graph.component_id(comp))
|
|
1179
|
+
g.save(path); return f"Recorded vulnerability {params['id']}" + (f" affecting {comp}." if comp else ".")
|
|
1180
|
+
return ("GraphAdd ops: component (name in `component`) | implements (`component`,`control`) "
|
|
1181
|
+
"| resides_on (`component`,`parent`) | vulnerability (`id`, optional `component`,`severity`).")
|
|
1182
|
+
|
|
1183
|
+
|
|
1056
1184
|
def tool_websearch(params: dict, config: dict) -> str:
|
|
1057
1185
|
"""Search the internet (DuckDuckGo). Read-only; clean message when offline."""
|
|
1058
1186
|
from drydock import web
|
|
@@ -1128,6 +1256,8 @@ _TOOLS = [
|
|
|
1128
1256
|
("task", tool_task, True),
|
|
1129
1257
|
("Dispatch", tool_dispatch, True),
|
|
1130
1258
|
("Knowledge", tool_knowledge, True),
|
|
1259
|
+
("GraphQuery", tool_graphquery, True),
|
|
1260
|
+
("GraphAdd", tool_graphadd, False),
|
|
1131
1261
|
("WebSearch", tool_websearch, True),
|
|
1132
1262
|
("WebFetch", tool_webfetch, True),
|
|
1133
1263
|
("GitStatus", tool_gitstatus, True),
|
|
@@ -1144,14 +1274,15 @@ def register_all():
|
|
|
1144
1274
|
"Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
|
|
1145
1275
|
"todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
|
|
1146
1276
|
"Knowledge": tool_knowledge,
|
|
1277
|
+
"GraphQuery": tool_graphquery, "GraphAdd": tool_graphadd,
|
|
1147
1278
|
"WebSearch": tool_websearch, "WebFetch": tool_webfetch,
|
|
1148
1279
|
"GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
|
|
1149
1280
|
"GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
|
|
1150
1281
|
}[name]
|
|
1151
1282
|
# Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
|
|
1152
|
-
# GitCommit
|
|
1283
|
+
# GitCommit + GraphAdd write).
|
|
1153
1284
|
read_only = name in (
|
|
1154
|
-
"Read", "Glob", "Grep", "task", "Dispatch", "Knowledge",
|
|
1285
|
+
"Read", "Glob", "Grep", "task", "Dispatch", "Knowledge", "GraphQuery",
|
|
1155
1286
|
"WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
|
|
1156
1287
|
)
|
|
1157
1288
|
register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
|
|
@@ -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,59 @@ 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
|
+
gstats = stats.get("graph", {})
|
|
558
|
+
msg = (
|
|
559
|
+
f"✓ RMF catalog ingested: {stats['family_docs']} family doc(s), "
|
|
560
|
+
f"{stats['chunks']} KB chunks + a typed graph ({gstats.get('nodes', 0)} "
|
|
561
|
+
f"nodes, {gstats.get('edges', 0)} edges). Try /rmf-control AC-2 (text) "
|
|
562
|
+
"or ask the agent to trace relationships (GraphQuery / GraphAdd)."
|
|
563
|
+
)
|
|
564
|
+
except Exception as e: # noqa: BLE001
|
|
565
|
+
msg = (f"RMF bootstrap failed: {e}. (Needs internet for the one-time "
|
|
566
|
+
"catalog download; after that it works offline.)")
|
|
567
|
+
self.call_from_thread(self._info, msg)
|
|
568
|
+
|
|
513
569
|
def _cmd_mcp(self) -> None:
|
|
514
570
|
"""List connected MCP servers and the tools they expose."""
|
|
515
571
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drydock-cli
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.64
|
|
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
|
|
@@ -89,6 +89,8 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
|
|
|
89
89
|
| `/<name>` | Run a skill |
|
|
90
90
|
| `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
|
|
91
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 |
|
|
92
94
|
| `/model` · `/cwd` | Show/set model & endpoint · working directory |
|
|
93
95
|
| `/undo` · `/back` | Revert the last write · rewind the last turn |
|
|
94
96
|
| `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
|
|
@@ -120,6 +122,28 @@ needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
|
|
|
120
122
|
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
121
123
|
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
122
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
|
+
|
|
140
|
+
Beyond text retrieval, `/rmf bootstrap` also builds a **typed ontology graph**
|
|
141
|
+
(Control / Component / Vulnerability nodes; IMPLEMENTS / RESIDES_ON / ASSESSES
|
|
142
|
+
edges). The agent records your system topology with `GraphAdd` and traces
|
|
143
|
+
relationships with `GraphQuery` — including **control inheritance** ("which
|
|
144
|
+
servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
|
|
145
|
+
no Neo4j.
|
|
146
|
+
|
|
123
147
|
## Install
|
|
124
148
|
|
|
125
149
|
```bash
|
|
@@ -17,10 +17,17 @@ drydock/guards.py
|
|
|
17
17
|
drydock/loop_detect.py
|
|
18
18
|
drydock/mcp.py
|
|
19
19
|
drydock/providers.py
|
|
20
|
+
drydock/rmf.py
|
|
21
|
+
drydock/rmf_graph.py
|
|
20
22
|
drydock/skills.py
|
|
21
23
|
drydock/tool_registry.py
|
|
22
24
|
drydock/tuning.py
|
|
23
25
|
drydock/web.py
|
|
26
|
+
drydock/builtin_skills/__init__.py
|
|
27
|
+
drydock/builtin_skills/rmf-categorize.md
|
|
28
|
+
drydock/builtin_skills/rmf-control.md
|
|
29
|
+
drydock/builtin_skills/rmf-poam.md
|
|
30
|
+
drydock/builtin_skills/rmf-review.md
|
|
24
31
|
drydock/tools/__init__.py
|
|
25
32
|
drydock/tui/__init__.py
|
|
26
33
|
drydock/tui/app.py
|
|
@@ -62,6 +69,8 @@ tests/test_oneshot_unreachable.py
|
|
|
62
69
|
tests/test_plan_autocontinue.py
|
|
63
70
|
tests/test_providers_unreachable.py
|
|
64
71
|
tests/test_read_index.py
|
|
72
|
+
tests/test_rmf.py
|
|
73
|
+
tests/test_rmf_graph.py
|
|
65
74
|
tests/test_runaway_repetition.py
|
|
66
75
|
tests/test_skills.py
|
|
67
76
|
tests/test_stop.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.
|
|
10
|
+
version = "3.0.64"
|
|
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,6 +24,10 @@ 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
|
|
|
@@ -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()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""RMF Phase 2 — the typed ontology graph + GraphQuery/GraphAdd tools.
|
|
2
|
+
Inheritance reasoning is the headline: a component inherits the controls its
|
|
3
|
+
parent system implements (RESIDES_ON → IMPLEMENTS)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from drydock import rmf_graph as G
|
|
7
|
+
from drydock.tools import tool_graphquery, tool_graphadd
|
|
8
|
+
|
|
9
|
+
_CAT = {"catalog": {"groups": [
|
|
10
|
+
{"id": "ac", "title": "Access Control", "controls": [
|
|
11
|
+
{"id": "ac-2", "title": "Account Management",
|
|
12
|
+
"parts": [{"name": "assessment-objective", "prose": "accounts are managed"}]}]},
|
|
13
|
+
{"id": "pe", "title": "Physical and Environmental Protection", "controls": [
|
|
14
|
+
{"id": "pe-3", "title": "Physical Access Control", "parts": []}]},
|
|
15
|
+
]}}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_build_from_catalog_typed_nodes():
|
|
19
|
+
g = G.build_from_catalog(_CAT)
|
|
20
|
+
assert g.get(G.control_id("ac-2"))["attrs"]["title"] == "Account Management"
|
|
21
|
+
objs = g.neighbors(G.control_id("ac-2"), "ASSESSES", direction="in")
|
|
22
|
+
assert objs and g.get(objs[0])["type"] == "Objective"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_inheritance_reasoning():
|
|
26
|
+
g = G.build_from_catalog(_CAT)
|
|
27
|
+
g.add_node(G.component_id("web-1"), "Component", name="web-1")
|
|
28
|
+
g.add_node(G.component_id("enclave"), "Boundary", name="enclave")
|
|
29
|
+
g.add_edge(G.component_id("web-1"), "RESIDES_ON", G.component_id("enclave"))
|
|
30
|
+
g.add_edge(G.component_id("enclave"), "IMPLEMENTS", G.control_id("pe-3"))
|
|
31
|
+
inh = g.inherited_controls(G.component_id("web-1"))
|
|
32
|
+
assert G.control_id("pe-3") in inh
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_persistence_roundtrip(tmp_path):
|
|
36
|
+
g = G.build_from_catalog(_CAT)
|
|
37
|
+
g.add_edge(G.component_id("a"), "RESIDES_ON", G.component_id("b"))
|
|
38
|
+
p = tmp_path / "g.json"; g.save(p)
|
|
39
|
+
g2 = G.RmfGraph.load(p)
|
|
40
|
+
assert g2.stats()["nodes"] == g.stats()["nodes"]
|
|
41
|
+
assert {"src": G.component_id("a"), "rel": "RESIDES_ON", "dst": G.component_id("b")} in g2.edges
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_graphadd_and_graphquery_tools(tmp_path):
|
|
45
|
+
from drydock import rmf_graph
|
|
46
|
+
# seed a catalog graph
|
|
47
|
+
g = G.build_from_catalog(_CAT)
|
|
48
|
+
g.save(rmf_graph.graph_path(str(tmp_path)))
|
|
49
|
+
cfg = {"cwd": str(tmp_path)}
|
|
50
|
+
# build a system via the write tool
|
|
51
|
+
assert "RESIDES_ON" in tool_graphadd({"op": "resides_on", "component": "srv1", "parent": "encl"}, cfg)
|
|
52
|
+
assert "IMPLEMENTS" in tool_graphadd({"op": "implements", "component": "encl", "control": "PE-3"}, cfg)
|
|
53
|
+
# query inheritance via the read tool
|
|
54
|
+
out = tool_graphquery({"op": "inherited", "id": "srv1"}, cfg)
|
|
55
|
+
assert "PE-3" in out and "inherits" in out
|
|
56
|
+
# control lookup + implementers
|
|
57
|
+
assert "Account Management" in tool_graphquery({"op": "control", "id": "AC-2"}, cfg)
|
|
58
|
+
assert "encl" in tool_graphquery({"op": "implementers", "id": "PE-3"}, cfg)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_graphquery_empty_graph_is_graceful(tmp_path):
|
|
62
|
+
assert "empty" in tool_graphquery({"op": "control", "id": "AC-2"}, {"cwd": str(tmp_path)}).lower()
|
|
@@ -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
|
|
56
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|