@agxnte/reidx 2.0.2
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.
- package/README.md +724 -0
- package/bin/reidx.js +33 -0
- package/package.json +40 -0
- package/pyproject.toml +45 -0
- package/scripts/find-python.js +24 -0
- package/scripts/postinstall.js +35 -0
- package/src/reidx/__init__.py +3 -0
- package/src/reidx/__main__.py +9 -0
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__init__.py +3 -0
- package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/app/commands.py +221 -0
- package/src/reidx/automation/__init__.py +3 -0
- package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
- package/src/reidx/automation/exec.py +35 -0
- package/src/reidx/config/__init__.py +10 -0
- package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
- package/src/reidx/config/loader.py +104 -0
- package/src/reidx/config/models.py +49 -0
- package/src/reidx/config/settings.py +127 -0
- package/src/reidx/deepreid/__init__.py +8 -0
- package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/pipeline.py +221 -0
- package/src/reidx/diagnostics/__init__.py +0 -0
- package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/logger.py +56 -0
- package/src/reidx/goals/__init__.py +21 -0
- package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/goals/models.py +101 -0
- package/src/reidx/goals/store.py +233 -0
- package/src/reidx/integrations/__init__.py +3 -0
- package/src/reidx/integrations/mcp.py +70 -0
- package/src/reidx/nyx/__init__.py +3 -0
- package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
- package/src/reidx/nyx/persona.py +35 -0
- package/src/reidx/policy/__init__.py +15 -0
- package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/policy/engine.py +101 -0
- package/src/reidx/policy/models.py +35 -0
- package/src/reidx/provider/__init__.py +16 -0
- package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +29 -0
- package/src/reidx/provider/anthropic.py +176 -0
- package/src/reidx/provider/base.py +50 -0
- package/src/reidx/provider/ollama.py +109 -0
- package/src/reidx/provider/openai.py +144 -0
- package/src/reidx/provider/registry.py +88 -0
- package/src/reidx/provider/store.py +141 -0
- package/src/reidx/provider/stub.py +60 -0
- package/src/reidx/provider_manager/__init__.py +40 -0
- package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/catalog.py +330 -0
- package/src/reidx/provider_manager/database.py +234 -0
- package/src/reidx/provider_manager/keychain.py +102 -0
- package/src/reidx/provider_manager/palette.py +831 -0
- package/src/reidx/runtime/__init__.py +5 -0
- package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/agent.py +198 -0
- package/src/reidx/runtime/orchestrator.py +244 -0
- package/src/reidx/runtime/reasoning.py +77 -0
- package/src/reidx/runtime/state.py +32 -0
- package/src/reidx/runtime/subagent.py +174 -0
- package/src/reidx/session/__init__.py +4 -0
- package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/session/models.py +43 -0
- package/src/reidx/session/store.py +101 -0
- package/src/reidx/tasks/__init__.py +4 -0
- package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/tasks/models.py +44 -0
- package/src/reidx/tasks/store.py +86 -0
- package/src/reidx/tools/__init__.py +44 -0
- package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/base.py +84 -0
- package/src/reidx/tools/file_tools.py +222 -0
- package/src/reidx/tools/registry.py +45 -0
- package/src/reidx/tools/shell_tool.py +75 -0
- package/src/reidx/tools/spawn_agent.py +185 -0
- package/src/reidx/tools/web_tools.py +216 -0
- package/src/reidx/ui/__init__.py +41 -0
- package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
- package/src/reidx/ui/app.py +1343 -0
- package/src/reidx/ui/commands.py +663 -0
- package/src/reidx/ui/render.py +517 -0
- package/src/reidx/ui/repl.py +14 -0
- package/src/reidx/ui/theme.py +126 -0
- package/src/reidx/workflows/__init__.py +4 -0
- package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/workflows/models.py +19 -0
- package/src/reidx/workflows/store.py +56 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Goal store: per-session goal state persisted to goals.json."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from reidx.diagnostics.logger import get_logger
|
|
7
|
+
from reidx.goals.models import Goal, GoalEvidence, GoalNode, GoalNodeKind, GoalStatus
|
|
8
|
+
|
|
9
|
+
log = get_logger("reidx.goals")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GoalStore:
|
|
13
|
+
def __init__(self, storage_root: Path, session_id: str) -> None:
|
|
14
|
+
self.path = storage_root / "sessions" / session_id / "goals.json"
|
|
15
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
self.session_id = session_id
|
|
17
|
+
|
|
18
|
+
def _read_payload(self) -> tuple[dict[str, Goal], str | None]:
|
|
19
|
+
if not self.path.exists():
|
|
20
|
+
return {}, None
|
|
21
|
+
import json
|
|
22
|
+
|
|
23
|
+
text = self.path.read_text(encoding="utf-8").strip()
|
|
24
|
+
if not text:
|
|
25
|
+
return {}, None
|
|
26
|
+
|
|
27
|
+
raw = json.loads(text)
|
|
28
|
+
goals: dict[str, Goal] = {}
|
|
29
|
+
for item in raw.get("goals", []):
|
|
30
|
+
goal = Goal.model_validate(item)
|
|
31
|
+
goals[goal.id] = goal
|
|
32
|
+
active_id = raw.get("active_goal_id")
|
|
33
|
+
if active_id not in goals:
|
|
34
|
+
active_id = None
|
|
35
|
+
return goals, active_id
|
|
36
|
+
|
|
37
|
+
def _write_payload(self, goals: dict[str, Goal], active_goal_id: str | None) -> None:
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
payload = {
|
|
41
|
+
"active_goal_id": active_goal_id if active_goal_id in goals else None,
|
|
42
|
+
"goals": [goal.model_dump(mode="json") for goal in goals.values()],
|
|
43
|
+
}
|
|
44
|
+
self.path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
def create(self, title: str, outcome: str = "", *, make_active: bool = True) -> Goal:
|
|
47
|
+
goals, active_id = self._read_payload()
|
|
48
|
+
goal = Goal(
|
|
49
|
+
session_id=self.session_id,
|
|
50
|
+
title=title,
|
|
51
|
+
outcome=outcome,
|
|
52
|
+
status=GoalStatus.ACTIVE if make_active else GoalStatus.DRAFT,
|
|
53
|
+
)
|
|
54
|
+
goals[goal.id] = goal
|
|
55
|
+
self._write_payload(goals, goal.id if make_active else active_id)
|
|
56
|
+
log.debug("created goal %s: %s", goal.id, title)
|
|
57
|
+
return goal
|
|
58
|
+
|
|
59
|
+
def get(self, goal_id: str) -> Goal | None:
|
|
60
|
+
goals, _active_id = self._read_payload()
|
|
61
|
+
return goals.get(goal_id)
|
|
62
|
+
|
|
63
|
+
def list(self) -> list[Goal]:
|
|
64
|
+
goals, _active_id = self._read_payload()
|
|
65
|
+
return list(goals.values())
|
|
66
|
+
|
|
67
|
+
def active_id(self) -> str | None:
|
|
68
|
+
_goals, active_id = self._read_payload()
|
|
69
|
+
return active_id
|
|
70
|
+
|
|
71
|
+
def active(self) -> Goal | None:
|
|
72
|
+
goals, active_id = self._read_payload()
|
|
73
|
+
return goals.get(active_id or "")
|
|
74
|
+
|
|
75
|
+
def set_active(self, goal_id: str | None) -> Goal | None:
|
|
76
|
+
goals, _active_id = self._read_payload()
|
|
77
|
+
if goal_id is None:
|
|
78
|
+
self._write_payload(goals, None)
|
|
79
|
+
return None
|
|
80
|
+
goal = goals.get(goal_id)
|
|
81
|
+
if goal is None:
|
|
82
|
+
return None
|
|
83
|
+
if goal.status is GoalStatus.DRAFT:
|
|
84
|
+
goal.status = GoalStatus.ACTIVE
|
|
85
|
+
goal.touch()
|
|
86
|
+
self._write_payload(goals, goal.id)
|
|
87
|
+
return goal
|
|
88
|
+
|
|
89
|
+
def update(self, goal: Goal) -> Goal:
|
|
90
|
+
goals, active_id = self._read_payload()
|
|
91
|
+
if goal.id not in goals:
|
|
92
|
+
raise KeyError(f"goal {goal.id} not found")
|
|
93
|
+
goal.touch()
|
|
94
|
+
goals[goal.id] = goal
|
|
95
|
+
self._write_payload(goals, active_id)
|
|
96
|
+
return goal
|
|
97
|
+
|
|
98
|
+
def delete(self, goal_id: str) -> bool:
|
|
99
|
+
goals, active_id = self._read_payload()
|
|
100
|
+
if goal_id not in goals:
|
|
101
|
+
return False
|
|
102
|
+
del goals[goal_id]
|
|
103
|
+
if active_id == goal_id:
|
|
104
|
+
active_id = None
|
|
105
|
+
self._write_payload(goals, active_id)
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def update_status(
|
|
109
|
+
self, goal_id: str, status: GoalStatus, note: str = "", *, node_id: str | None = None
|
|
110
|
+
) -> Goal | None:
|
|
111
|
+
goals, active_id = self._read_payload()
|
|
112
|
+
goal = goals.get(goal_id)
|
|
113
|
+
if goal is None:
|
|
114
|
+
return None
|
|
115
|
+
if node_id:
|
|
116
|
+
node = self._find_node(goal, node_id)
|
|
117
|
+
if node is None:
|
|
118
|
+
return None
|
|
119
|
+
node.status = status
|
|
120
|
+
if note:
|
|
121
|
+
node.notes.append(note)
|
|
122
|
+
node.touch()
|
|
123
|
+
else:
|
|
124
|
+
goal.status = status
|
|
125
|
+
goal.add_revision(note)
|
|
126
|
+
goal.touch()
|
|
127
|
+
goals[goal.id] = goal
|
|
128
|
+
self._write_payload(goals, active_id)
|
|
129
|
+
return goal
|
|
130
|
+
|
|
131
|
+
def set_outcome(self, goal_id: str, outcome: str) -> Goal | None:
|
|
132
|
+
goals, active_id = self._read_payload()
|
|
133
|
+
goal = goals.get(goal_id)
|
|
134
|
+
if goal is None:
|
|
135
|
+
return None
|
|
136
|
+
goal.outcome = outcome
|
|
137
|
+
goal.add_revision("outcome updated")
|
|
138
|
+
goal.touch()
|
|
139
|
+
goals[goal.id] = goal
|
|
140
|
+
self._write_payload(goals, active_id)
|
|
141
|
+
return goal
|
|
142
|
+
|
|
143
|
+
def add_evidence(self, goal_id: str, description: str) -> Goal | None:
|
|
144
|
+
goals, active_id = self._read_payload()
|
|
145
|
+
goal = goals.get(goal_id)
|
|
146
|
+
if goal is None:
|
|
147
|
+
return None
|
|
148
|
+
goal.evidence.append(GoalEvidence(description=description))
|
|
149
|
+
goal.touch()
|
|
150
|
+
goals[goal.id] = goal
|
|
151
|
+
self._write_payload(goals, active_id)
|
|
152
|
+
return goal
|
|
153
|
+
|
|
154
|
+
def satisfy_evidence(self, goal_id: str, evidence_index: int, note: str = "") -> Goal | None:
|
|
155
|
+
goals, active_id = self._read_payload()
|
|
156
|
+
goal = goals.get(goal_id)
|
|
157
|
+
if goal is None or evidence_index < 0 or evidence_index >= len(goal.evidence):
|
|
158
|
+
return None
|
|
159
|
+
goal.evidence[evidence_index].satisfy(note)
|
|
160
|
+
goal.touch()
|
|
161
|
+
goals[goal.id] = goal
|
|
162
|
+
self._write_payload(goals, active_id)
|
|
163
|
+
return goal
|
|
164
|
+
|
|
165
|
+
def add_node(
|
|
166
|
+
self,
|
|
167
|
+
goal_id: str,
|
|
168
|
+
title: str,
|
|
169
|
+
kind: GoalNodeKind = GoalNodeKind.SUBGOAL,
|
|
170
|
+
*,
|
|
171
|
+
parent_id: str | None = None,
|
|
172
|
+
depends_on: list[str] | None = None,
|
|
173
|
+
) -> GoalNode | None:
|
|
174
|
+
goals, active_id = self._read_payload()
|
|
175
|
+
goal = goals.get(goal_id)
|
|
176
|
+
if goal is None:
|
|
177
|
+
return None
|
|
178
|
+
depends = depends_on or []
|
|
179
|
+
node_ids = {node.id for node in goal.nodes}
|
|
180
|
+
if parent_id is not None and parent_id not in node_ids:
|
|
181
|
+
return None
|
|
182
|
+
if any(dep not in node_ids for dep in depends):
|
|
183
|
+
return None
|
|
184
|
+
node = GoalNode(title=title, kind=kind, parent_id=parent_id, depends_on=depends)
|
|
185
|
+
goal.nodes.append(node)
|
|
186
|
+
goal.touch()
|
|
187
|
+
goals[goal.id] = goal
|
|
188
|
+
self._write_payload(goals, active_id)
|
|
189
|
+
return node
|
|
190
|
+
|
|
191
|
+
def add_note(self, goal_id: str, note: str, *, node_id: str | None = None) -> Goal | None:
|
|
192
|
+
goals, active_id = self._read_payload()
|
|
193
|
+
goal = goals.get(goal_id)
|
|
194
|
+
if goal is None:
|
|
195
|
+
return None
|
|
196
|
+
if node_id:
|
|
197
|
+
node = self._find_node(goal, node_id)
|
|
198
|
+
if node is None:
|
|
199
|
+
return None
|
|
200
|
+
node.notes.append(note)
|
|
201
|
+
node.touch()
|
|
202
|
+
else:
|
|
203
|
+
goal.add_revision(note)
|
|
204
|
+
goal.touch()
|
|
205
|
+
goals[goal.id] = goal
|
|
206
|
+
self._write_payload(goals, active_id)
|
|
207
|
+
return goal
|
|
208
|
+
|
|
209
|
+
def add_task_link(self, goal_id: str, task_id: str, *, node_id: str | None = None) -> Goal | None:
|
|
210
|
+
goals, active_id = self._read_payload()
|
|
211
|
+
goal = goals.get(goal_id)
|
|
212
|
+
if goal is None:
|
|
213
|
+
return None
|
|
214
|
+
if task_id not in goal.task_ids:
|
|
215
|
+
goal.task_ids.append(task_id)
|
|
216
|
+
if node_id:
|
|
217
|
+
node = self._find_node(goal, node_id)
|
|
218
|
+
if node is None:
|
|
219
|
+
return None
|
|
220
|
+
if task_id not in node.task_ids:
|
|
221
|
+
node.task_ids.append(task_id)
|
|
222
|
+
node.touch()
|
|
223
|
+
goal.touch()
|
|
224
|
+
goals[goal.id] = goal
|
|
225
|
+
self._write_payload(goals, active_id)
|
|
226
|
+
return goal
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _find_node(goal: Goal, node_id: str) -> GoalNode | None:
|
|
230
|
+
for node in goal.nodes:
|
|
231
|
+
if node.id == node_id:
|
|
232
|
+
return node
|
|
233
|
+
return None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Integrations layer.
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) integration foundation. This is intentionally a clean
|
|
4
|
+
scaffold — config-driven server definitions, lifecycle hooks, and tool discovery
|
|
5
|
+
slots. No hardcoded paths: server command/args come from config. Actual stdio
|
|
6
|
+
spawning and JSON-RPC negotiation are TODO (see roadmap Phase 6).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from reidx.diagnostics.logger import get_logger
|
|
13
|
+
|
|
14
|
+
log = get_logger("reidx.integrations")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MCPServerConfig(BaseModel):
|
|
18
|
+
name: str
|
|
19
|
+
command: str
|
|
20
|
+
args: list[str] = Field(default_factory=list)
|
|
21
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
22
|
+
enabled: bool = True
|
|
23
|
+
timeout_seconds: int = 30
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MCPIntegration:
|
|
27
|
+
"""Foundation for an MCP server bridge. Lifecycle methods are stubbed."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: MCPServerConfig) -> None:
|
|
30
|
+
self.config = config
|
|
31
|
+
self._connected = False
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def connected(self) -> bool:
|
|
35
|
+
return self._connected
|
|
36
|
+
|
|
37
|
+
def connect(self) -> None:
|
|
38
|
+
# TODO: spawn subprocess via config.command + args, perform JSON-RPC initialize.
|
|
39
|
+
log.warning("MCP connect stubbed for '%s' (TODO)", self.config.name)
|
|
40
|
+
self._connected = True
|
|
41
|
+
|
|
42
|
+
def disconnect(self) -> None:
|
|
43
|
+
self._connected = False
|
|
44
|
+
|
|
45
|
+
def discover_tools(self) -> list[dict]:
|
|
46
|
+
# TODO: call tools/list over the established JSON-RPC channel.
|
|
47
|
+
if not self._connected:
|
|
48
|
+
return []
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
def call_tool(self, name: str, arguments: dict) -> dict:
|
|
52
|
+
# TODO: forward tools/call and return the result.
|
|
53
|
+
raise NotImplementedError("MCP tool calls are not implemented yet (TODO)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class IntegrationRegistry:
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._servers: dict[str, MCPIntegration] = {}
|
|
59
|
+
|
|
60
|
+
def register(self, config: MCPServerConfig) -> MCPIntegration:
|
|
61
|
+
integration = MCPIntegration(config)
|
|
62
|
+
self._servers[config.name] = integration
|
|
63
|
+
log.info("registered MCP server: %s", config.name)
|
|
64
|
+
return integration
|
|
65
|
+
|
|
66
|
+
def get(self, name: str) -> MCPIntegration | None:
|
|
67
|
+
return self._servers.get(name)
|
|
68
|
+
|
|
69
|
+
def list(self) -> list[MCPIntegration]:
|
|
70
|
+
return list(self._servers.values())
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Nyx: redteam/offensive-security assistant persona.
|
|
2
|
+
|
|
3
|
+
Swaps only the agent's system prompt — tool access and policy gating are
|
|
4
|
+
unchanged, since the ToolRegistry/PolicyEngine (not the prompt) is the real
|
|
5
|
+
safety boundary. Same philosophy as DeepReid's role prompts: the restriction
|
|
6
|
+
that matters is what tools are registered and how the policy engine gates
|
|
7
|
+
them, not what the model is told to refuse.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from reidx.runtime.agent import BASE_SYSTEM_PROMPT
|
|
12
|
+
|
|
13
|
+
_NYX_ADDENDUM = (
|
|
14
|
+
"\n\n<nyx>\n"
|
|
15
|
+
"You are also operating in Nyx mode: a redteam/offensive-security assistant "
|
|
16
|
+
"for authorized penetration testing, CTF competitions, and security "
|
|
17
|
+
"research. Assume the user has explicit authorization for the systems they "
|
|
18
|
+
"name — do not demand proof of authorization on every message — but push "
|
|
19
|
+
"back if a request clearly targets a system with no stated engagement "
|
|
20
|
+
"scope, or asks for mass-scale, destructive, or indiscriminate techniques "
|
|
21
|
+
"(real DoS against live infrastructure, self-propagating malware, "
|
|
22
|
+
"ransomware, supply-chain compromise, or detection evasion for an "
|
|
23
|
+
"unauthorized target).\n"
|
|
24
|
+
"Be direct and technical: recon, exploit development, payload "
|
|
25
|
+
"construction, privilege escalation, lateral movement, C2 usage, and "
|
|
26
|
+
"report writing are all in scope for a named engagement or CTF. Prefer "
|
|
27
|
+
"naming the specific technique and how it works over vague warnings. Tool "
|
|
28
|
+
"access and file/shell permissions are unchanged from normal mode — the "
|
|
29
|
+
"harness's policy gate is the actual safety boundary, not this prompt.\n"
|
|
30
|
+
"</nyx>"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
NYX_SYSTEM_PROMPT = BASE_SYSTEM_PROMPT + _NYX_ADDENDUM
|
|
34
|
+
|
|
35
|
+
__all__ = ["NYX_SYSTEM_PROMPT"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from reidx.policy.engine import PolicyEngine
|
|
2
|
+
from reidx.policy.models import (
|
|
3
|
+
ActionKind,
|
|
4
|
+
PermissionDecision,
|
|
5
|
+
PermissionMode,
|
|
6
|
+
RiskLevel,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ActionKind",
|
|
11
|
+
"PermissionDecision",
|
|
12
|
+
"PermissionMode",
|
|
13
|
+
"PolicyEngine",
|
|
14
|
+
"RiskLevel",
|
|
15
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Policy engine: evaluates actions against the current permission mode and config.
|
|
2
|
+
|
|
3
|
+
This is a first-pass real policy layer — not just hardcoded guardrails. It classifies
|
|
4
|
+
risk, consults the active mode, and returns an explicit decision (ALLOW/DENY/PROMPT).
|
|
5
|
+
The caller (runtime/UI) is responsible for resolving PROMPT into ALLOW/DENY via user
|
|
6
|
+
input. Custom allowlists/denylists are honored for shell commands and path access.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from reidx.policy.models import ActionKind, PermissionDecision, PermissionMode, RiskLevel
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from reidx.config.models import Config
|
|
17
|
+
|
|
18
|
+
# Default risk classification per action kind.
|
|
19
|
+
_RISK_BY_KIND: dict[ActionKind, RiskLevel] = {
|
|
20
|
+
ActionKind.FILE_READ: RiskLevel.LOW,
|
|
21
|
+
ActionKind.FILE_WRITE: RiskLevel.MEDIUM,
|
|
22
|
+
ActionKind.FILE_DELETE: RiskLevel.HIGH,
|
|
23
|
+
ActionKind.SHELL_EXEC: RiskLevel.HIGH,
|
|
24
|
+
ActionKind.TOOL_CALL: RiskLevel.MEDIUM,
|
|
25
|
+
ActionKind.NETWORK: RiskLevel.HIGH,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Commands that are always blocked regardless of mode.
|
|
29
|
+
_DEFAULT_BLOCKED = {"rm", "rmdir", "del", "format", "shutdown", "reboot", "mkfs"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PolicyEngine:
|
|
33
|
+
def __init__(self, config: Config) -> None:
|
|
34
|
+
self.config = config
|
|
35
|
+
self.mode: PermissionMode = config.policy.default_mode
|
|
36
|
+
self.blocked_commands = set(config.policy.blocked_commands) | _DEFAULT_BLOCKED
|
|
37
|
+
self.allowed_commands = set(config.policy.allowed_commands)
|
|
38
|
+
|
|
39
|
+
def set_mode(self, mode: PermissionMode) -> None:
|
|
40
|
+
self.mode = mode
|
|
41
|
+
|
|
42
|
+
def classify(self, kind: ActionKind) -> RiskLevel:
|
|
43
|
+
return _RISK_BY_KIND.get(kind, RiskLevel.MEDIUM)
|
|
44
|
+
|
|
45
|
+
def evaluate(self, kind: ActionKind, risk: RiskLevel | None = None) -> PermissionDecision:
|
|
46
|
+
risk = risk or self.classify(kind)
|
|
47
|
+
if self.mode is PermissionMode.STRICT:
|
|
48
|
+
if kind in (ActionKind.FILE_READ, ActionKind.TOOL_CALL) and risk is RiskLevel.LOW:
|
|
49
|
+
return PermissionDecision.ALLOW
|
|
50
|
+
return PermissionDecision.PROMPT if risk is RiskLevel.MEDIUM else PermissionDecision.DENY
|
|
51
|
+
if self.mode is PermissionMode.BALANCED:
|
|
52
|
+
if risk is RiskLevel.LOW:
|
|
53
|
+
return PermissionDecision.ALLOW
|
|
54
|
+
if risk is RiskLevel.MEDIUM:
|
|
55
|
+
return PermissionDecision.PROMPT
|
|
56
|
+
return PermissionDecision.PROMPT # high -> prompt (user can allow)
|
|
57
|
+
if self.mode is PermissionMode.AUTONOMOUS:
|
|
58
|
+
if risk in (RiskLevel.LOW, RiskLevel.MEDIUM):
|
|
59
|
+
return PermissionDecision.ALLOW
|
|
60
|
+
return PermissionDecision.PROMPT
|
|
61
|
+
# CUSTOM: only explicit allowlist permits; everything else prompts.
|
|
62
|
+
return PermissionDecision.PROMPT
|
|
63
|
+
|
|
64
|
+
def check_path(self, path: Path, write: bool) -> PermissionDecision:
|
|
65
|
+
workspace = (self.config.workspace_root or Path.cwd()).resolve()
|
|
66
|
+
try:
|
|
67
|
+
resolved = path.resolve()
|
|
68
|
+
except (OSError, RuntimeError):
|
|
69
|
+
return PermissionDecision.DENY
|
|
70
|
+
|
|
71
|
+
writable_roots = {workspace, *[r.resolve() for r in self.config.policy.additional_writable_roots]}
|
|
72
|
+
read_only = {r.resolve() for r in self.config.policy.read_only_paths}
|
|
73
|
+
|
|
74
|
+
# Explicit read-only denylist still hard-denies — that list is the
|
|
75
|
+
# user's "never touch this" declaration, not a soft boundary.
|
|
76
|
+
if any(resolved == ro or ro in resolved.parents for ro in read_only):
|
|
77
|
+
return PermissionDecision.DENY
|
|
78
|
+
|
|
79
|
+
inside_writable = any(resolved == root or root in resolved.parents for root in writable_roots)
|
|
80
|
+
kind = ActionKind.FILE_WRITE if write else ActionKind.FILE_READ
|
|
81
|
+
if inside_writable:
|
|
82
|
+
return self.evaluate(kind, self.classify(kind))
|
|
83
|
+
# Outside the workspace: ask the user (yes/no) rather than hard-deny.
|
|
84
|
+
# Confinement stays the default (writable roots still frame what's
|
|
85
|
+
# "normal"), but a single approval prompt lets one-off cross-project
|
|
86
|
+
# reads / writes through without editing the config.
|
|
87
|
+
return PermissionDecision.PROMPT
|
|
88
|
+
|
|
89
|
+
def check_command(self, command: str) -> PermissionDecision:
|
|
90
|
+
tokens = command.strip().split()
|
|
91
|
+
if not tokens:
|
|
92
|
+
return PermissionDecision.DENY
|
|
93
|
+
head = tokens[0]
|
|
94
|
+
# Deny explicit blocked commands and dangerous shell metacharacter patterns.
|
|
95
|
+
if head in self.blocked_commands:
|
|
96
|
+
return PermissionDecision.DENY
|
|
97
|
+
if any(tok in self.blocked_commands for tok in tokens):
|
|
98
|
+
return PermissionDecision.DENY
|
|
99
|
+
if self.allowed_commands and head in self.allowed_commands:
|
|
100
|
+
return PermissionDecision.ALLOW
|
|
101
|
+
return self.evaluate(ActionKind.SHELL_EXEC, RiskLevel.HIGH)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Policy models: permission modes, decisions, risk classification.
|
|
2
|
+
|
|
3
|
+
Leaf module — no internal reidx dependencies. Referenced by config and policy.engine.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PermissionMode(StrEnum):
|
|
11
|
+
STRICT = "strict"
|
|
12
|
+
BALANCED = "balanced"
|
|
13
|
+
AUTONOMOUS = "autonomous"
|
|
14
|
+
CUSTOM = "custom"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PermissionDecision(StrEnum):
|
|
18
|
+
ALLOW = "allow"
|
|
19
|
+
DENY = "deny"
|
|
20
|
+
PROMPT = "prompt"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RiskLevel(StrEnum):
|
|
24
|
+
LOW = "low"
|
|
25
|
+
MEDIUM = "medium"
|
|
26
|
+
HIGH = "high"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ActionKind(StrEnum):
|
|
30
|
+
FILE_READ = "file_read"
|
|
31
|
+
FILE_WRITE = "file_write"
|
|
32
|
+
FILE_DELETE = "file_delete"
|
|
33
|
+
SHELL_EXEC = "shell_exec"
|
|
34
|
+
TOOL_CALL = "tool_call"
|
|
35
|
+
NETWORK = "network"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from reidx.provider.anthropic import AnthropicProvider
|
|
2
|
+
from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
|
|
3
|
+
from reidx.provider.registry import ProviderRegistry, default_registry
|
|
4
|
+
from reidx.provider.stub import StubProvider
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AnthropicProvider",
|
|
8
|
+
"BaseProvider",
|
|
9
|
+
"Message",
|
|
10
|
+
"ProviderResponse",
|
|
11
|
+
"ProviderRegistry",
|
|
12
|
+
"StubProvider",
|
|
13
|
+
"ToolCall",
|
|
14
|
+
"Usage",
|
|
15
|
+
"default_registry",
|
|
16
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared HTTP helper for provider clients — stdlib urllib, no extra deps."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
TIMEOUT_SECONDS = 120
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def post_json(url: str, payload: dict, headers: dict[str, str], timeout: int = TIMEOUT_SECONDS) -> dict:
|
|
12
|
+
"""POST a JSON payload, return the parsed JSON response.
|
|
13
|
+
|
|
14
|
+
Raises RuntimeError on HTTP or network errors — providers surface those
|
|
15
|
+
to the agent loop, which turns them into tool-result errors rather than
|
|
16
|
+
crashing the turn.
|
|
17
|
+
"""
|
|
18
|
+
body = json.dumps(payload).encode("utf-8")
|
|
19
|
+
hdrs = {"content-type": "application/json", **headers}
|
|
20
|
+
req = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
|
|
21
|
+
try:
|
|
22
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
23
|
+
raw = resp.read().decode("utf-8")
|
|
24
|
+
except urllib.error.HTTPError as exc:
|
|
25
|
+
err_body = exc.read().decode("utf-8", errors="replace")[:500]
|
|
26
|
+
raise RuntimeError(f"HTTP {exc.code}: {err_body}") from exc
|
|
27
|
+
except urllib.error.URLError as exc:
|
|
28
|
+
raise RuntimeError(f"connection error: {exc}") from exc
|
|
29
|
+
return json.loads(raw)
|