drydock-cli 3.0.73__tar.gz → 3.0.75__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {drydock_cli-3.0.73/drydock_cli.egg-info → drydock_cli-3.0.75}/PKG-INFO +37 -10
  2. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/README.md +36 -9
  3. drydock_cli-3.0.75/drydock/cci.py +109 -0
  4. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/rmf_graph.py +13 -5
  5. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tools/__init__.py +23 -4
  6. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tui/app.py +27 -14
  7. {drydock_cli-3.0.73 → drydock_cli-3.0.75/drydock_cli.egg-info}/PKG-INFO +37 -10
  8. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock_cli.egg-info/SOURCES.txt +2 -0
  9. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/pyproject.toml +1 -1
  10. drydock_cli-3.0.75/tests/test_cci.py +82 -0
  11. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_dispatch.py +11 -0
  12. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/LICENSE +0 -0
  13. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/NOTICE +0 -0
  14. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/__init__.py +0 -0
  15. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/agent.py +0 -0
  17. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/builtin_skills/__init__.py +0 -0
  19. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/builtin_skills/rmf-categorize.md +0 -0
  20. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/builtin_skills/rmf-control.md +0 -0
  21. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/builtin_skills/rmf-poam.md +0 -0
  22. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/builtin_skills/rmf-review.md +0 -0
  23. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/builtin_skills/stig-assess.md +0 -0
  24. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/builtin_skills/stig-remediate.md +0 -0
  25. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/cli.py +0 -0
  26. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/compaction.py +0 -0
  27. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/config.py +0 -0
  28. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/detect.py +0 -0
  29. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/extract.py +0 -0
  30. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/gittools.py +0 -0
  31. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/graphrag.py +0 -0
  32. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/guards.py +0 -0
  33. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/loop_detect.py +0 -0
  34. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/mcp.py +0 -0
  35. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/providers.py +0 -0
  36. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/rmf.py +0 -0
  37. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/skills.py +0 -0
  38. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/stig.py +0 -0
  39. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tool_registry.py +0 -0
  40. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tui/__init__.py +0 -0
  41. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tui/approval.py +0 -0
  42. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tui/messages.py +0 -0
  43. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tui/widgets.py +0 -0
  44. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/tuning.py +0 -0
  45. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock/web.py +0 -0
  46. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock_cli.egg-info/dependency_links.txt +0 -0
  47. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock_cli.egg-info/entry_points.txt +0 -0
  48. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock_cli.egg-info/requires.txt +0 -0
  49. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/drydock_cli.egg-info/top_level.txt +0 -0
  50. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/setup.cfg +0 -0
  51. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_approval.py +0 -0
  52. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_back_command.py +0 -0
  53. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_bash_output_bounding.py +0 -0
  54. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_bash_process_group.py +0 -0
  55. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_bash_safety.py +0 -0
  56. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_bash_timeout_network.py +0 -0
  57. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_cli_agents.py +0 -0
  58. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_compact_command.py +0 -0
  59. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_compaction.py +0 -0
  60. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_config.py +0 -0
  61. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_config_migration.py +0 -0
  62. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_context_limit_config.py +0 -0
  63. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_detect.py +0 -0
  64. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_e2e_connected.py +0 -0
  65. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_empty_response.py +0 -0
  66. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_extract.py +0 -0
  67. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_failure_loop.py +0 -0
  68. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_first_run_setup.py +0 -0
  69. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_gittools.py +0 -0
  70. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_graphrag.py +0 -0
  71. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_guards_and_tools.py +0 -0
  72. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_hallucinated_tools.py +0 -0
  73. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_leaked_tool_call.py +0 -0
  74. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_loop_detect.py +0 -0
  75. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_mcp.py +0 -0
  76. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_oneshot_unreachable.py +0 -0
  77. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_plan_autocontinue.py +0 -0
  78. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_providers_unreachable.py +0 -0
  79. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_read_index.py +0 -0
  80. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_rmf.py +0 -0
  81. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_rmf_graph.py +0 -0
  82. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_rmf_stig_graph.py +0 -0
  83. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_runaway_repetition.py +0 -0
  84. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_skills.py +0 -0
  85. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_stig.py +0 -0
  86. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_stop.py +0 -0
  87. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_streaming_newlines.py +0 -0
  88. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_subagent.py +0 -0
  89. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_system_prompt_help.py +0 -0
  90. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_todo.py +0 -0
  91. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_tool_arg_parsing.py +0 -0
  92. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_tools_undo.py +0 -0
  93. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_tui.py +0 -0
  94. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_tuning.py +0 -0
  95. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_vision_input.py +0 -0
  96. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_web_tools.py +0 -0
  97. {drydock_cli-3.0.73 → drydock_cli-3.0.75}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.73
3
+ Version: 3.0.75
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
@@ -91,10 +91,14 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
91
91
  | `/mcp` | List connected MCP servers + their tools |
92
92
  | `/rmf bootstrap [families]` | Ingest the NIST SP 800-53 catalog (RMF automation) |
