drydock-cli 3.0.63__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.
Files changed (88) hide show
  1. {drydock_cli-3.0.63/drydock_cli.egg-info → drydock_cli-3.0.64}/PKG-INFO +8 -1
  2. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/README.md +7 -0
  3. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/rmf.py +6 -0
  4. drydock_cli-3.0.64/drydock/rmf_graph.py +155 -0
  5. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tools/__init__.py +133 -2
  6. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tui/app.py +4 -2
  7. {drydock_cli-3.0.63 → drydock_cli-3.0.64/drydock_cli.egg-info}/PKG-INFO +8 -1
  8. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock_cli.egg-info/SOURCES.txt +2 -0
  9. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/pyproject.toml +1 -1
  10. drydock_cli-3.0.64/tests/test_rmf_graph.py +62 -0
  11. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/LICENSE +0 -0
  12. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/NOTICE +0 -0
  13. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/__init__.py +0 -0
  14. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/__main__.py +0 -0
  15. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/agent.py +0 -0
  16. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/bash_safety.py +0 -0
  17. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/builtin_skills/__init__.py +0 -0
  18. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/builtin_skills/rmf-categorize.md +0 -0
  19. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/builtin_skills/rmf-control.md +0 -0
  20. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/builtin_skills/rmf-poam.md +0 -0
  21. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/builtin_skills/rmf-review.md +0 -0
  22. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/cli.py +0 -0
  23. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/compaction.py +0 -0
  24. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/config.py +0 -0
  25. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/detect.py +0 -0
  26. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/extract.py +0 -0
  27. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/gittools.py +0 -0
  28. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/graphrag.py +0 -0
  29. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/guards.py +0 -0
  30. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/loop_detect.py +0 -0
  31. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/mcp.py +0 -0
  32. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/providers.py +0 -0
  33. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/skills.py +0 -0
  34. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tool_registry.py +0 -0
  35. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tui/__init__.py +0 -0
  36. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tui/approval.py +0 -0
  37. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tui/messages.py +0 -0
  38. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tui/widgets.py +0 -0
  39. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/tuning.py +0 -0
  40. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock/web.py +0 -0
  41. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock_cli.egg-info/dependency_links.txt +0 -0
  42. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock_cli.egg-info/entry_points.txt +0 -0
  43. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock_cli.egg-info/requires.txt +0 -0
  44. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/drydock_cli.egg-info/top_level.txt +0 -0
  45. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/setup.cfg +0 -0
  46. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_approval.py +0 -0
  47. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_back_command.py +0 -0
  48. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_bash_output_bounding.py +0 -0
  49. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_bash_process_group.py +0 -0
  50. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_bash_safety.py +0 -0
  51. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_bash_timeout_network.py +0 -0
  52. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_cli_agents.py +0 -0
  53. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_compact_command.py +0 -0
  54. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_compaction.py +0 -0
  55. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_config.py +0 -0
  56. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_config_migration.py +0 -0
  57. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_context_limit_config.py +0 -0
  58. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_detect.py +0 -0
  59. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_dispatch.py +0 -0
  60. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_empty_response.py +0 -0
  61. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_extract.py +0 -0
  62. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_failure_loop.py +0 -0
  63. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_first_run_setup.py +0 -0
  64. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_gittools.py +0 -0
  65. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_graphrag.py +0 -0
  66. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_guards_and_tools.py +0 -0
  67. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_hallucinated_tools.py +0 -0
  68. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_leaked_tool_call.py +0 -0
  69. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_loop_detect.py +0 -0
  70. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_mcp.py +0 -0
  71. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_oneshot_unreachable.py +0 -0
  72. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_plan_autocontinue.py +0 -0
  73. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_providers_unreachable.py +0 -0
  74. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_read_index.py +0 -0
  75. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_rmf.py +0 -0
  76. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_runaway_repetition.py +0 -0
  77. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_skills.py +0 -0
  78. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_stop.py +0 -0
  79. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_streaming_newlines.py +0 -0
  80. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_subagent.py +0 -0
  81. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_system_prompt_help.py +0 -0
  82. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_todo.py +0 -0
  83. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_tool_arg_parsing.py +0 -0
  84. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_tools_undo.py +0 -0
  85. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_tui.py +0 -0
  86. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_tuning.py +0 -0
  87. {drydock_cli-3.0.63 → drydock_cli-3.0.64}/tests/test_vision_input.py +0 -0
  88. {drydock_cli-3.0.63 → 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.63
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
@@ -137,6 +137,13 @@ control catalog into the knowledge base and ships four RMF skills — all
137
137
  /rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
138
138
  ```
139
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
+
140
147
  ## Install
141
148
 
142
149
  ```bash
@@ -113,6 +113,13 @@ control catalog into the knowledge base and ships four RMF skills — all
113
113
  /rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
114
114
  ```
115
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
+
116
123
  ## Install
117
124
 
118
125
  ```bash
@@ -123,4 +123,10 @@ def bootstrap(cwd: str, *, families: list[str] | None = None,
123
123
  store = graphrag.default_store_path(cwd)
124
124
  stats = graphrag.add_to_index([str(d) for d in docs], store, cwd=cwd)
125
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()
126
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)
@@ -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 writes a local, reversible commit).
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))
@@ -554,10 +554,12 @@ class DrydockApp(App):
554
554
 
555
555
  try:
556
556
  stats = rmf.bootstrap(cwd, families=families)
557
+ gstats = stats.get("graph", {})
557
558
  msg = (
558
559
  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."
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)."
561
563
  )
562
564
  except Exception as e: # noqa: BLE001
563
565
  msg = (f"RMF bootstrap failed: {e}. (Needs internet for the one-time "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.63
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
@@ -137,6 +137,13 @@ control catalog into the knowledge base and ships four RMF skills — all
137
137
  /rmf-poam <finding> # generate a POA&M entry from a scan/STIG finding
138
138
  ```
139
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
+
140
147
  ## Install
141
148
 
142
149
  ```bash
@@ -18,6 +18,7 @@ drydock/loop_detect.py
18
18
  drydock/mcp.py
19
19
  drydock/providers.py
20
20
  drydock/rmf.py
21
+ drydock/rmf_graph.py
21
22
  drydock/skills.py
22
23
  drydock/tool_registry.py
23
24
  drydock/tuning.py
@@ -69,6 +70,7 @@ tests/test_plan_autocontinue.py
69
70
  tests/test_providers_unreachable.py
70
71
  tests/test_read_index.py
71
72
  tests/test_rmf.py
73
+ tests/test_rmf_graph.py
72
74
  tests/test_runaway_repetition.py
73
75
  tests/test_skills.py
74
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.63"
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"
@@ -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()
File without changes
File without changes
File without changes