differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
tools/structure_index.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/structure_index.py
|
|
3
|
+
-------------------------
|
|
4
|
+
Byte-deterministic AST structure index for the VegeX codebase.
|
|
5
|
+
|
|
6
|
+
Per your directive (Override → Option 1), main.py will call rebuild() on
|
|
7
|
+
every validation step. That means freshness is guaranteed and the cost is
|
|
8
|
+
20–200ms of local CPU — cheap insurance against an LLM retry caused by a
|
|
9
|
+
stale index.
|
|
10
|
+
|
|
11
|
+
Public API:
|
|
12
|
+
- StructureIndex.rebuild(project_root, deprecations_path=None) -> StructureIndex
|
|
13
|
+
- index.save(path) / StructureIndex.load(path)
|
|
14
|
+
- index.fingerprint -> str # sha256 of all module content, for cache checks
|
|
15
|
+
- index.modules # Dict[module_name, ModuleInfo]
|
|
16
|
+
|
|
17
|
+
Determinism contract: two calls to rebuild() on unchanged source produce
|
|
18
|
+
byte-identical `save()` output *except* for the `generated_at` field. The
|
|
19
|
+
`fingerprint` field ignores `generated_at`, so cache comparisons use it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import ast
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field, asdict
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
SCHEMA_VERSION = "2.0.0"
|
|
35
|
+
|
|
36
|
+
# Files we never index.
|
|
37
|
+
_IGNORED_DIRS = {
|
|
38
|
+
".git", ".vegex", "__pycache__", ".pytest_cache", ".mypy_cache",
|
|
39
|
+
".venv", "venv", "env", "node_modules", "bin", "obj", "build",
|
|
40
|
+
"dist", ".idea", ".vscode", "iteration_logs", "AI_Orchestrator_Backups",
|
|
41
|
+
}
|
|
42
|
+
_IGNORED_FILE_SUFFIXES = (".pyc", ".pyo", ".pyd", ".so", ".dll")
|
|
43
|
+
_IGNORED_FILENAMES = {
|
|
44
|
+
"state.json", "structure_meta.json", "projects_registry.json",
|
|
45
|
+
"skills_config.json", "active_models.json", "models_cache.json",
|
|
46
|
+
".agent_bindings.json", ".openrouter_models_cache.json",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Data classes
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
@dataclass
|
|
54
|
+
class ParamInfo:
|
|
55
|
+
name: str
|
|
56
|
+
kind: str # positional_only|positional_or_keyword|keyword_only|var_positional|var_keyword
|
|
57
|
+
annotation: Optional[str] = None
|
|
58
|
+
default: Optional[str] = None
|
|
59
|
+
has_default: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class FunctionInfo:
|
|
64
|
+
name: str
|
|
65
|
+
params: List[ParamInfo] = field(default_factory=list)
|
|
66
|
+
returns: Optional[str] = None
|
|
67
|
+
decorators: List[str] = field(default_factory=list)
|
|
68
|
+
is_async: bool = False
|
|
69
|
+
is_static: bool = False
|
|
70
|
+
is_class: bool = False
|
|
71
|
+
is_property: bool = False
|
|
72
|
+
line: int = 0
|
|
73
|
+
has_var_positional: bool = False
|
|
74
|
+
has_var_keyword: bool = False
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def positional_params(self) -> List[ParamInfo]:
|
|
78
|
+
return [
|
|
79
|
+
p for p in self.params
|
|
80
|
+
if p.kind in ("positional_only", "positional_or_keyword")
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def keyword_params(self) -> List[ParamInfo]:
|
|
85
|
+
return [
|
|
86
|
+
p for p in self.params
|
|
87
|
+
if p.kind in ("positional_or_keyword", "keyword_only")
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ClassInfo:
|
|
93
|
+
name: str
|
|
94
|
+
bases: List[str] = field(default_factory=list)
|
|
95
|
+
methods: Dict[str, FunctionInfo] = field(default_factory=dict)
|
|
96
|
+
line: int = 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class ImportInfo:
|
|
101
|
+
module: str = "" # e.g. "memory.storage"
|
|
102
|
+
names: List[str] = field(default_factory=list) # imported symbols
|
|
103
|
+
alias_of_names: Dict[str, str] = field(default_factory=dict) # local alias -> original
|
|
104
|
+
level: int = 0 # 0 = absolute, 1+ = relative
|
|
105
|
+
inside_try: bool = False
|
|
106
|
+
type_checking_only: bool = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class ModuleInfo:
|
|
111
|
+
name: str # canonical dotted module name
|
|
112
|
+
file: str # relative path with forward slashes
|
|
113
|
+
content_hash: str
|
|
114
|
+
classes: Dict[str, ClassInfo] = field(default_factory=dict)
|
|
115
|
+
functions: Dict[str, FunctionInfo] = field(default_factory=dict)
|
|
116
|
+
imports: List[ImportInfo] = field(default_factory=list)
|
|
117
|
+
top_level_names: List[str] = field(default_factory=list)
|
|
118
|
+
syntax_error: Optional[str] = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# AST extraction
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
def _annotation_to_str(node: Optional[ast.AST]) -> Optional[str]:
|
|
125
|
+
if node is None:
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
return ast.unparse(node)
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_params(func: ast.FunctionDef | ast.AsyncFunctionDef) -> Tuple[List[ParamInfo], bool, bool]:
|
|
134
|
+
args = func.args
|
|
135
|
+
out: List[ParamInfo] = []
|
|
136
|
+
has_var_pos = args.vararg is not None
|
|
137
|
+
has_var_kw = args.kwarg is not None
|
|
138
|
+
|
|
139
|
+
# positional-only
|
|
140
|
+
for a in args.posonlyargs:
|
|
141
|
+
out.append(ParamInfo(name=a.arg, kind="positional_only",
|
|
142
|
+
annotation=_annotation_to_str(a.annotation)))
|
|
143
|
+
# positional-or-keyword
|
|
144
|
+
pos_args = list(args.args)
|
|
145
|
+
defaults = list(args.defaults)
|
|
146
|
+
num_defaults = len(defaults)
|
|
147
|
+
for i, a in enumerate(pos_args):
|
|
148
|
+
default_idx = i - (len(pos_args) - num_defaults)
|
|
149
|
+
has_default = default_idx >= 0
|
|
150
|
+
out.append(ParamInfo(
|
|
151
|
+
name=a.arg,
|
|
152
|
+
kind="positional_or_keyword",
|
|
153
|
+
annotation=_annotation_to_str(a.annotation),
|
|
154
|
+
default=_annotation_to_str(defaults[default_idx]) if has_default else None,
|
|
155
|
+
has_default=has_default,
|
|
156
|
+
))
|
|
157
|
+
# var-positional
|
|
158
|
+
if args.vararg is not None:
|
|
159
|
+
out.append(ParamInfo(name=args.vararg.arg, kind="var_positional",
|
|
160
|
+
annotation=_annotation_to_str(args.vararg.annotation)))
|
|
161
|
+
# keyword-only
|
|
162
|
+
for i, a in enumerate(args.kwonlyargs):
|
|
163
|
+
kw_default = args.kw_defaults[i] if i < len(args.kw_defaults) else None
|
|
164
|
+
out.append(ParamInfo(
|
|
165
|
+
name=a.arg, kind="keyword_only",
|
|
166
|
+
annotation=_annotation_to_str(a.annotation),
|
|
167
|
+
default=_annotation_to_str(kw_default) if kw_default is not None else None,
|
|
168
|
+
has_default=kw_default is not None,
|
|
169
|
+
))
|
|
170
|
+
# var-keyword
|
|
171
|
+
if args.kwarg is not None:
|
|
172
|
+
out.append(ParamInfo(name=args.kwarg.arg, kind="var_keyword",
|
|
173
|
+
annotation=_annotation_to_str(args.kwarg.annotation)))
|
|
174
|
+
|
|
175
|
+
return out, has_var_pos, has_var_kw
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_decorators(node: ast.FunctionDef | ast.AsyncFunctionDef) -> List[str]:
|
|
179
|
+
return [_annotation_to_str(d) or "" for d in node.decorator_list]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _extract_function(node, *, is_async: bool, class_context: Optional[str] = None) -> FunctionInfo:
|
|
183
|
+
params, has_vp, has_vk = _extract_params(node)
|
|
184
|
+
decorators = _extract_decorators(node)
|
|
185
|
+
return FunctionInfo(
|
|
186
|
+
name=node.name,
|
|
187
|
+
params=params,
|
|
188
|
+
returns=_annotation_to_str(node.returns),
|
|
189
|
+
decorators=decorators,
|
|
190
|
+
is_async=is_async,
|
|
191
|
+
is_static=any("staticmethod" in d for d in decorators),
|
|
192
|
+
is_class=any("classmethod" in d for d in decorators),
|
|
193
|
+
is_property=any("property" in d for d in decorators),
|
|
194
|
+
line=node.lineno,
|
|
195
|
+
has_var_positional=has_vp,
|
|
196
|
+
has_var_keyword=has_vk,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _extract_imports(tree: ast.Module) -> List[ImportInfo]:
|
|
201
|
+
out: List[ImportInfo] = []
|
|
202
|
+
|
|
203
|
+
def visit(node: ast.AST, inside_try: bool, type_checking_only: bool):
|
|
204
|
+
for child in ast.iter_child_nodes(node):
|
|
205
|
+
if isinstance(child, ast.Import):
|
|
206
|
+
for alias in child.names:
|
|
207
|
+
info = ImportInfo(
|
|
208
|
+
module=alias.name,
|
|
209
|
+
names=[],
|
|
210
|
+
alias_of_names={alias.asname or alias.name.split(".")[0]: alias.name},
|
|
211
|
+
level=0,
|
|
212
|
+
inside_try=inside_try,
|
|
213
|
+
type_checking_only=type_checking_only,
|
|
214
|
+
)
|
|
215
|
+
out.append(info)
|
|
216
|
+
elif isinstance(child, ast.ImportFrom):
|
|
217
|
+
module = child.module or ""
|
|
218
|
+
names = [a.name for a in child.names]
|
|
219
|
+
alias_map = {(a.asname or a.name): a.name for a in child.names}
|
|
220
|
+
out.append(ImportInfo(
|
|
221
|
+
module=module,
|
|
222
|
+
names=names,
|
|
223
|
+
alias_of_names=alias_map,
|
|
224
|
+
level=child.level or 0,
|
|
225
|
+
inside_try=inside_try,
|
|
226
|
+
type_checking_only=type_checking_only,
|
|
227
|
+
))
|
|
228
|
+
elif isinstance(child, ast.Try):
|
|
229
|
+
for stmt in child.body:
|
|
230
|
+
visit(stmt, True, type_checking_only)
|
|
231
|
+
for handler in child.handlers:
|
|
232
|
+
for stmt in handler.body:
|
|
233
|
+
visit(stmt, True, type_checking_only)
|
|
234
|
+
for stmt in child.orelse:
|
|
235
|
+
visit(stmt, inside_try, type_checking_only)
|
|
236
|
+
for stmt in child.finalbody:
|
|
237
|
+
visit(stmt, inside_try, type_checking_only)
|
|
238
|
+
elif isinstance(child, ast.If):
|
|
239
|
+
is_type_checking = _is_type_checking_guard(child.test)
|
|
240
|
+
for stmt in child.body:
|
|
241
|
+
visit(stmt, inside_try, type_checking_only or is_type_checking)
|
|
242
|
+
for stmt in child.orelse:
|
|
243
|
+
visit(stmt, inside_try, type_checking_only)
|
|
244
|
+
|
|
245
|
+
visit(tree, False, False)
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_type_checking_guard(test: ast.AST) -> bool:
|
|
250
|
+
if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
|
|
251
|
+
return True
|
|
252
|
+
if isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING":
|
|
253
|
+
return True
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _extract_module(
|
|
258
|
+
file_path: Path, rel_path: str, module_name: str
|
|
259
|
+
) -> ModuleInfo:
|
|
260
|
+
try:
|
|
261
|
+
raw = file_path.read_bytes()
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
return ModuleInfo(
|
|
264
|
+
name=module_name, file=rel_path,
|
|
265
|
+
content_hash="", syntax_error=f"read failed: {exc}",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
content_hash = hashlib.sha256(raw).hexdigest()
|
|
269
|
+
try:
|
|
270
|
+
source = raw.decode("utf-8")
|
|
271
|
+
except UnicodeDecodeError as exc:
|
|
272
|
+
return ModuleInfo(
|
|
273
|
+
name=module_name, file=rel_path,
|
|
274
|
+
content_hash=content_hash, syntax_error=f"decode failed: {exc}",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
tree = ast.parse(source, filename=rel_path)
|
|
279
|
+
except SyntaxError as exc:
|
|
280
|
+
return ModuleInfo(
|
|
281
|
+
name=module_name, file=rel_path,
|
|
282
|
+
content_hash=content_hash,
|
|
283
|
+
syntax_error=f"line {exc.lineno}: {exc.msg}",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
info = ModuleInfo(name=module_name, file=rel_path, content_hash=content_hash)
|
|
287
|
+
|
|
288
|
+
for node in tree.body:
|
|
289
|
+
if isinstance(node, ast.ClassDef):
|
|
290
|
+
cls = ClassInfo(
|
|
291
|
+
name=node.name,
|
|
292
|
+
bases=[_annotation_to_str(b) or "" for b in node.bases],
|
|
293
|
+
line=node.lineno,
|
|
294
|
+
)
|
|
295
|
+
for item in node.body:
|
|
296
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
297
|
+
cls.methods[item.name] = _extract_function(
|
|
298
|
+
item,
|
|
299
|
+
is_async=isinstance(item, ast.AsyncFunctionDef),
|
|
300
|
+
class_context=node.name,
|
|
301
|
+
)
|
|
302
|
+
info.classes[node.name] = cls
|
|
303
|
+
info.top_level_names.append(node.name)
|
|
304
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
305
|
+
info.functions[node.name] = _extract_function(
|
|
306
|
+
node, is_async=isinstance(node, ast.AsyncFunctionDef)
|
|
307
|
+
)
|
|
308
|
+
info.top_level_names.append(node.name)
|
|
309
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
310
|
+
for target in _assign_targets(node):
|
|
311
|
+
if isinstance(target, ast.Name):
|
|
312
|
+
info.top_level_names.append(target.id)
|
|
313
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
314
|
+
pass # handled by _extract_imports
|
|
315
|
+
|
|
316
|
+
info.imports = _extract_imports(tree)
|
|
317
|
+
return info
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _assign_targets(node) -> List[ast.AST]:
|
|
321
|
+
targets: List[ast.AST] = []
|
|
322
|
+
if isinstance(node, ast.Assign):
|
|
323
|
+
for t in node.targets:
|
|
324
|
+
if isinstance(t, (ast.Tuple, ast.List)):
|
|
325
|
+
targets.extend(t.elts)
|
|
326
|
+
else:
|
|
327
|
+
targets.append(t)
|
|
328
|
+
elif isinstance(node, ast.AnnAssign):
|
|
329
|
+
targets.append(node.target)
|
|
330
|
+
return targets
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# Path → module name
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
def _path_to_module(rel_path: str) -> str:
|
|
337
|
+
p = rel_path.replace("\\", "/")
|
|
338
|
+
if p.endswith(".py"):
|
|
339
|
+
p = p[:-3]
|
|
340
|
+
if p.endswith("/__init__"):
|
|
341
|
+
p = p[: -len("/__init__")]
|
|
342
|
+
return p.replace("/", ".")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
# StructureIndex
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
@dataclass
|
|
349
|
+
class StructureIndex:
|
|
350
|
+
schema_version: str = SCHEMA_VERSION
|
|
351
|
+
generated_at: str = ""
|
|
352
|
+
project_root_hash: str = ""
|
|
353
|
+
modules: Dict[str, ModuleInfo] = field(default_factory=dict)
|
|
354
|
+
|
|
355
|
+
# ------------------------------------------------------------------ #
|
|
356
|
+
# Build / load / save
|
|
357
|
+
# ------------------------------------------------------------------ #
|
|
358
|
+
@classmethod
|
|
359
|
+
def rebuild(cls, project_root: str) -> "StructureIndex":
|
|
360
|
+
root = Path(project_root).resolve()
|
|
361
|
+
idx = cls(generated_at=_utc_now_iso())
|
|
362
|
+
|
|
363
|
+
files = list(_iter_python_files(root))
|
|
364
|
+
combined_hasher = hashlib.sha256()
|
|
365
|
+
|
|
366
|
+
for abs_path in sorted(files):
|
|
367
|
+
rel = abs_path.relative_to(root).as_posix()
|
|
368
|
+
module_name = _path_to_module(rel)
|
|
369
|
+
mod_info = _extract_module(abs_path, rel, module_name)
|
|
370
|
+
idx.modules[module_name] = mod_info
|
|
371
|
+
combined_hasher.update(rel.encode("utf-8"))
|
|
372
|
+
combined_hasher.update(b"\0")
|
|
373
|
+
combined_hasher.update(mod_info.content_hash.encode("utf-8"))
|
|
374
|
+
combined_hasher.update(b"\n")
|
|
375
|
+
|
|
376
|
+
idx.project_root_hash = combined_hasher.hexdigest()
|
|
377
|
+
return idx
|
|
378
|
+
|
|
379
|
+
def save(self, path: str) -> None:
|
|
380
|
+
target = Path(path)
|
|
381
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
383
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
384
|
+
json.dump(self.to_dict(), f, sort_keys=True, indent=2)
|
|
385
|
+
os.replace(tmp, target)
|
|
386
|
+
|
|
387
|
+
@classmethod
|
|
388
|
+
def load(cls, path: str) -> Optional["StructureIndex"]:
|
|
389
|
+
target = Path(path)
|
|
390
|
+
if not target.exists():
|
|
391
|
+
return None
|
|
392
|
+
try:
|
|
393
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
394
|
+
data = json.load(f)
|
|
395
|
+
except Exception:
|
|
396
|
+
return None
|
|
397
|
+
return cls.from_dict(data)
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------ #
|
|
400
|
+
# Serialization (deterministic)
|
|
401
|
+
# ------------------------------------------------------------------ #
|
|
402
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
403
|
+
return {
|
|
404
|
+
"schema_version": self.schema_version,
|
|
405
|
+
"generated_at": self.generated_at,
|
|
406
|
+
"project_root_hash": self.project_root_hash,
|
|
407
|
+
"fingerprint": self.fingerprint,
|
|
408
|
+
"modules": {
|
|
409
|
+
name: _module_to_dict(mod)
|
|
410
|
+
for name, mod in sorted(self.modules.items())
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def from_dict(cls, data: Dict[str, Any]) -> "StructureIndex":
|
|
416
|
+
idx = cls(
|
|
417
|
+
schema_version=data.get("schema_version", SCHEMA_VERSION),
|
|
418
|
+
generated_at=data.get("generated_at", ""),
|
|
419
|
+
project_root_hash=data.get("project_root_hash", ""),
|
|
420
|
+
)
|
|
421
|
+
for name, mod_dict in (data.get("modules") or {}).items():
|
|
422
|
+
idx.modules[name] = _module_from_dict(mod_dict)
|
|
423
|
+
return idx
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def fingerprint(self) -> str:
|
|
427
|
+
"""
|
|
428
|
+
Fingerprint that ignores generated_at so identical source → identical
|
|
429
|
+
fingerprint. Use this for prompt-cache checks.
|
|
430
|
+
"""
|
|
431
|
+
h = hashlib.sha256()
|
|
432
|
+
h.update(self.project_root_hash.encode("utf-8"))
|
|
433
|
+
h.update(b"\0")
|
|
434
|
+
h.update(SCHEMA_VERSION.encode("utf-8"))
|
|
435
|
+
return h.hexdigest()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# Deterministic file walking
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
def _iter_python_files(root: Path):
|
|
442
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
443
|
+
dirnames[:] = sorted(
|
|
444
|
+
d for d in dirnames
|
|
445
|
+
if d not in _IGNORED_DIRS and not d.startswith(".")
|
|
446
|
+
)
|
|
447
|
+
for fname in sorted(filenames):
|
|
448
|
+
if not fname.endswith(".py"):
|
|
449
|
+
continue
|
|
450
|
+
if fname in _IGNORED_FILENAMES:
|
|
451
|
+
continue
|
|
452
|
+
if any(fname.endswith(sfx) for sfx in _IGNORED_FILE_SUFFIXES):
|
|
453
|
+
continue
|
|
454
|
+
yield Path(dirpath) / fname
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
# Serialization helpers
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
def _module_to_dict(mod: ModuleInfo) -> Dict[str, Any]:
|
|
461
|
+
return {
|
|
462
|
+
"file": mod.file,
|
|
463
|
+
"content_hash": mod.content_hash,
|
|
464
|
+
"syntax_error": mod.syntax_error,
|
|
465
|
+
"top_level_names": sorted(set(mod.top_level_names)),
|
|
466
|
+
"classes": {
|
|
467
|
+
name: {
|
|
468
|
+
"name": c.name,
|
|
469
|
+
"bases": c.bases,
|
|
470
|
+
"line": c.line,
|
|
471
|
+
"methods": {mname: _func_to_dict(m) for mname, m in sorted(c.methods.items())},
|
|
472
|
+
}
|
|
473
|
+
for name, c in sorted(mod.classes.items())
|
|
474
|
+
},
|
|
475
|
+
"functions": {
|
|
476
|
+
name: _func_to_dict(f) for name, f in sorted(mod.functions.items())
|
|
477
|
+
},
|
|
478
|
+
"imports": [
|
|
479
|
+
{
|
|
480
|
+
"module": imp.module,
|
|
481
|
+
"names": sorted(imp.names),
|
|
482
|
+
"alias_of_names": dict(sorted(imp.alias_of_names.items())),
|
|
483
|
+
"level": imp.level,
|
|
484
|
+
"inside_try": imp.inside_try,
|
|
485
|
+
"type_checking_only": imp.type_checking_only,
|
|
486
|
+
}
|
|
487
|
+
for imp in mod.imports
|
|
488
|
+
],
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _module_from_dict(d: Dict[str, Any]) -> ModuleInfo:
|
|
493
|
+
mod = ModuleInfo(
|
|
494
|
+
name=d.get("name", ""),
|
|
495
|
+
file=d.get("file", ""),
|
|
496
|
+
content_hash=d.get("content_hash", ""),
|
|
497
|
+
syntax_error=d.get("syntax_error"),
|
|
498
|
+
top_level_names=list(d.get("top_level_names", [])),
|
|
499
|
+
)
|
|
500
|
+
for name, cd in (d.get("classes") or {}).items():
|
|
501
|
+
cls = ClassInfo(name=cd.get("name", name),
|
|
502
|
+
bases=list(cd.get("bases", [])),
|
|
503
|
+
line=cd.get("line", 0))
|
|
504
|
+
for mname, md in (cd.get("methods") or {}).items():
|
|
505
|
+
cls.methods[mname] = _func_from_dict(md)
|
|
506
|
+
mod.classes[name] = cls
|
|
507
|
+
for name, fd in (d.get("functions") or {}).items():
|
|
508
|
+
mod.functions[name] = _func_from_dict(fd)
|
|
509
|
+
for imp in (d.get("imports") or []):
|
|
510
|
+
mod.imports.append(ImportInfo(
|
|
511
|
+
module=imp.get("module", ""),
|
|
512
|
+
names=list(imp.get("names", [])),
|
|
513
|
+
alias_of_names=dict(imp.get("alias_of_names", {})),
|
|
514
|
+
level=imp.get("level", 0),
|
|
515
|
+
inside_try=imp.get("inside_try", False),
|
|
516
|
+
type_checking_only=imp.get("type_checking_only", False),
|
|
517
|
+
))
|
|
518
|
+
return mod
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _func_to_dict(f: FunctionInfo) -> Dict[str, Any]:
|
|
522
|
+
return {
|
|
523
|
+
"name": f.name,
|
|
524
|
+
"params": [
|
|
525
|
+
{"name": p.name, "kind": p.kind,
|
|
526
|
+
"annotation": p.annotation, "default": p.default,
|
|
527
|
+
"has_default": p.has_default}
|
|
528
|
+
for p in f.params
|
|
529
|
+
],
|
|
530
|
+
"returns": f.returns,
|
|
531
|
+
"decorators": f.decorators,
|
|
532
|
+
"is_async": f.is_async,
|
|
533
|
+
"is_static": f.is_static,
|
|
534
|
+
"is_class": f.is_class,
|
|
535
|
+
"is_property": f.is_property,
|
|
536
|
+
"line": f.line,
|
|
537
|
+
"has_var_positional": f.has_var_positional,
|
|
538
|
+
"has_var_keyword": f.has_var_keyword,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _func_from_dict(d: Dict[str, Any]) -> FunctionInfo:
|
|
543
|
+
return FunctionInfo(
|
|
544
|
+
name=d.get("name", ""),
|
|
545
|
+
params=[ParamInfo(**p) for p in d.get("params", [])],
|
|
546
|
+
returns=d.get("returns"),
|
|
547
|
+
decorators=list(d.get("decorators", [])),
|
|
548
|
+
is_async=d.get("is_async", False),
|
|
549
|
+
is_static=d.get("is_static", False),
|
|
550
|
+
is_class=d.get("is_class", False),
|
|
551
|
+
is_property=d.get("is_property", False),
|
|
552
|
+
line=d.get("line", 0),
|
|
553
|
+
has_var_positional=d.get("has_var_positional", False),
|
|
554
|
+
has_var_keyword=d.get("has_var_keyword", False),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _utc_now_iso() -> str:
|
|
559
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
__all__ = [
|
|
563
|
+
"StructureIndex",
|
|
564
|
+
"ModuleInfo",
|
|
565
|
+
"ClassInfo",
|
|
566
|
+
"FunctionInfo",
|
|
567
|
+
"ParamInfo",
|
|
568
|
+
"ImportInfo",
|
|
569
|
+
"SCHEMA_VERSION",
|
|
570
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import pytest
|
|
3
|
+
from tools.state_manager import ExecutionControl
|
|
4
|
+
|
|
5
|
+
@pytest.mark.unit
|
|
6
|
+
def test_all_steps_in_main_are_valid_literals():
|
|
7
|
+
# Читаем main.py и ищем все строки вида step == "something"
|
|
8
|
+
with open("main.py", "r", encoding="utf-8") as f:
|
|
9
|
+
main_code = f.read()
|
|
10
|
+
|
|
11
|
+
used_steps = set(re.findall(r'step\s*==\s*"([^"]+)"', main_code))
|
|
12
|
+
# Получаем допустимые шаги из Pydantic Literal
|
|
13
|
+
allowed_steps = set(ExecutionControl.model_fields["current_step"].annotation.__args__)
|
|
14
|
+
|
|
15
|
+
# Игнорируем "done" и "completed", так как они часто обрабатываются вместе
|
|
16
|
+
used_steps = {s for s in used_steps if s not in ("done", "completed")}
|
|
17
|
+
|
|
18
|
+
missing = used_steps - allowed_steps
|
|
19
|
+
assert not missing, f"Steps used in main.py but not in ExecutionControl Literal: {missing}"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/security/verification_firewall.py
|
|
3
|
+
----------------------------------------
|
|
4
|
+
Agent self-reports are ADVISORY. Only external signals are AUTHORITATIVE.
|
|
5
|
+
|
|
6
|
+
Problem this solves (August 2026 incident):
|
|
7
|
+
An agent in a loop hallucinated "test passed" and the pipeline believed it,
|
|
8
|
+
because `state.get("compiler_output")` was populated by the agent itself.
|
|
9
|
+
The agent then spent tokens on simulated work instead of real work.
|
|
10
|
+
|
|
11
|
+
Solution:
|
|
12
|
+
build_success MUST come from run_universal_build(), never from state.
|
|
13
|
+
apply_result.ok MUST be verified via fs-hash before/after, not trusted.
|
|
14
|
+
Anything the agent claims about success must be cross-checked.
|
|
15
|
+
|
|
16
|
+
This module provides those cross-checks.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fs_hash(path: str) -> str:
|
|
25
|
+
"""SHA-256 of file content, or empty string if file doesn't exist."""
|
|
26
|
+
if not os.path.exists(path):
|
|
27
|
+
return ""
|
|
28
|
+
try:
|
|
29
|
+
with open(path, "rb") as f:
|
|
30
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
31
|
+
except OSError:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def verify_file_changed(path: str, before_hash: str) -> tuple[bool, str]:
|
|
36
|
+
"""
|
|
37
|
+
Verify that the file actually changed after the agent claimed a write.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
(changed, current_hash)
|
|
41
|
+
"""
|
|
42
|
+
after_hash = fs_hash(path)
|
|
43
|
+
changed = (after_hash != before_hash)
|
|
44
|
+
return changed, after_hash
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify_compiler_output(
|
|
48
|
+
compiler_output_from_state: str,
|
|
49
|
+
actual_compiler_output: str,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Cross-check what state says vs what the compiler actually said.
|
|
53
|
+
|
|
54
|
+
In a healthy run, these match. In a confused-deputy attack, they diverge.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
{"match": bool, "state_claimed": str, "actual": str, "tampered": bool}
|
|
58
|
+
"""
|
|
59
|
+
# Normalize whitespace for comparison
|
|
60
|
+
state_norm = (compiler_output_from_state or "").strip()
|
|
61
|
+
actual_norm = (actual_compiler_output or "").strip()
|
|
62
|
+
|
|
63
|
+
match = state_norm == actual_norm
|
|
64
|
+
tampered = bool(state_norm) and bool(actual_norm) and not match
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"match": match,
|
|
68
|
+
"state_claimed": state_norm[:200],
|
|
69
|
+
"actual": actual_norm[:200],
|
|
70
|
+
"tampered": tampered,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_build_result(
|
|
75
|
+
claimed_success: bool,
|
|
76
|
+
actual_success: bool,
|
|
77
|
+
actual_log: str,
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""
|
|
80
|
+
Cross-check what state/build_success says vs what run_universal_build returned.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
{"consistent": bool, "trusted_success": bool, "warnings": [str]}
|
|
84
|
+
"""
|
|
85
|
+
warnings = []
|
|
86
|
+
|
|
87
|
+
if claimed_success != actual_success:
|
|
88
|
+
warnings.append(
|
|
89
|
+
f"build_success mismatch: state={claimed_success} "
|
|
90
|
+
f"but run_universal_build={actual_success}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
trusted_success = actual_success # Only trust run_universal_build
|
|
94
|
+
|
|
95
|
+
if claimed_success and not actual_success:
|
|
96
|
+
warnings.append(
|
|
97
|
+
"AGENT CLAIMED SUCCESS BUT COMPILER FAILED. "
|
|
98
|
+
"This is the 'simulated test passed' incident pattern."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"consistent": claimed_success == actual_success,
|
|
103
|
+
"trusted_success": trusted_success,
|
|
104
|
+
"warnings": warnings,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = [
|
|
109
|
+
"fs_hash",
|
|
110
|
+
"verify_file_changed",
|
|
111
|
+
"verify_compiler_output",
|
|
112
|
+
"verify_build_result",
|
|
113
|
+
]
|