93
93
  | `/rmf-control` · `/rmf-categorize` · `/rmf-review` · `/rmf-poam` | Bundled RMF skills |
94
+ | `/stig new <xccdf>` | Generate a blank `.ckl` from a DISA STIG benchmark |
95
+ | `/stig <ckl>` · `/stig <ckl> open` | Summarize a checklist · list findings by status |
96
+ | `/stig graph <ckl>` | Ingest a checklist into the RMF graph (auto-links rules→controls via CCI) |
97
+ | `/stig-assess <ckl>` · `/stig-remediate <ckl> <rule>` | Assess a rule vs evidence · write a fix script |
94
98
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
95
99
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
96
- | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
97
- | `/help` · `/quit` | Help · exit |
100
+ | `/compact` · `/context [n]` | Shrink context now · view/set the context-window budget |
101
+ | `/status` · `/clear` · `/help` · `/quit` | Session stats · reset · help · exit |
98
102
 
99
103
  ### Knowledge base (GraphRAG) — ingesting your documents
100
104
 
@@ -107,9 +111,11 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
107
111
 
108
112
  Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
109
113
  tool) when a question touches your material. Ingests text formats
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
114
+ (`.md .txt .py .js .json .yaml .sql …`), **PDF and Word (`.docx`)**, and **STIG
115
+ checklists (`.ckl`/`.cklb`)** checklists are flattened to per-rule findings so
116
+ you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
117
+ uses the `pdftotext` binary (poppler) if present, else `pip install
118
+ drydock-cli[pdf]` (pypdf). The index is a single JSON at
113
119
  `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
114
120
 
115
121
  ### Custom skills
@@ -144,6 +150,26 @@ relationships with `GraphQuery` — including **control inheritance** ("which
144
150
  servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
145
151
  no Neo4j.
146
152
 
153
+ ### STIG checklists (DISA `.ckl`/`.cklb`)
154
+
155
+ Take a raw DISA STIG benchmark all the way to a completed, eMASS/STIG-Viewer-
156
+ compatible checklist — entirely local (hostnames, IPs, and findings are CUI):
157
+
158
+ ```
159
+ /stig new U_ASD_STIG_V6R1_Manual-xccdf.xml app.ckl # benchmark → blank .ckl
160
+ /graphrag build ./app # pull in the app's evidence
161
+ /loop 286 /stig-assess app.ckl # assess each rule vs evidence
162
+ /stig app.ckl open # list the open findings
163
+ /stig-remediate app.ckl SV-900010r1_rule # write an idempotent fix script
164
+ /stig graph app.ckl # ingest + auto-link rules → NIST controls
165
+ ```
166
+
167
+ `/stig new` parses the XCCDF benchmark (validated against the full 286-rule
168
+ Application STIG); `/stig-assess` reads your evidence and writes status +
169
+ finding-details back in place; `/stig graph` builds `STIG`/`STIG-Rule` nodes and
170
+ **auto-links each rule to its NIST 800-53 control** through DISA's CCI map
171
+ (`Control —SATISFIED_BY→ rule`), fetched once and cached offline.
172
+
147
173
  ## Install
148
174
 
149
175
  ```bash
@@ -173,10 +199,11 @@ commands to do the work, showing each as a collapsible tool card.
173
199
  `◡ Keelhauling… (12s · ↓ 6.2k tokens · thinking with high effort)`
174
200
  - Submit while it's working and the prompt **queues** (drains in order)
175
201
  - Slash commands: `/model` · `/cwd` · `/undo` (revert last write) · `/back`
176
- (rewind last turn) · `/status` · `/compact` (shrink context) · `/graphrag`
177
- (build/query a knowledge base) · `/skills` (list your `/<name>` skills) ·
178
- `/loop` (repeat a prompt) · `/mcp` (list MCP servers) · `/clear` · `/help` ·
179
- `/quit`
202
+ (rewind last turn) · `/status` · `/compact` (shrink context) · `/context`
203
+ (view/set the context-window budget) · `/graphrag` (build/query a knowledge
204
+ base) · `/skills` (list your `/<name>` skills) · `/loop` (repeat a prompt) ·
205
+ `/mcp` (list MCP servers) · `/rmf` & `/stig` (NIST 800-53 / DISA STIG
206
+ automation) · `/clear` · `/help` · `/quit`
180
207
 
181
208
  It honors `AGENTS.md` / `DRYDOCK.md` in the working directory for project
182
209
  conventions.
@@ -67,10 +67,14 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
67
67
  | `/mcp` | List connected MCP servers + their tools |
68
68
  | `/rmf bootstrap [families]` | Ingest the NIST SP 800-53 catalog (RMF automation) |
69
69
  | `/rmf-control` · `/rmf-categorize` · `/rmf-review` · `/rmf-poam` | Bundled RMF skills |
