@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.
Files changed (135) hide show
  1. package/README.md +724 -0
  2. package/bin/reidx.js +33 -0
  3. package/package.json +40 -0
  4. package/pyproject.toml +45 -0
  5. package/scripts/find-python.js +24 -0
  6. package/scripts/postinstall.js +35 -0
  7. package/src/reidx/__init__.py +3 -0
  8. package/src/reidx/__main__.py +9 -0
  9. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  10. package/src/reidx/app/__init__.py +3 -0
  11. package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
  13. package/src/reidx/app/commands.py +221 -0
  14. package/src/reidx/automation/__init__.py +3 -0
  15. package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
  17. package/src/reidx/automation/exec.py +35 -0
  18. package/src/reidx/config/__init__.py +10 -0
  19. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  21. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  22. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  23. package/src/reidx/config/loader.py +104 -0
  24. package/src/reidx/config/models.py +49 -0
  25. package/src/reidx/config/settings.py +127 -0
  26. package/src/reidx/deepreid/__init__.py +8 -0
  27. package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
  29. package/src/reidx/deepreid/pipeline.py +221 -0
  30. package/src/reidx/diagnostics/__init__.py +0 -0
  31. package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
  33. package/src/reidx/diagnostics/logger.py +56 -0
  34. package/src/reidx/goals/__init__.py +21 -0
  35. package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
  37. package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
  38. package/src/reidx/goals/models.py +101 -0
  39. package/src/reidx/goals/store.py +233 -0
  40. package/src/reidx/integrations/__init__.py +3 -0
  41. package/src/reidx/integrations/mcp.py +70 -0
  42. package/src/reidx/nyx/__init__.py +3 -0
  43. package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
  45. package/src/reidx/nyx/persona.py +35 -0
  46. package/src/reidx/policy/__init__.py +15 -0
  47. package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
  48. package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
  49. package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
  50. package/src/reidx/policy/engine.py +101 -0
  51. package/src/reidx/policy/models.py +35 -0
  52. package/src/reidx/provider/__init__.py +16 -0
  53. package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  55. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  56. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  57. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  58. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  59. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  60. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  61. package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
  62. package/src/reidx/provider/_http.py +29 -0
  63. package/src/reidx/provider/anthropic.py +176 -0
  64. package/src/reidx/provider/base.py +50 -0
  65. package/src/reidx/provider/ollama.py +109 -0
  66. package/src/reidx/provider/openai.py +144 -0
  67. package/src/reidx/provider/registry.py +88 -0
  68. package/src/reidx/provider/store.py +141 -0
  69. package/src/reidx/provider/stub.py +60 -0
  70. package/src/reidx/provider_manager/__init__.py +40 -0
  71. package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
  73. package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
  74. package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
  75. package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
  76. package/src/reidx/provider_manager/catalog.py +330 -0
  77. package/src/reidx/provider_manager/database.py +234 -0
  78. package/src/reidx/provider_manager/keychain.py +102 -0
  79. package/src/reidx/provider_manager/palette.py +831 -0
  80. package/src/reidx/runtime/__init__.py +5 -0
  81. package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  82. package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
  83. package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
  84. package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
  85. package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
  86. package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
  87. package/src/reidx/runtime/agent.py +198 -0
  88. package/src/reidx/runtime/orchestrator.py +244 -0
  89. package/src/reidx/runtime/reasoning.py +77 -0
  90. package/src/reidx/runtime/state.py +32 -0
  91. package/src/reidx/runtime/subagent.py +174 -0
  92. package/src/reidx/session/__init__.py +4 -0
  93. package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
  94. package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
  95. package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
  96. package/src/reidx/session/models.py +43 -0
  97. package/src/reidx/session/store.py +101 -0
  98. package/src/reidx/tasks/__init__.py +4 -0
  99. package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  100. package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
  101. package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
  102. package/src/reidx/tasks/models.py +44 -0
  103. package/src/reidx/tasks/store.py +86 -0
  104. package/src/reidx/tools/__init__.py +44 -0
  105. package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
  107. package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  108. package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
  109. package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
  110. package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
  111. package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  112. package/src/reidx/tools/base.py +84 -0
  113. package/src/reidx/tools/file_tools.py +222 -0
  114. package/src/reidx/tools/registry.py +45 -0
  115. package/src/reidx/tools/shell_tool.py +75 -0
  116. package/src/reidx/tools/spawn_agent.py +185 -0
  117. package/src/reidx/tools/web_tools.py +216 -0
  118. package/src/reidx/ui/__init__.py +41 -0
  119. package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  120. package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
  121. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  122. package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
  123. package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
  124. package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
  125. package/src/reidx/ui/app.py +1343 -0
  126. package/src/reidx/ui/commands.py +663 -0
  127. package/src/reidx/ui/render.py +517 -0
  128. package/src/reidx/ui/repl.py +14 -0
  129. package/src/reidx/ui/theme.py +126 -0
  130. package/src/reidx/workflows/__init__.py +4 -0
  131. package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  132. package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
  133. package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
  134. package/src/reidx/workflows/models.py +19 -0
  135. 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,3 @@
1
+ from reidx.integrations.mcp import IntegrationRegistry, MCPIntegration, MCPServerConfig
2
+
3
+ __all__ = ["IntegrationRegistry", "MCPIntegration", "MCPServerConfig"]
@@ -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())
@@ -0,0 +1,3 @@
1
+ from reidx.nyx.persona import NYX_SYSTEM_PROMPT
2
+
3
+ __all__ = ["NYX_SYSTEM_PROMPT"]
@@ -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
+ ]
@@ -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
+ ]
@@ -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)