ctrlcode 0.1.0__py3-none-any.whl
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.
- ctrlcode/__init__.py +8 -0
- ctrlcode/agents/__init__.py +29 -0
- ctrlcode/agents/cleanup.py +388 -0
- ctrlcode/agents/communication.py +439 -0
- ctrlcode/agents/observability.py +421 -0
- ctrlcode/agents/react_loop.py +297 -0
- ctrlcode/agents/registry.py +211 -0
- ctrlcode/agents/result_parser.py +242 -0
- ctrlcode/agents/workflow.py +723 -0
- ctrlcode/analysis/__init__.py +28 -0
- ctrlcode/analysis/ast_diff.py +163 -0
- ctrlcode/analysis/bug_detector.py +149 -0
- ctrlcode/analysis/code_graphs.py +329 -0
- ctrlcode/analysis/semantic.py +205 -0
- ctrlcode/analysis/static.py +183 -0
- ctrlcode/analysis/synthesizer.py +281 -0
- ctrlcode/analysis/tests.py +189 -0
- ctrlcode/cleanup/__init__.py +16 -0
- ctrlcode/cleanup/auto_merge.py +350 -0
- ctrlcode/cleanup/doc_gardening.py +388 -0
- ctrlcode/cleanup/pr_automation.py +330 -0
- ctrlcode/cleanup/scheduler.py +356 -0
- ctrlcode/config.py +380 -0
- ctrlcode/embeddings/__init__.py +6 -0
- ctrlcode/embeddings/embedder.py +192 -0
- ctrlcode/embeddings/vector_store.py +213 -0
- ctrlcode/fuzzing/__init__.py +24 -0
- ctrlcode/fuzzing/analyzer.py +280 -0
- ctrlcode/fuzzing/budget.py +112 -0
- ctrlcode/fuzzing/context.py +665 -0
- ctrlcode/fuzzing/context_fuzzer.py +506 -0
- ctrlcode/fuzzing/derived_orchestrator.py +732 -0
- ctrlcode/fuzzing/oracle_adapter.py +135 -0
- ctrlcode/linters/__init__.py +11 -0
- ctrlcode/linters/hand_rolled_utils.py +221 -0
- ctrlcode/linters/yolo_parsing.py +217 -0
- ctrlcode/metrics/__init__.py +6 -0
- ctrlcode/metrics/dashboard.py +283 -0
- ctrlcode/metrics/tech_debt.py +663 -0
- ctrlcode/paths.py +68 -0
- ctrlcode/permissions.py +179 -0
- ctrlcode/providers/__init__.py +15 -0
- ctrlcode/providers/anthropic.py +138 -0
- ctrlcode/providers/base.py +77 -0
- ctrlcode/providers/openai.py +197 -0
- ctrlcode/providers/parallel.py +104 -0
- ctrlcode/server.py +871 -0
- ctrlcode/session/__init__.py +6 -0
- ctrlcode/session/baseline.py +57 -0
- ctrlcode/session/manager.py +967 -0
- ctrlcode/skills/__init__.py +10 -0
- ctrlcode/skills/builtin/commit.toml +29 -0
- ctrlcode/skills/builtin/docs.toml +25 -0
- ctrlcode/skills/builtin/refactor.toml +33 -0
- ctrlcode/skills/builtin/review.toml +28 -0
- ctrlcode/skills/builtin/test.toml +28 -0
- ctrlcode/skills/loader.py +111 -0
- ctrlcode/skills/registry.py +139 -0
- ctrlcode/storage/__init__.py +19 -0
- ctrlcode/storage/history_db.py +708 -0
- ctrlcode/tools/__init__.py +220 -0
- ctrlcode/tools/bash.py +112 -0
- ctrlcode/tools/browser.py +352 -0
- ctrlcode/tools/executor.py +153 -0
- ctrlcode/tools/explore.py +486 -0
- ctrlcode/tools/mcp.py +108 -0
- ctrlcode/tools/observability.py +561 -0
- ctrlcode/tools/registry.py +193 -0
- ctrlcode/tools/todo.py +291 -0
- ctrlcode/tools/update.py +266 -0
- ctrlcode/tools/webfetch.py +147 -0
- ctrlcode-0.1.0.dist-info/METADATA +93 -0
- ctrlcode-0.1.0.dist-info/RECORD +75 -0
- ctrlcode-0.1.0.dist-info/WHEEL +4 -0
- ctrlcode-0.1.0.dist-info/entry_points.txt +3 -0
ctrlcode/server.py
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
"""JSON-RPC server for ctrl-code harness."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aiohttp import web
|
|
11
|
+
from harnessutils.config import HarnessConfig
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .paths import get_data_dir
|
|
15
|
+
from .providers.anthropic import AnthropicProvider
|
|
16
|
+
from .providers.openai import OpenAIProvider
|
|
17
|
+
from .providers.base import Provider
|
|
18
|
+
from .session.manager import SessionManager
|
|
19
|
+
from .fuzzing.derived_orchestrator import DerivedFuzzingOrchestrator
|
|
20
|
+
from .tools.registry import ToolRegistry
|
|
21
|
+
from .tools.executor import ToolExecutor
|
|
22
|
+
from .tools import (
|
|
23
|
+
setup_explore_tools,
|
|
24
|
+
setup_todo_tools,
|
|
25
|
+
setup_bash_tools,
|
|
26
|
+
setup_webfetch_tools,
|
|
27
|
+
setup_update_tools,
|
|
28
|
+
setup_observability_tools,
|
|
29
|
+
)
|
|
30
|
+
from .skills.loader import SkillLoader
|
|
31
|
+
from .skills.registry import SkillRegistry
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HarnessServer:
|
|
37
|
+
"""JSON-RPC server for coding harness."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: Config):
|
|
40
|
+
"""
|
|
41
|
+
Initialize harness server.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config: Server configuration
|
|
45
|
+
"""
|
|
46
|
+
self.config = config
|
|
47
|
+
self.api_key = config.server.api_key
|
|
48
|
+
self.app = web.Application()
|
|
49
|
+
self.app.router.add_post("/rpc", self.handle_rpc)
|
|
50
|
+
self.app.router.add_get("/metrics/tech-debt", self.serve_tech_debt_dashboard)
|
|
51
|
+
|
|
52
|
+
# Add startup/shutdown handlers
|
|
53
|
+
self.app.on_startup.append(self._startup)
|
|
54
|
+
self.app.on_shutdown.append(self._shutdown)
|
|
55
|
+
|
|
56
|
+
# Initialize providers
|
|
57
|
+
self.providers: dict[str, Provider] = {}
|
|
58
|
+
if config.anthropic:
|
|
59
|
+
self.providers["anthropic"] = AnthropicProvider( # type: ignore[assignment]
|
|
60
|
+
api_key=config.anthropic.api_key,
|
|
61
|
+
model=config.anthropic.model,
|
|
62
|
+
base_url=config.anthropic.base_url
|
|
63
|
+
)
|
|
64
|
+
if config.openai:
|
|
65
|
+
self.providers["openai"] = OpenAIProvider( # type: ignore[assignment]
|
|
66
|
+
api_key=config.openai.api_key,
|
|
67
|
+
model=config.openai.model,
|
|
68
|
+
base_url=config.openai.base_url
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not self.providers:
|
|
72
|
+
raise ValueError("No providers configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY")
|
|
73
|
+
|
|
74
|
+
# Default provider (prefer Anthropic)
|
|
75
|
+
default_provider = self.providers.get("anthropic") or self.providers.get("openai")
|
|
76
|
+
assert default_provider is not None, "At least one provider must be configured"
|
|
77
|
+
|
|
78
|
+
# Initialize derived fuzzing orchestrator (if enabled)
|
|
79
|
+
fuzzing_orchestrator = None
|
|
80
|
+
if config.fuzzing.enabled:
|
|
81
|
+
provider_list = list(self.providers.values())
|
|
82
|
+
|
|
83
|
+
# Prepare embeddings config if available
|
|
84
|
+
embeddings_config = None
|
|
85
|
+
if config.embeddings:
|
|
86
|
+
embeddings_config = {
|
|
87
|
+
"api_key": config.embeddings.api_key,
|
|
88
|
+
"base_url": config.embeddings.base_url,
|
|
89
|
+
"model": config.embeddings.model,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fuzzing_orchestrator = DerivedFuzzingOrchestrator(
|
|
93
|
+
providers=provider_list,
|
|
94
|
+
config={
|
|
95
|
+
"budget_tokens": config.fuzzing.budget_tokens,
|
|
96
|
+
"budget_seconds": config.fuzzing.budget_seconds,
|
|
97
|
+
"max_iterations": config.fuzzing.max_iterations,
|
|
98
|
+
"input_fuzz_ratio": config.fuzzing.input_fuzz_ratio,
|
|
99
|
+
"environment_fuzz_ratio": config.fuzzing.environment_fuzz_ratio,
|
|
100
|
+
"combined_fuzz_ratio": config.fuzzing.combined_fuzz_ratio,
|
|
101
|
+
"invariant_fuzz_ratio": config.fuzzing.invariant_fuzz_ratio,
|
|
102
|
+
"oracle_confidence_threshold": config.fuzzing.oracle_confidence_threshold,
|
|
103
|
+
"context_re_derivation_on_mismatch": config.fuzzing.context_re_derivation_on_mismatch,
|
|
104
|
+
},
|
|
105
|
+
embeddings_config=embeddings_config,
|
|
106
|
+
)
|
|
107
|
+
logger.info("Derived context fuzzing enabled")
|
|
108
|
+
|
|
109
|
+
# Initialize tool registry
|
|
110
|
+
self.tool_registry = ToolRegistry()
|
|
111
|
+
self.tool_executor = ToolExecutor(self.tool_registry)
|
|
112
|
+
|
|
113
|
+
# Initialize permission manager
|
|
114
|
+
from .permissions import PermissionManager, set_permission_manager
|
|
115
|
+
self.permission_manager = PermissionManager(approval_callback=self._send_permission_request)
|
|
116
|
+
set_permission_manager(self.permission_manager)
|
|
117
|
+
self._permission_stream: Optional[web.StreamResponse] = None
|
|
118
|
+
|
|
119
|
+
# Register built-in explore tools
|
|
120
|
+
workspace_root = config.workspace_root or Path.cwd()
|
|
121
|
+
setup_explore_tools(self.tool_registry, workspace_root)
|
|
122
|
+
logger.info(f"Registered explore tools with workspace: {workspace_root}")
|
|
123
|
+
|
|
124
|
+
# Register built-in todo tools
|
|
125
|
+
data_dir = get_data_dir()
|
|
126
|
+
setup_todo_tools(self.tool_registry, data_dir)
|
|
127
|
+
logger.info(f"Registered todo tools with data dir: {data_dir}")
|
|
128
|
+
|
|
129
|
+
# Register built-in bash tools
|
|
130
|
+
setup_bash_tools(self.tool_registry, workspace_root)
|
|
131
|
+
logger.info("Registered bash tools")
|
|
132
|
+
|
|
133
|
+
# Register built-in web fetch tools
|
|
134
|
+
setup_webfetch_tools(self.tool_registry)
|
|
135
|
+
logger.info("Registered web fetch tools")
|
|
136
|
+
|
|
137
|
+
# Register built-in update file tools
|
|
138
|
+
setup_update_tools(self.tool_registry, workspace_root)
|
|
139
|
+
logger.info("Registered update file tools")
|
|
140
|
+
|
|
141
|
+
# Register built-in observability tools
|
|
142
|
+
log_dir = get_data_dir() / "logs"
|
|
143
|
+
setup_observability_tools(self.tool_registry, log_dir)
|
|
144
|
+
logger.info(f"Registered observability tools with log dir: {log_dir}")
|
|
145
|
+
|
|
146
|
+
# Initialize skill registry
|
|
147
|
+
self.skill_registry = SkillRegistry()
|
|
148
|
+
self._load_skills(config)
|
|
149
|
+
|
|
150
|
+
# Initialize tech debt metrics
|
|
151
|
+
from .metrics import TechDebtMetrics
|
|
152
|
+
metrics_dir = get_data_dir() / "metrics"
|
|
153
|
+
self.tech_debt_metrics = TechDebtMetrics(metrics_dir, project_path=workspace_root)
|
|
154
|
+
logger.info(f"Initialized tech debt metrics at: {metrics_dir} for project: {workspace_root}")
|
|
155
|
+
|
|
156
|
+
# Initialize session manager
|
|
157
|
+
harness_config = HarnessConfig() # Uses default config
|
|
158
|
+
self.session_manager = SessionManager(
|
|
159
|
+
provider=default_provider,
|
|
160
|
+
storage_path=str(config.storage_path),
|
|
161
|
+
config=harness_config,
|
|
162
|
+
fuzzing_orchestrator=fuzzing_orchestrator,
|
|
163
|
+
fuzzing_enabled=config.fuzzing.enabled,
|
|
164
|
+
tool_executor=self.tool_executor,
|
|
165
|
+
tool_registry=self.tool_registry,
|
|
166
|
+
skill_registry=self.skill_registry,
|
|
167
|
+
context_limit=config.context.default_limit,
|
|
168
|
+
workspace_root=workspace_root,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.fuzzing_orchestrator = fuzzing_orchestrator
|
|
172
|
+
|
|
173
|
+
# Security: rate limiting (requests per session)
|
|
174
|
+
self._rate_limit_requests: dict[str, list[float]] = defaultdict(list)
|
|
175
|
+
self._rate_limit_enabled = config.security.rate_limit_enabled
|
|
176
|
+
self._rate_limit_window = config.security.rate_limit_window_seconds
|
|
177
|
+
self._rate_limit_max_requests = config.security.rate_limit_max_requests
|
|
178
|
+
self._max_input_length = config.security.max_input_length
|
|
179
|
+
|
|
180
|
+
logger.info(f"Initialized with providers: {list(self.providers.keys())}")
|
|
181
|
+
|
|
182
|
+
def _load_skills(self, config: Config) -> None:
|
|
183
|
+
"""Load built-in and user skills."""
|
|
184
|
+
from pathlib import Path
|
|
185
|
+
|
|
186
|
+
# Load built-in skills
|
|
187
|
+
builtin_dir = Path(__file__).parent / "skills" / "builtin"
|
|
188
|
+
loader = SkillLoader(builtin_dir)
|
|
189
|
+
builtin_skills = loader.load_all()
|
|
190
|
+
self.skill_registry.register_all(builtin_skills)
|
|
191
|
+
logger.info(f"Loaded {len(builtin_skills)} built-in skills")
|
|
192
|
+
|
|
193
|
+
# Load user skills
|
|
194
|
+
if config.skills_directory and config.skills_directory.exists():
|
|
195
|
+
user_loader = SkillLoader(config.skills_directory)
|
|
196
|
+
user_skills = user_loader.load_all()
|
|
197
|
+
self.skill_registry.register_all(user_skills)
|
|
198
|
+
logger.info(f"Loaded {len(user_skills)} user skills")
|
|
199
|
+
|
|
200
|
+
async def _startup(self, app: web.Application) -> None:
|
|
201
|
+
"""Initialize MCP servers on startup."""
|
|
202
|
+
for server_config in self.config.mcp_servers:
|
|
203
|
+
# Security: log MCP server command for audit trail
|
|
204
|
+
logger.info(
|
|
205
|
+
f"Starting MCP server '{server_config.name}' with command: {' '.join(server_config.command)}"
|
|
206
|
+
)
|
|
207
|
+
config_dict = {
|
|
208
|
+
"name": server_config.name,
|
|
209
|
+
"command": server_config.command,
|
|
210
|
+
"env": server_config.env,
|
|
211
|
+
}
|
|
212
|
+
await self.tool_registry.add_server(config_dict)
|
|
213
|
+
|
|
214
|
+
tools = self.tool_registry.list_tools()
|
|
215
|
+
builtin_count = len(self.tool_registry.builtin_tools)
|
|
216
|
+
mcp_count = len(self.tool_registry.mcp_tools)
|
|
217
|
+
logger.info(f"Initialized {len(tools)} total tools ({builtin_count} built-in, {mcp_count} from MCP)")
|
|
218
|
+
|
|
219
|
+
async def _shutdown(self, app: web.Application) -> None:
|
|
220
|
+
"""Close MCP servers on shutdown."""
|
|
221
|
+
await self.tool_registry.close_all()
|
|
222
|
+
logger.info("Closed all MCP servers")
|
|
223
|
+
|
|
224
|
+
async def handle_rpc(self, request: web.Request) -> web.StreamResponse:
|
|
225
|
+
"""Handle JSON-RPC requests."""
|
|
226
|
+
# Validate API key
|
|
227
|
+
if not self._validate_api_key(request):
|
|
228
|
+
return web.json_response({
|
|
229
|
+
"jsonrpc": "2.0",
|
|
230
|
+
"error": {"code": -32000, "message": "Unauthorized - invalid or missing API key"},
|
|
231
|
+
"id": None
|
|
232
|
+
}, status=401)
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
data = await request.json()
|
|
236
|
+
except json.JSONDecodeError:
|
|
237
|
+
return web.json_response({
|
|
238
|
+
"jsonrpc": "2.0",
|
|
239
|
+
"error": {"code": -32700, "message": "Parse error"},
|
|
240
|
+
"id": None
|
|
241
|
+
}, status=400)
|
|
242
|
+
|
|
243
|
+
method = data.get("method")
|
|
244
|
+
params = data.get("params", {})
|
|
245
|
+
request_id = data.get("id")
|
|
246
|
+
|
|
247
|
+
logger.info(f"RPC call: {method}")
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
if method == "session.create":
|
|
251
|
+
result = await self.create_session(params)
|
|
252
|
+
return web.json_response({
|
|
253
|
+
"jsonrpc": "2.0",
|
|
254
|
+
"id": request_id,
|
|
255
|
+
"result": result
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
elif method == "turn.process":
|
|
259
|
+
# Streaming response
|
|
260
|
+
return await self.process_turn(request, params, request_id)
|
|
261
|
+
|
|
262
|
+
elif method == "session.baseline":
|
|
263
|
+
result = await self.get_baseline(params)
|
|
264
|
+
return web.json_response({
|
|
265
|
+
"jsonrpc": "2.0",
|
|
266
|
+
"id": request_id,
|
|
267
|
+
"result": result
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
elif method == "session.stats":
|
|
271
|
+
result = await self.get_stats(params)
|
|
272
|
+
return web.json_response({
|
|
273
|
+
"jsonrpc": "2.0",
|
|
274
|
+
"id": request_id,
|
|
275
|
+
"result": result
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
elif method == "session.compact":
|
|
279
|
+
result = await self.compact_session(params)
|
|
280
|
+
return web.json_response({
|
|
281
|
+
"jsonrpc": "2.0",
|
|
282
|
+
"id": request_id,
|
|
283
|
+
"result": result
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
elif method == "session.clear":
|
|
287
|
+
result = await self.clear_session(params)
|
|
288
|
+
return web.json_response({
|
|
289
|
+
"jsonrpc": "2.0",
|
|
290
|
+
"id": request_id,
|
|
291
|
+
"result": result
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
elif method == "session.get_history_metrics":
|
|
295
|
+
result = await self.get_history_metrics(params)
|
|
296
|
+
return web.json_response({
|
|
297
|
+
"jsonrpc": "2.0",
|
|
298
|
+
"id": request_id,
|
|
299
|
+
"result": result
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
elif method == "tool.call":
|
|
303
|
+
result = await self.call_tool(params)
|
|
304
|
+
return web.json_response({
|
|
305
|
+
"jsonrpc": "2.0",
|
|
306
|
+
"id": request_id,
|
|
307
|
+
"result": result
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
elif method == "workflow.execute":
|
|
311
|
+
# Streaming response for multi-agent workflow
|
|
312
|
+
return await self.execute_workflow(params, request, request_id)
|
|
313
|
+
|
|
314
|
+
elif method == "permission.response":
|
|
315
|
+
result = await self.handle_permission_response(params)
|
|
316
|
+
return web.json_response({
|
|
317
|
+
"jsonrpc": "2.0",
|
|
318
|
+
"id": request_id,
|
|
319
|
+
"result": result
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
elif method == "ping":
|
|
323
|
+
return web.json_response({
|
|
324
|
+
"jsonrpc": "2.0",
|
|
325
|
+
"id": request_id,
|
|
326
|
+
"result": {"status": "alive"}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
else:
|
|
330
|
+
return web.json_response({
|
|
331
|
+
"jsonrpc": "2.0",
|
|
332
|
+
"error": {"code": -32601, "message": "Method not found"},
|
|
333
|
+
"id": request_id
|
|
334
|
+
}, status=404)
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.error(f"Error handling {method}: {e}", exc_info=True)
|
|
338
|
+
return web.json_response({
|
|
339
|
+
"jsonrpc": "2.0",
|
|
340
|
+
"error": {"code": -32603, "message": str(e)},
|
|
341
|
+
"id": request_id
|
|
342
|
+
}, status=500)
|
|
343
|
+
|
|
344
|
+
async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
345
|
+
"""Create a new session."""
|
|
346
|
+
provider_name = params.get("provider")
|
|
347
|
+
provider = None
|
|
348
|
+
|
|
349
|
+
if provider_name and provider_name in self.providers:
|
|
350
|
+
provider = self.providers[provider_name]
|
|
351
|
+
|
|
352
|
+
session = self.session_manager.create_session(provider=provider)
|
|
353
|
+
|
|
354
|
+
# Get context limits from config
|
|
355
|
+
context_limit = self.config.context.default_limit if self.config.context else 200000
|
|
356
|
+
|
|
357
|
+
# Get sessions directory path for TUI logging
|
|
358
|
+
sessions_dir = get_data_dir() / "sessions"
|
|
359
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
"session_id": session.id,
|
|
363
|
+
"provider": session.provider.__class__.__name__,
|
|
364
|
+
"context_limit": context_limit,
|
|
365
|
+
"max_input_length": self._max_input_length,
|
|
366
|
+
"sessions_dir": str(sessions_dir)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async def _send_permission_request(self, request_id: str, permission_request) -> None:
|
|
370
|
+
"""Send permission request to TUI via stream.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
request_id: Unique request ID
|
|
374
|
+
permission_request: PermissionRequest object
|
|
375
|
+
"""
|
|
376
|
+
if not self._permission_stream:
|
|
377
|
+
logger.error("No active permission stream")
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
import json
|
|
381
|
+
event = {
|
|
382
|
+
"type": "permission_request",
|
|
383
|
+
"request_id": request_id,
|
|
384
|
+
"operation": permission_request.operation,
|
|
385
|
+
"path": permission_request.path,
|
|
386
|
+
"reason": permission_request.reason,
|
|
387
|
+
"details": permission_request.details or {}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await self._permission_stream.write(
|
|
391
|
+
(json.dumps(event) + "\n").encode("utf-8")
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _validate_api_key(self, request: web.Request) -> bool:
|
|
395
|
+
"""
|
|
396
|
+
Validate API key from request Authorization header.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
request: aiohttp request object
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
True if API key is valid, False otherwise
|
|
403
|
+
"""
|
|
404
|
+
# Get Authorization header
|
|
405
|
+
auth_header = request.headers.get("Authorization", "")
|
|
406
|
+
|
|
407
|
+
# Check Bearer token format
|
|
408
|
+
if not auth_header.startswith("Bearer "):
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
# Extract token
|
|
412
|
+
provided_key = auth_header[7:] # Remove "Bearer " prefix
|
|
413
|
+
|
|
414
|
+
# If no API key configured, allow all requests (backward compatibility)
|
|
415
|
+
if not self.api_key:
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
# Constant-time comparison to prevent timing attacks
|
|
419
|
+
import hmac
|
|
420
|
+
return hmac.compare_digest(provided_key, self.api_key)
|
|
421
|
+
|
|
422
|
+
def _sanitize_user_input(self, user_input: str) -> tuple[str, str | None]:
|
|
423
|
+
"""
|
|
424
|
+
Sanitize and validate user input.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
user_input: Raw user input string
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Tuple of (sanitized_input, error_message)
|
|
431
|
+
If error_message is not None, input is invalid
|
|
432
|
+
"""
|
|
433
|
+
# Length validation (from config, -1 = unlimited)
|
|
434
|
+
if self._max_input_length > 0 and len(user_input) > self._max_input_length:
|
|
435
|
+
return "", f"Input exceeds maximum length of {self._max_input_length} characters"
|
|
436
|
+
|
|
437
|
+
# Remove null bytes (security risk)
|
|
438
|
+
if "\x00" in user_input:
|
|
439
|
+
return "", "Input contains null bytes"
|
|
440
|
+
|
|
441
|
+
# Remove other control characters except newlines/tabs
|
|
442
|
+
sanitized = "".join(
|
|
443
|
+
char for char in user_input
|
|
444
|
+
if char == "\n" or char == "\t" or (ord(char) >= 32 and ord(char) != 127)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Detect potential prompt injection patterns
|
|
448
|
+
suspicious_patterns = [
|
|
449
|
+
"ignore previous instructions",
|
|
450
|
+
"disregard all",
|
|
451
|
+
"new instructions:",
|
|
452
|
+
"system:",
|
|
453
|
+
"<|endoftext|>",
|
|
454
|
+
"<|im_start|>",
|
|
455
|
+
"<|im_end|>",
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
lower_input = sanitized.lower()
|
|
459
|
+
for pattern in suspicious_patterns:
|
|
460
|
+
if pattern in lower_input:
|
|
461
|
+
logger.warning(f"Suspicious input pattern detected: {pattern}")
|
|
462
|
+
# Don't block, just log - could be legitimate discussion
|
|
463
|
+
|
|
464
|
+
return sanitized, None
|
|
465
|
+
|
|
466
|
+
def _check_rate_limit(self, session_id: str) -> bool:
|
|
467
|
+
"""
|
|
468
|
+
Check if request is within rate limit for session.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
session_id: Session identifier
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
True if within rate limit, False if exceeded
|
|
475
|
+
"""
|
|
476
|
+
# Skip if rate limiting disabled
|
|
477
|
+
if not self._rate_limit_enabled:
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
now = time.time()
|
|
481
|
+
|
|
482
|
+
# Get request history for this session
|
|
483
|
+
requests = self._rate_limit_requests[session_id]
|
|
484
|
+
|
|
485
|
+
# Remove requests outside the time window
|
|
486
|
+
requests[:] = [req_time for req_time in requests if now - req_time < self._rate_limit_window]
|
|
487
|
+
|
|
488
|
+
# Check if limit exceeded
|
|
489
|
+
if len(requests) >= self._rate_limit_max_requests:
|
|
490
|
+
logger.warning(
|
|
491
|
+
f"Rate limit exceeded for session {session_id}: "
|
|
492
|
+
f"{len(requests)} requests in {self._rate_limit_window}s"
|
|
493
|
+
)
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
# Add current request
|
|
497
|
+
requests.append(now)
|
|
498
|
+
return True
|
|
499
|
+
|
|
500
|
+
async def process_turn(
|
|
501
|
+
self,
|
|
502
|
+
request: web.Request,
|
|
503
|
+
params: dict[str, Any],
|
|
504
|
+
request_id: Any
|
|
505
|
+
) -> web.StreamResponse:
|
|
506
|
+
"""Process conversation turn with streaming."""
|
|
507
|
+
session_id = params.get("session_id")
|
|
508
|
+
user_input = params.get("input")
|
|
509
|
+
tools = params.get("tools")
|
|
510
|
+
|
|
511
|
+
if not session_id or not user_input:
|
|
512
|
+
return web.json_response({
|
|
513
|
+
"jsonrpc": "2.0",
|
|
514
|
+
"error": {"code": -32602, "message": "Invalid params"},
|
|
515
|
+
"id": request_id
|
|
516
|
+
}, status=400)
|
|
517
|
+
|
|
518
|
+
# Security: check rate limit
|
|
519
|
+
if not self._check_rate_limit(session_id):
|
|
520
|
+
return web.json_response({
|
|
521
|
+
"jsonrpc": "2.0",
|
|
522
|
+
"error": {"code": -32602, "message": "Rate limit exceeded. Please wait before sending more requests."},
|
|
523
|
+
"id": request_id
|
|
524
|
+
}, status=429)
|
|
525
|
+
|
|
526
|
+
# Security: sanitize user input
|
|
527
|
+
sanitized_input, error = self._sanitize_user_input(user_input)
|
|
528
|
+
if error:
|
|
529
|
+
logger.warning(f"Input validation failed: {error}")
|
|
530
|
+
return web.json_response({
|
|
531
|
+
"jsonrpc": "2.0",
|
|
532
|
+
"error": {"code": -32602, "message": f"Invalid input: {error}"},
|
|
533
|
+
"id": request_id
|
|
534
|
+
}, status=400)
|
|
535
|
+
|
|
536
|
+
# Use sanitized input instead of raw input
|
|
537
|
+
user_input = sanitized_input
|
|
538
|
+
|
|
539
|
+
# Stream response as newline-delimited JSON
|
|
540
|
+
response = web.StreamResponse()
|
|
541
|
+
response.headers['Content-Type'] = 'application/x-ndjson'
|
|
542
|
+
await response.prepare(request)
|
|
543
|
+
|
|
544
|
+
# Set permission stream for this request
|
|
545
|
+
self._permission_stream = response
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
async for event in self.session_manager.process_turn(
|
|
549
|
+
session_id=session_id,
|
|
550
|
+
user_input=user_input,
|
|
551
|
+
tools=tools
|
|
552
|
+
):
|
|
553
|
+
# Send event as NDJSON
|
|
554
|
+
line = json.dumps(event.to_dict()) + "\n"
|
|
555
|
+
try:
|
|
556
|
+
await response.write(line.encode())
|
|
557
|
+
# aiohttp auto-flushes after each write
|
|
558
|
+
except (ConnectionResetError, ConnectionError) as e:
|
|
559
|
+
logger.info(f"Client disconnected during streaming: {e}")
|
|
560
|
+
break # Stop streaming, client is gone
|
|
561
|
+
|
|
562
|
+
except Exception as e:
|
|
563
|
+
logger.error(f"Error processing turn: {e}", exc_info=True)
|
|
564
|
+
error_event = {"type": "error", "data": {"message": str(e)}}
|
|
565
|
+
try:
|
|
566
|
+
await response.write((json.dumps(error_event) + "\n").encode())
|
|
567
|
+
except (ConnectionResetError, ConnectionError):
|
|
568
|
+
logger.info("Client disconnected, cannot send error event")
|
|
569
|
+
|
|
570
|
+
finally:
|
|
571
|
+
# Clear permission stream
|
|
572
|
+
self._permission_stream = None
|
|
573
|
+
|
|
574
|
+
await response.write_eof()
|
|
575
|
+
return response
|
|
576
|
+
|
|
577
|
+
async def get_baseline(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
578
|
+
"""Get baseline for session."""
|
|
579
|
+
session_id = params.get("session_id")
|
|
580
|
+
|
|
581
|
+
if not session_id:
|
|
582
|
+
raise ValueError("session_id required")
|
|
583
|
+
|
|
584
|
+
baseline = self.session_manager.get_baseline(session_id)
|
|
585
|
+
|
|
586
|
+
if baseline:
|
|
587
|
+
return {
|
|
588
|
+
"code": baseline.code,
|
|
589
|
+
"file_path": baseline.file_path,
|
|
590
|
+
"language": baseline.language
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {"code": None}
|
|
594
|
+
|
|
595
|
+
async def get_stats(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
596
|
+
"""Get conversation statistics."""
|
|
597
|
+
session_id = params.get("session_id")
|
|
598
|
+
|
|
599
|
+
if not session_id:
|
|
600
|
+
raise ValueError("session_id required")
|
|
601
|
+
|
|
602
|
+
return self.session_manager.get_context_stats(session_id)
|
|
603
|
+
|
|
604
|
+
async def get_history_metrics(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
605
|
+
"""Get historical learning metrics from fuzzing orchestrator."""
|
|
606
|
+
if not self.fuzzing_orchestrator or not self.fuzzing_orchestrator.history_db:
|
|
607
|
+
return {}
|
|
608
|
+
|
|
609
|
+
# Get stats from history DB
|
|
610
|
+
stats = self.fuzzing_orchestrator.get_history_stats()
|
|
611
|
+
|
|
612
|
+
# Get cache stats if available
|
|
613
|
+
cache_stats = {}
|
|
614
|
+
if hasattr(self.fuzzing_orchestrator, 'context_engine') and \
|
|
615
|
+
hasattr(self.fuzzing_orchestrator.context_engine, 'oracle_cache'):
|
|
616
|
+
cache = self.fuzzing_orchestrator.context_engine.oracle_cache
|
|
617
|
+
cache_stats = {
|
|
618
|
+
"size": cache.size,
|
|
619
|
+
"hits": cache.hits,
|
|
620
|
+
"misses": cache.misses,
|
|
621
|
+
"hit_rate": cache.hit_rate,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
# Get vector store size if available
|
|
625
|
+
vector_store_size = 0
|
|
626
|
+
if hasattr(self.fuzzing_orchestrator, 'context_engine') and \
|
|
627
|
+
hasattr(self.fuzzing_orchestrator.context_engine, 'vector_store'):
|
|
628
|
+
vector_store_size = self.fuzzing_orchestrator.context_engine.vector_store.size
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
**stats,
|
|
632
|
+
"cache_stats": cache_stats,
|
|
633
|
+
"vector_store_size": vector_store_size,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async def compact_session(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
637
|
+
"""Compact conversation history."""
|
|
638
|
+
session_id = params.get("session_id")
|
|
639
|
+
|
|
640
|
+
if not session_id:
|
|
641
|
+
raise ValueError("session_id required")
|
|
642
|
+
|
|
643
|
+
self.session_manager.compact_conversation(session_id)
|
|
644
|
+
|
|
645
|
+
return {"success": True}
|
|
646
|
+
|
|
647
|
+
async def clear_session(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
648
|
+
"""Clear conversation history."""
|
|
649
|
+
session_id = params.get("session_id")
|
|
650
|
+
|
|
651
|
+
if not session_id:
|
|
652
|
+
raise ValueError("session_id required")
|
|
653
|
+
|
|
654
|
+
self.session_manager.clear_conversation(session_id)
|
|
655
|
+
|
|
656
|
+
return {"success": True}
|
|
657
|
+
|
|
658
|
+
async def get_history_metrics(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
659
|
+
"""Get historical learning metrics from HistoryDB.
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
Dictionary containing historical fuzzing metrics
|
|
663
|
+
"""
|
|
664
|
+
# Check if fuzzing orchestrator and history DB are available
|
|
665
|
+
if not self.fuzzing_orchestrator:
|
|
666
|
+
return {
|
|
667
|
+
"error": "Fuzzing not enabled",
|
|
668
|
+
"total_sessions": 0
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if not self.fuzzing_orchestrator.history_db:
|
|
672
|
+
return {
|
|
673
|
+
"error": "History database not available",
|
|
674
|
+
"total_sessions": 0
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
# Get stats from history database
|
|
678
|
+
try:
|
|
679
|
+
stats = self.fuzzing_orchestrator.history_db.get_stats()
|
|
680
|
+
return stats
|
|
681
|
+
except Exception as e:
|
|
682
|
+
logger.error(f"Failed to get history metrics: {e}")
|
|
683
|
+
return {
|
|
684
|
+
"error": f"Failed to get metrics: {str(e)}",
|
|
685
|
+
"total_sessions": 0
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async def handle_permission_response(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
689
|
+
"""Handle permission response from TUI.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
params: {request_id: str, approved: bool}
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
Success status
|
|
696
|
+
"""
|
|
697
|
+
request_id = params.get("request_id")
|
|
698
|
+
approved = params.get("approved", False)
|
|
699
|
+
|
|
700
|
+
if not request_id:
|
|
701
|
+
raise ValueError("request_id required")
|
|
702
|
+
|
|
703
|
+
# Forward to permission manager
|
|
704
|
+
from .permissions import get_permission_manager
|
|
705
|
+
|
|
706
|
+
manager = get_permission_manager()
|
|
707
|
+
if manager:
|
|
708
|
+
manager.handle_permission_response(request_id, approved)
|
|
709
|
+
else:
|
|
710
|
+
logger.warning(f"No permission manager to handle response for {request_id}")
|
|
711
|
+
|
|
712
|
+
return {"success": True}
|
|
713
|
+
|
|
714
|
+
async def call_tool(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
715
|
+
"""Call a tool directly."""
|
|
716
|
+
session_id = params.get("session_id")
|
|
717
|
+
tool_name = params.get("tool_name")
|
|
718
|
+
tool_input = params.get("tool_input", {})
|
|
719
|
+
|
|
720
|
+
if not session_id:
|
|
721
|
+
raise ValueError("session_id required")
|
|
722
|
+
if not tool_name:
|
|
723
|
+
raise ValueError("tool_name required")
|
|
724
|
+
|
|
725
|
+
# Execute tool via tool executor
|
|
726
|
+
import uuid
|
|
727
|
+
call_id = str(uuid.uuid4())
|
|
728
|
+
result = await self.tool_executor.execute(tool_name, tool_input, call_id)
|
|
729
|
+
|
|
730
|
+
# Return the result from the tool call
|
|
731
|
+
if result.success:
|
|
732
|
+
return result.result
|
|
733
|
+
else:
|
|
734
|
+
raise ValueError(result.error or "Tool execution failed")
|
|
735
|
+
|
|
736
|
+
async def execute_workflow(
|
|
737
|
+
self,
|
|
738
|
+
params: dict[str, Any],
|
|
739
|
+
request: web.Request,
|
|
740
|
+
request_id: str | None
|
|
741
|
+
) -> web.StreamResponse:
|
|
742
|
+
"""
|
|
743
|
+
Execute multi-agent workflow with streaming events.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
params: Request parameters containing 'intent'
|
|
747
|
+
request: HTTP request object
|
|
748
|
+
request_id: JSON-RPC request ID
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
Streaming NDJSON response
|
|
752
|
+
"""
|
|
753
|
+
user_intent = params.get("intent")
|
|
754
|
+
if not user_intent:
|
|
755
|
+
return web.json_response({
|
|
756
|
+
"jsonrpc": "2.0",
|
|
757
|
+
"error": {"code": -32602, "message": "intent parameter required"},
|
|
758
|
+
"id": request_id
|
|
759
|
+
}, status=400)
|
|
760
|
+
|
|
761
|
+
# Security: sanitize user intent
|
|
762
|
+
sanitized_intent, error = self._sanitize_user_input(user_intent)
|
|
763
|
+
if error:
|
|
764
|
+
logger.warning(f"Workflow intent validation failed: {error}")
|
|
765
|
+
return web.json_response({
|
|
766
|
+
"jsonrpc": "2.0",
|
|
767
|
+
"error": {"code": -32602, "message": f"Invalid intent: {error}"},
|
|
768
|
+
"id": request_id
|
|
769
|
+
}, status=400)
|
|
770
|
+
|
|
771
|
+
# Use sanitized intent
|
|
772
|
+
user_intent = sanitized_intent
|
|
773
|
+
|
|
774
|
+
# Create streaming response
|
|
775
|
+
response = web.StreamResponse()
|
|
776
|
+
response.headers['Content-Type'] = 'application/x-ndjson'
|
|
777
|
+
await response.prepare(request)
|
|
778
|
+
|
|
779
|
+
try:
|
|
780
|
+
# Import workflow components
|
|
781
|
+
from .agents.workflow import WorkflowOrchestrator
|
|
782
|
+
from .agents.registry import AgentRegistry
|
|
783
|
+
|
|
784
|
+
# Create agent registry
|
|
785
|
+
workspace_root = self.config.workspace_root or Path.cwd()
|
|
786
|
+
agent_registry = AgentRegistry(
|
|
787
|
+
workspace_root=workspace_root,
|
|
788
|
+
tool_registry=self.tool_registry
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
# Create workflow event callback
|
|
792
|
+
async def workflow_event_callback(event_type: str, data: dict):
|
|
793
|
+
event = {"type": event_type, "data": data}
|
|
794
|
+
line = json.dumps(event) + "\n"
|
|
795
|
+
await response.write(line.encode())
|
|
796
|
+
|
|
797
|
+
# Get provider
|
|
798
|
+
provider = self.providers.get("anthropic") or self.providers.get("openai")
|
|
799
|
+
if not provider:
|
|
800
|
+
raise ValueError("No provider available")
|
|
801
|
+
|
|
802
|
+
# Create orchestrator
|
|
803
|
+
orchestrator = WorkflowOrchestrator(
|
|
804
|
+
agent_registry=agent_registry,
|
|
805
|
+
storage_path=get_data_dir() / "agents",
|
|
806
|
+
provider=provider,
|
|
807
|
+
tool_registry=self.tool_registry,
|
|
808
|
+
event_callback=workflow_event_callback
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
# Execute workflow
|
|
812
|
+
result = await orchestrator.handle_user_request(user_intent)
|
|
813
|
+
|
|
814
|
+
# Send final result event
|
|
815
|
+
final_event = {"type": "workflow_complete", "data": result}
|
|
816
|
+
await response.write((json.dumps(final_event) + "\n").encode())
|
|
817
|
+
|
|
818
|
+
except Exception as e:
|
|
819
|
+
logger.error(f"Workflow execution failed: {e}", exc_info=True)
|
|
820
|
+
error_event = {"type": "workflow_error", "data": {"error": str(e)}}
|
|
821
|
+
await response.write((json.dumps(error_event) + "\n").encode())
|
|
822
|
+
|
|
823
|
+
await response.write_eof()
|
|
824
|
+
return response
|
|
825
|
+
|
|
826
|
+
async def serve_tech_debt_dashboard(self, request: web.Request) -> web.Response:
|
|
827
|
+
"""
|
|
828
|
+
Serve tech debt dashboard HTML.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
request: HTTP request
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
HTML response with dashboard
|
|
835
|
+
"""
|
|
836
|
+
try:
|
|
837
|
+
html = self.tech_debt_metrics.generate_dashboard_html()
|
|
838
|
+
return web.Response(text=html, content_type="text/html")
|
|
839
|
+
except Exception as e:
|
|
840
|
+
logger.error(f"Failed to generate dashboard: {e}", exc_info=True)
|
|
841
|
+
return web.Response(
|
|
842
|
+
text=f"<html><body><h1>Error generating dashboard</h1><p>{e}</p></body></html>",
|
|
843
|
+
content_type="text/html",
|
|
844
|
+
status=500
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
def run(self):
|
|
848
|
+
"""Run the server."""
|
|
849
|
+
logger.info(f"Starting server on {self.config.server.host}:{self.config.server.port}")
|
|
850
|
+
web.run_app(
|
|
851
|
+
self.app,
|
|
852
|
+
host=self.config.server.host,
|
|
853
|
+
port=self.config.server.port,
|
|
854
|
+
print=None # Suppress default startup message
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def main():
|
|
859
|
+
"""Entry point for server."""
|
|
860
|
+
logging.basicConfig(
|
|
861
|
+
level=logging.INFO,
|
|
862
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
config = Config.load()
|
|
866
|
+
server = HarnessServer(config)
|
|
867
|
+
server.run()
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
if __name__ == "__main__":
|
|
871
|
+
main()
|