70
+ | `/stig new <xccdf>` | Generate a blank `.ckl` from a DISA STIG benchmark |
71
+ | `/stig <ckl>` · `/stig <ckl> open` | Summarize a checklist · list findings by status |
72
+ | `/stig graph <ckl>` | Ingest a checklist into the RMF graph (auto-links rules→controls via CCI) |
73
+ | `/stig-assess <ckl>` · `/stig-remediate <ckl> <rule>` | Assess a rule vs evidence · write a fix script |
70
74
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
71
75
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
72
- | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
73
- | `/help` · `/quit` | Help · exit |
76
+ | `/compact` · `/context [n]` | Shrink context now · view/set the context-window budget |
77
+ | `/status` · `/clear` · `/help` · `/quit` | Session stats · reset · help · exit |
74
78
 
75
79
  ### Knowledge base (GraphRAG) — ingesting your documents
76
80
 
@@ -83,9 +87,11 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
83
87
 
84
88
  Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
85
89
  tool) when a question touches your material. Ingests text formats
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
90
+ (`.md .txt .py .js .json .yaml .sql …`), **PDF and Word (`.docx`)**, and **STIG
91
+ checklists (`.ckl`/`.cklb`)** checklists are flattened to per-rule findings so
92
+ you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
93
+ uses the `pdftotext` binary (poppler) if present, else `pip install
94
+ drydock-cli[pdf]` (pypdf). The index is a single JSON at
89
95
  `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
90
96
 
91
97
  ### Custom skills
@@ -120,6 +126,26 @@ relationships with `GraphQuery` — including **control inheritance** ("which
120
126
  servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
121
127
  no Neo4j.
122
128
 
129
+ ### STIG checklists (DISA `.ckl`/`.cklb`)
130
+
131
+ Take a raw DISA STIG benchmark all the way to a completed, eMASS/STIG-Viewer-
132
+ compatible checklist — entirely local (hostnames, IPs, and findings are CUI):
133
+
134
+ ```
135
+ /stig new U_ASD_STIG_V6R1_Manual-xccdf.xml app.ckl # benchmark → blank .ckl
136
+ /graphrag build ./app # pull in the app's evidence
137
+ /loop 286 /stig-assess app.ckl # assess each rule vs evidence
138
+ /stig app.ckl open # list the open findings
139
+ /stig-remediate app.ckl SV-900010r1_rule # write an idempotent fix script
140
+ /stig graph app.ckl # ingest + auto-link rules → NIST controls
141
+ ```
142
+
143
+ `/stig new` parses the XCCDF benchmark (validated against the full 286-rule
144
+ Application STIG); `/stig-assess` reads your evidence and writes status +
145
+ finding-details back in place; `/stig graph` builds `STIG`/`STIG-Rule` nodes and
146
+ **auto-links each rule to its NIST 800-53 control** through DISA's CCI map
147
+ (`Control —SATISFIED_BY→ rule`), fetched once and cached offline.
148
+
123
149
  ## Install
124
150
 
125
151
  ```bash
@@ -149,10 +175,11 @@ commands to do the work, showing each as a collapsible tool card.
149
175
  `◡ Keelhauling… (12s · ↓ 6.2k tokens · thinking with high effort)`
150
176
  - Submit while it's working and the prompt **queues** (drains in order)
151
177
  - Slash commands: `/model` · `/cwd` · `/undo` (revert last write) · `/back`
152
- (rewind last turn) · `/status` · `/compact` (shrink context) · `/graphrag`
153
- (build/query a knowledge base) · `/skills` (list your `/<name>` skills) ·
154
- `/loop` (repeat a prompt) · `/mcp` (list MCP servers) · `/clear` · `/help` ·
155
- `/quit`
178
+ (rewind last turn) · `/status` · `/compact` (shrink context) · `/context`
179
+ (view/set the context-window budget) · `/graphrag` (build/query a knowledge
180
+ base) · `/skills` (list your `/<name>` skills) · `/loop` (repeat a prompt) ·
181
+ `/mcp` (list MCP servers) · `/rmf` & `/stig` (NIST 800-53 / DISA STIG
182
+ automation) · `/clear` · `/help` · `/quit`
156
183
 
157
184
  It honors `AGENTS.md` / `DRYDOCK.md` in the working directory for project
158
185
  conventions.
@@ -0,0 +1,109 @@
1
+ """CCI → NIST 800-53 control mapping.
2
+
3
+ DISA's Control Correlation Identifiers (CCIs) are the bridge between a STIG rule
4
+ and the NIST control it enforces: every STIG rule cites a CCI, and the CCI maps
5
+ to an 800-53 control. This builds that map from DISA's `U_CCI_List.xml` so
6
+ `/stig graph` can auto-create `Control —SATISFIED_BY→ STIG-Rule` edges.
7
+
8
+ The mappings are U.S. Government public-domain data (facts, not copyrightable).
9
+ The list is fetched once and cached locally; offline-safe. Stdlib only.
10
+
11
+ All logic original to Drydock.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import urllib.error
18
+ import urllib.request
19
+ import xml.etree.ElementTree as ET
20
+ from pathlib import Path
21
+
22
+ # DISA U_CCI_List.xml (official list, mirrored). Government public-domain data.
23
+ _CCI_URL = ("https://raw.githubusercontent.com/CyberSecDef/Cyber.Trackr.Live/master/"
24
+ "cyber.trackr.live/resources/data/cci/U_CCI_List.xml")
25
+ _UA = "drydock-cli"
26
+ _RE_CONTROL = re.compile(r"\s*([A-Za-z]{2})-(\d+)(?:\s*\(?(\d+)\)?|\.(\d+))?")
27
+
28
+
29
+ def cache_path(cwd: str) -> Path:
30
+ return Path(cwd) / ".drydock" / "rmf" / "cci_map.json"
31
+
32
+
33
+ def _ln(el) -> str:
34
+ return el.tag.split("}")[-1]
35
+
36
+
37
+ def _norm_control(index: str) -> str | None:
38
+ """'AC-10' -> 'ac-10'; 'AC-2 (1)' / 'AC-2.1' -> 'ac-2.1'; junk -> None."""
39
+ m = _RE_CONTROL.match(index or "")
40
+ if not m:
41
+ return None
42
+ fam, num = m.group(1).lower(), m.group(2)
43
+ enh = m.group(3) or m.group(4)
44
+ return f"{fam}-{num}.{enh}" if enh else f"{fam}-{num}"
45
+
46
+
47
+ def parse_cci_list(path: str | Path) -> dict[str, str]:
48
+ """Parse U_CCI_List.xml -> {CCI-id: control-id}. Picks the highest 800-53
49
+ revision reference (excludes 800-53A, which lists assessment procedures)."""
50
+ root = ET.parse(path).getroot()
51
+ out: dict[str, str] = {}
52
+ for item in (e for e in root.iter() if _ln(e) == "cci_item"):
53
+ cid = item.get("id")
54
+ best: str | None = None
55
+ best_ver = -1
56
+ for ref in item.iter():
57
+ if _ln(ref) != "reference":
58
+ continue
59
+ title = ref.get("title", "")
60
+ if not title.startswith("NIST SP 800-53") or "800-53A" in title:
61
+ continue
62
+ try:
63
+ ver = int(ref.get("version") or 0)
64
+ except ValueError:
65
+ ver = 0
66
+ ctl = _norm_control(ref.get("index", ""))
67
+ if ctl and ver >= best_ver:
68
+ best, best_ver = ctl, ver
69
+ if cid and best:
70
+ out[cid] = best
71
+ return out
72
+
73
+
74
+ def fetch_to(path: str | Path, *, timeout: float = 60.0) -> None:
75
+ req = urllib.request.Request(_CCI_URL, headers={"User-Agent": _UA})
76
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted host)
77
+ data = r.read()
78
+ Path(path).write_bytes(data)
79
+
80
+
81
+ def load_map(cwd: str, *, refresh: bool = False) -> dict[str, str]:
82
+ """Return the cached {CCI: control} map, building it from U_CCI_List.xml on
83
+ first use (or --refresh). Offline-safe: returns the cache if the fetch fails,
84
+ or {} if there's no cache and no network."""
85
+ cp = cache_path(cwd)
86
+ if cp.exists() and not refresh:
87
+ try:
88
+ return json.loads(cp.read_text("utf-8"))
89
+ except (OSError, ValueError):
90
+ pass
91
+ cp.parent.mkdir(parents=True, exist_ok=True) # ensure .drydock/rmf/ exists before writing
92
+ raw = cp.with_suffix(".xml")
93
+ try:
94
+ fetch_to(raw)
95
+ except (urllib.error.URLError, OSError):
96
+ if cp.exists():
97
+ try:
98
+ return json.loads(cp.read_text("utf-8"))
99
+ except (OSError, ValueError):
100
+ return {}
101
+ return {}
102
+ mapping = parse_cci_list(raw)
103
+ cp.parent.mkdir(parents=True, exist_ok=True)
104
+ cp.write_text(json.dumps(mapping), "utf-8")
105
+ try:
106
+ raw.unlink()
107
+ except OSError:
108
+ pass
109
+ return mapping
@@ -126,11 +126,11 @@ def rule_node(rid: str) -> str:
126
126
  return f"stigrule:{rid.strip().lower()}"
127
127
 
128
128
 
129
- def ingest_checklist(g: "RmfGraph", checklist) -> dict:
129
+ def ingest_checklist(g: "RmfGraph", checklist, cci_map: dict | None = None) -> dict:
130
130
  """Add a parsed STIG Checklist to the typed graph: STIG + STIG-Rule nodes,
131
- PART_OF (rule→stig), APPLIES_TO (stig→host), EVALUATES (rule→host). Returns
132
- {rules, host}. The Control —SATISFIED_BY→ STIG-Rule link is asserted
133
- separately (via GraphAdd 'satisfies') since it needs the CCI→control map."""
131
+ PART_OF (rule→stig), APPLIES_TO (stig→host), EVALUATES (rule→host). When a
132
+ CCI→control map is supplied, also auto-creates Control —SATISFIED_BY→ STIG-Rule
133
+ edges via each rule's CCI. Returns {rules, host, linked}."""
134
134
  host = (checklist.asset.get("HOST_NAME") or checklist.asset.get("host_name") or "target")
135
135
  hnode = component_id(host)
136
136
  g.add_node(hnode, "Component", name=host, ip=checklist.asset.get("HOST_IP"))
@@ -138,6 +138,7 @@ def ingest_checklist(g: "RmfGraph", checklist) -> dict:
138
138
  snode = stig_id(sname)
139
139
  g.add_node(snode, "STIG", name=sname, version=checklist.stig_version)
140
140
  g.add_edge(snode, "APPLIES_TO", hnode)
141
+ linked = 0
141
142
  for r in checklist.rules:
142
143
  rid = r.rule_id or r.group_id
143
144
  if not rid:
@@ -147,7 +148,14 @@ def ingest_checklist(g: "RmfGraph", checklist) -> dict:
147
148
  severity=r.severity, status=r.status, cci=r.cci, title=r.title)
148
149
  g.add_edge(rn, "PART_OF", snode)
149
150
  g.add_edge(rn, "EVALUATES", hnode)
150
- return {"rules": len(checklist.rules), "host": host}
151
+ ctl = (cci_map or {}).get(r.cci)
152
+ if ctl:
153
+ cn = control_id(ctl)
154
+ if not g.get(cn):
155
+ g.add_node(cn, "Control", control_id=ctl.upper())
156
+ g.add_edge(cn, "SATISFIED_BY", rn)
157
+ linked += 1
158
+ return {"rules": len(checklist.rules), "host": host, "linked": linked}
151
159
 
152
160
 
153
161
  def build_from_catalog(catalog: dict, *, families: list[str] | None = None) -> RmfGraph:
@@ -1017,15 +1017,34 @@ _SUBAGENT_SYSTEM = (
1017
1017
  "tools: Read, Glob, Grep, and Bash (use Bash only to INSPECT — ls, cat, "
1018
1018
  "grep, find, git log — never to modify files). Investigate the task you are "
1019
1019
  "given, then STOP and reply with a concise, factual summary of what you "
1020
- "found: concrete file:line references and the key code, not narration. Do "
1021
- "NOT try to edit or create files the main agent acts on your findings."
1020
+ "found: concrete file:line references and only the few key code snippets that "
1021
+ "matter, not narration and not whole files. Aim for under ~250 words the "
1022
+ "main agent only gets this summary (not your tool output), so distill, don't "
1023
+ "dump. Do NOT try to edit or create files — the main agent acts on your findings."
1022
1024
  )
1023
1025
 
1024
1026
 
1027
+ # A sub-agent's whole job is to keep its investigation OUT of the main agent's
1028
+ # context and hand back only a partition. The system prompt asks for a concise
1029
+ # summary, but a runaway model could still return a wall of text — so cap what
1030
+ # crosses back into the parent's window. ~4000 chars ≈ ~1000 tokens.
1031
+ _SUBAGENT_SUMMARY_CAP = 4000
1032
+
1033
+
1034
+ def _cap_summary(text: str) -> str:
1035
+ if len(text) <= _SUBAGENT_SUMMARY_CAP:
1036
+ return text
1037
+ head = text[:_SUBAGENT_SUMMARY_CAP].rsplit("\n", 1)[0] or text[:_SUBAGENT_SUMMARY_CAP]
1038
+ dropped = len(text) - len(head)
1039
+ return (head + f"\n[… sub-agent summary truncated, {dropped} chars dropped to keep it out "
1040
+ "of the main context. Ask a narrower follow-up sub-agent task if you need more.]")
1041
+
1042
+
1025
1043
  def _run_subagent(prompt: str, config: dict) -> str:
1026
1044
  """Run one read-only sub-agent to completion and return its final summary.
1027
1045
  Shared by `task` (one) and `Dispatch` (many in parallel). Hard-capped; never
1028
- raises (a sub-agent must not crash the parent turn)."""
1046
+ raises (a sub-agent must not crash the parent turn). The returned summary is
1047
+ size-capped (_cap_summary) so a sub-agent can never bloat the main context."""
1029
1048
  from drydock.agent import run as agent_run, AgentState, TurnDone
1030
1049
 
1031
1050
  sub_state = AgentState()
@@ -1048,7 +1067,7 @@ def _run_subagent(prompt: str, config: dict) -> str:
1048
1067
  return f"[sub-agent error: {e}]"
1049
1068
  for msg in reversed(sub_state.messages):
1050
1069
  if msg.get("role") == "assistant" and (msg.get("content") or "").strip():
1051
- return msg["content"].strip()
1070
+ return _cap_summary(msg["content"].strip())
1052
1071
  return f"[sub-agent finished {steps} step(s) with no summary]"
1053
1072
 
1054
1073
 
@@ -563,21 +563,10 @@ class DrydockApp(App):
563
563
  return
564
564
  # /stig graph <path> — ingest the checklist into the RMF typed graph
565
565
  if parts[0].lower() == "graph" and len(parts) > 1:
566
- from drydock import rmf_graph
567
566
  gp = parts[1] if _os.path.isabs(parts[1]) else _os.path.join(cwd, parts[1])
568
- try:
569
- cl = stig.load(gp)
570
- g = rmf_graph.RmfGraph.load(rmf_graph.graph_path(cwd))
571
- r = rmf_graph.ingest_checklist(g, cl)
572
- g.save(rmf_graph.graph_path(cwd))
573
- except Exception as e: # noqa: BLE001
574
- self._mount(ErrorMessage(f"could not graph checklist: {e}"))
575
- return
576
- self._info(
577
- f"✓ Ingested {r['rules']} STIG rules for host '{r['host']}' into the "
578
- f"RMF graph (STIG/STIG-Rule nodes, PART_OF/APPLIES_TO/EVALUATES). "
579
- "Link rules to controls with GraphAdd satisfies; trace via GraphQuery."
580
- )
567
+ self._info("Ingesting the checklist into the RMF graph "
568
+ "(fetching the DISA CCI→800-53 map on first use)…")
569
+ self.run_worker(lambda: self._stig_graph(cwd, gp), thread=True)
581
570
  return
582
571
  path = parts[0]
583
572
  if not _os.path.isabs(path):
@@ -643,6 +632,30 @@ class DrydockApp(App):
643
632
  "catalog download; after that it works offline.)")
644
633
  self.call_from_thread(self._info, msg)
645
634
 
635
+ def _stig_graph(self, cwd: str, path: str) -> None:
636
+ """Worker-thread body: ingest a checklist into the RMF graph, auto-linking
637
+ rules to NIST controls via the DISA CCI map. Reports back on the UI."""
638
+ from drydock import cci, rmf_graph, stig
639
+
640
+ try:
641
+ cl = stig.load(path)
642
+ cci_map = cci.load_map(cwd) # fetch+cache once; offline-safe ({} on failure)
643
+ g = rmf_graph.RmfGraph.load(rmf_graph.graph_path(cwd))
644
+ r = rmf_graph.ingest_checklist(g, cl, cci_map)
645
+ g.save(rmf_graph.graph_path(cwd))
646
+ link_note = (
647
+ f"auto-linked {r['linked']}/{r['rules']} rules to NIST controls via CCI "
648
+ "(Control —SATISFIED_BY→ rule). Trace with GraphQuery control <id>."
649
+ if r["linked"] else
650
+ "(no CCI→control links — the CCI map was unavailable offline; rules are "
651
+ "still in the graph. Re-run online, or use GraphAdd satisfies.)"
652
+ )
653
+ msg = (f"✓ Ingested {r['rules']} STIG rules for host '{r['host']}' into the "
654
+ f"RMF graph (STIG/STIG-Rule + PART_OF/APPLIES_TO/EVALUATES); {link_note}")
655
+ except Exception as e: # noqa: BLE001
656
+ msg = f"could not graph checklist: {e}"
657
+ self.call_from_thread(self._info, msg)
658
+
646
659
  def _cmd_mcp(self) -> None:
647
660
  """List connected MCP servers and the tools they expose."""
648
661
  from drydock import mcp
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.73
3
+ Version: 3.0.75
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
@@ -91,10 +91,14 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
91
91
  | `/mcp` | List connected MCP servers + their tools |
92
92
  | `/rmf bootstrap [families]` | Ingest the NIST SP 800-53 catalog (RMF automation) |
93
93
  | `/rmf-control` · `/rmf-categorize` · `/rmf-review` · `/rmf-poam` | Bundled RMF skills |
94
+ | `/stig new <xccdf>` | Generate a blank `.ckl` from a DISA STIG benchmark |
95
+ | `/stig <ckl>` · `/stig <ckl> open` | Summarize a checklist · list findings by status |
96
+ | `/stig graph <ckl>` | Ingest a checklist into the RMF graph (auto-links rules→controls via CCI) |
97
+ | `/stig-assess <ckl>` · `/stig-remediate <ckl> <rule>` | Assess a rule vs evidence · write a fix script |
94
98
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
95
99
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
96
- | `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
97
- | `/help` · `/quit` | Help · exit |
100
+ | `/compact` · `/context [n]` | Shrink context now · view/set the context-window budget |
101
+ | `/status` · `/clear` · `/help` · `/quit` | Session stats · reset · help · exit |
98
102
 
99
103
  ### Knowledge base (GraphRAG) — ingesting your documents
100
104
 
@@ -107,9 +111,11 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
107
111
 
108
112
  Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
109
113
  tool) when a question touches your material. Ingests text formats
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
114
+ (`.md .txt .py .js .json .yaml .sql …`), **PDF and Word (`.docx`)**, and **STIG
115
+ checklists (`.ckl`/`.cklb`)** checklists are flattened to per-rule findings so
116
+ you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
117
+ uses the `pdftotext` binary (poppler) if present, else `pip install
118
+ drydock-cli[pdf]` (pypdf). The index is a single JSON at
113
119
  `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
114
120
 
115
121
  ### Custom skills
@@ -144,6 +150,26 @@ relationships with `GraphQuery` — including **control inheritance** ("which
144
150
  servers inherit physical controls from their enclave?"). Stdlib in-memory graph,
145
151
  no Neo4j.
146
152
 
153
+ ### STIG checklists (DISA `.ckl`/`.cklb`)
154
+
155
+ Take a raw DISA STIG benchmark all the way to a completed, eMASS/STIG-Viewer-
156
+ compatible checklist — entirely local (hostnames, IPs, and findings are CUI):
157
+
158
+ ```
159
+ /stig new U_ASD_STIG_V6R1_Manual-xccdf.xml app.ckl # benchmark → blank .ckl
160
+ /graphrag build ./app # pull in the app's evidence
161
+ /loop 286 /stig-assess app.ckl # assess each rule vs evidence
162
+ /stig app.ckl open # list the open findings
163
+ /stig-remediate app.ckl SV-900010r1_rule # write an idempotent fix script
164
+ /stig graph app.ckl # ingest + auto-link rules → NIST controls
165
+ ```
166
+
167
+ `/stig new` parses the XCCDF benchmark (validated against the full 286-rule
168
+ Application STIG); `/stig-assess` reads your evidence and writes status +
169
+ finding-details back in place; `/stig graph` builds `STIG`/`STIG-Rule` nodes and
170
+ **auto-links each rule to its NIST 800-53 control** through DISA's CCI map
171
+ (`Control —SATISFIED_BY→ rule`), fetched once and cached offline.
172
+
147
173
  ## Install
148
174
 
149
175
  ```bash
@@ -173,10 +199,11 @@ commands to do the work, showing each as a collapsible tool card.
173
199
  `◡ Keelhauling… (12s · ↓ 6.2k tokens · thinking with high effort)`
174
200
  - Submit while it's working and the prompt **queues** (drains in order)
175
201
  - Slash commands: `/model` · `/cwd` · `/undo` (revert last write) · `/back`
176
- (rewind last turn) · `/status` · `/compact` (shrink context) · `/graphrag`
177
- (build/query a knowledge base) · `/skills` (list your `/<name>` skills) ·
178
- `/loop` (repeat a prompt) · `/mcp` (list MCP servers) · `/clear` · `/help` ·
179
- `/quit`
202
+ (rewind last turn) · `/status` · `/compact` (shrink context) · `/context`
203
+ (view/set the context-window budget) · `/graphrag` (build/query a knowledge
204
+ base) · `/skills` (list your `/<name>` skills) · `/loop` (repeat a prompt) ·
205
+ `/mcp` (list MCP servers) · `/rmf` & `/stig` (NIST 800-53 / DISA STIG
206
+ automation) · `/clear` · `/help` · `/quit`
180
207
 
181
208
  It honors `AGENTS.md` / `DRYDOCK.md` in the working directory for project
182
209
  conventions.
@@ -6,6 +6,7 @@ drydock/__init__.py
6
6
  drydock/__main__.py
7
7
  drydock/agent.py
8
8
  drydock/bash_safety.py
9
+ drydock/cci.py
9
10
  drydock/cli.py
10
11
  drydock/compaction.py
11
12
  drydock/config.py
@@ -49,6 +50,7 @@ tests/test_bash_output_bounding.py
49
50
  tests/test_bash_process_group.py
50
51
  tests/test_bash_safety.py
51
52
  tests/test_bash_timeout_network.py
53
+ tests/test_cci.py
52
54
  tests/test_cli_agents.py
53
55
  tests/test_compact_command.py
54
56
  tests/test_compaction.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.73"
10
+ version = "3.0.75"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,82 @@
1
+ """CCI → NIST 800-53 control map (the STIG-rule ↔ control bridge)."""
2
+ from __future__ import annotations
3
+
4
+ from drydock import cci, rmf_graph, stig
5
+
6
+ _CCI_XML = '''<?xml version="1.0"?><cci_list><cci_items>
7
+ <cci_item id="CCI-000366"><references>
8
+ <reference title="NIST SP 800-53" version="3" index="CM-6"/>
9
+ <reference title="NIST SP 800-53A" version="1" index="CM-6.1 (ii)"/>
10
+ <reference title="NIST SP 800-53 Revision 4" version="4" index="CM-6 b"/></references></cci_item>
11
+ <cci_item id="CCI-000054"><references>
12
+ <reference title="NIST SP 800-53 Revision 5" version="5" index="AC-10"/></references></cci_item>
13
+ <cci_item id="CCI-001133"><references>
14
+ <reference title="NIST SP 800-53 Revision 4" version="4" index="SC-10 (1)"/></references></cci_item>
15
+ <cci_item id="CCI-999999"><references>
16
+ <reference title="NIST SP 800-53A" version="1" index="ZZ-9.9"/></references></cci_item>
17
+ </cci_items></cci_list>'''
18
+
19
+
20
+ def test_parse_picks_highest_rev_excludes_53A(tmp_path):
21
+ p = tmp_path / "cci.xml"; p.write_text(_CCI_XML)
22
+ m = cci.parse_cci_list(p)
23
+ assert m["CCI-000366"] == "cm-6" # rev4 base, 800-53A excluded
24
+ assert m["CCI-000054"] == "ac-10"
25
+ assert m["CCI-001133"] == "sc-10.1" # enhancement normalized
26
+ assert "CCI-999999" not in m # only 800-53A ref → no control
27
+
28
+
29
+ def test_norm_control_formats():
30
+ assert cci._norm_control("AC-10") == "ac-10"
31
+ assert cci._norm_control("AC-2 (1)") == "ac-2.1"
32
+ assert cci._norm_control("AC-2.1") == "ac-2.1"
33
+ assert cci._norm_control("garbage") is None
34
+
35
+
36
+ def test_load_map_caches_and_is_offline_safe(tmp_path, monkeypatch):
37
+ # no network: with no cache, returns {} (never raises)
38
+ def boom(*a, **k):
39
+ raise OSError("offline")
40
+ monkeypatch.setattr(cci, "fetch_to", boom)
41
+ assert cci.load_map(str(tmp_path)) == {}
42
+ # seed a cache → load_map returns it without fetching
43
+ cp = cci.cache_path(str(tmp_path)); cp.parent.mkdir(parents=True, exist_ok=True)
44
+ cp.write_text('{"CCI-000366": "cm-6"}')
45
+ assert cci.load_map(str(tmp_path)) == {"CCI-000366": "cm-6"}
46
+
47
+
48
+ def test_ingest_auto_links_via_cci(tmp_path):
49
+ (tmp_path / "s.ckl").write_text(
50
+ '<?xml version="1.0"?><CHECKLIST><ASSET><HOST_NAME>h1</HOST_NAME></ASSET><STIGS><iSTIG>'
51
+ '<STIG_INFO></STIG_INFO><VULN>'
52
+ '<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-9</ATTRIBUTE_DATA></STIG_DATA>'
53
+ '<STIG_DATA><VULN_ATTRIBUTE>CCI_REF</VULN_ATTRIBUTE><ATTRIBUTE_DATA>CCI-000366</ATTRIBUTE_DATA></STIG_DATA>'
54
+ '<STATUS>Open</STATUS></VULN></iSTIG></STIGS></CHECKLIST>')
55
+ cl = stig.load(tmp_path / "s.ckl")
56
+ g = rmf_graph.RmfGraph()
57
+ r = rmf_graph.ingest_checklist(g, cl, {"CCI-000366": "cm-6"})
58
+ assert r["linked"] == 1
59
+ assert rmf_graph.rule_node("SV-9") in g.neighbors(rmf_graph.control_id("cm-6"), "SATISFIED_BY")
60
+
61
+
62
+ def test_ingest_without_map_does_not_link(tmp_path):
63
+ (tmp_path / "s.ckl").write_text(
64
+ '<?xml version="1.0"?><CHECKLIST><STIGS><iSTIG><STIG_INFO></STIG_INFO><VULN>'
65
+ '<STIG_DATA><VULN_ATTRIBUTE>Rule_ID</VULN_ATTRIBUTE><ATTRIBUTE_DATA>SV-9</ATTRIBUTE_DATA></STIG_DATA>'
66
+ '<STATUS>Open</STATUS></VULN></iSTIG></STIGS></CHECKLIST>')
67
+ cl = stig.load(tmp_path / "s.ckl")
68
+ r = rmf_graph.ingest_checklist(rmf_graph.RmfGraph(), cl)
69
+ assert r["linked"] == 0
70
+
71
+
72
+ def test_load_map_creates_dir_and_caches(tmp_path, monkeypatch):
73
+ """Regression: the fetch must create .drydock/rmf/ before writing — a real
74
+ fetch failed because the dir didn't exist yet (found via hands-on TUI test)."""
75
+ def fake_fetch(path, **kw):
76
+ # a real fetch writes to .drydock/rmf/cci_map.xml — the dir must exist
77
+ from pathlib import Path
78
+ Path(path).write_text(_CCI_XML)
79
+ monkeypatch.setattr(cci, "fetch_to", fake_fetch)
80
+ m = cci.load_map(str(tmp_path))
81
+ assert m["CCI-000366"] == "cm-6" # parsed from the "fetched" list
82
+ assert cci.cache_path(str(tmp_path)).exists() # cached as json
@@ -60,3 +60,14 @@ def test_task_still_works_via_shared_runner(monkeypatch):
60
60
  monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"summary:{p}")
61
61
  assert T.tool_task({"prompt": "explore X"}, {}) == "summary:explore X"
62
62
  assert "needs a `prompt`" in T.tool_task({}, {})
63
+
64
+
65
+ def test_subagent_summary_is_capped():
66
+ """A sub-agent's return is size-capped so its investigation can never bloat
67
+ the main agent's context — only a bounded partition crosses back."""
68
+ from drydock.tools import _cap_summary, _SUBAGENT_SUMMARY_CAP
69
+ assert _cap_summary("brief finding") == "brief finding" # short passes through
70
+ big = "\n".join(f"line {i} with some detail" for i in range(2000))
71
+ out = _cap_summary(big)
72
+ assert len(out) < _SUBAGENT_SUMMARY_CAP + 200 # bounded
73
+ assert "truncated" in out and "main context" in out # explains the cut
File without changes
File without changes
File without changes