execweave 0.6.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.
- execweave/__init__.py +3 -0
- execweave/__main__.py +5 -0
- execweave/analysis.py +406 -0
- execweave/backends.py +63 -0
- execweave/benchmark.py +80 -0
- execweave/claude_adapter.py +448 -0
- execweave/claude_hook_cli.py +101 -0
- execweave/claude_record.py +106 -0
- execweave/cli.py +588 -0
- execweave/codex_adapter.py +314 -0
- execweave/codex_hook_cli.py +98 -0
- execweave/codex_record.py +111 -0
- execweave/collector.py +301 -0
- execweave/correlation.py +604 -0
- execweave/cursor_adapter.py +347 -0
- execweave/cursor_hook_cli.py +82 -0
- execweave/cursor_record.py +96 -0
- execweave/filesystem.py +103 -0
- execweave/focus.py +118 -0
- execweave/gemini_adapter.py +265 -0
- execweave/gemini_hook_cli.py +77 -0
- execweave/gemini_record.py +94 -0
- execweave/graph.py +300 -0
- execweave/graph_ops.py +446 -0
- execweave/inference_gateway.py +422 -0
- execweave/inference_gateway_cli.py +106 -0
- execweave/inference_identity.py +76 -0
- execweave/inference_identity_cli.py +60 -0
- execweave/live.py +275 -0
- execweave/model_runtime.py +535 -0
- execweave/model_runtime_cli.py +154 -0
- execweave/opencode_adapter.py +316 -0
- execweave/opencode_hook_cli.py +57 -0
- execweave/opencode_plugin_cli.py +110 -0
- execweave/opencode_record.py +96 -0
- execweave/overhead_benchmark.py +440 -0
- execweave/provider_record.py +215 -0
- execweave/schema.py +62 -0
- execweave/semantic.py +346 -0
- execweave/sink.py +33 -0
- execweave/strace_backend.py +682 -0
- execweave/validate.py +193 -0
- execweave/viewer.py +283 -0
- execweave/workflow.py +114 -0
- execweave-0.6.0.dist-info/METADATA +356 -0
- execweave-0.6.0.dist-info/RECORD +49 -0
- execweave-0.6.0.dist-info/WHEEL +4 -0
- execweave-0.6.0.dist-info/entry_points.txt +17 -0
- execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_MAX_COMMAND_CHARS = 4096
|
|
13
|
+
_MAX_LABEL_CHARS = 160
|
|
14
|
+
_SUPPORTED_EVENTS = {"sessionStart", "preToolUse", "postToolUse", "postToolUseFailure"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _now() -> str:
|
|
18
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _entity(
|
|
22
|
+
entity_type: str,
|
|
23
|
+
entity_id: str,
|
|
24
|
+
*,
|
|
25
|
+
name: str | None = None,
|
|
26
|
+
attributes: dict[str, Any] | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
return {"type": entity_type, "id": entity_id, "name": name, "attributes": attributes or {}}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _event(
|
|
32
|
+
*,
|
|
33
|
+
timestamp: str,
|
|
34
|
+
event_type: str,
|
|
35
|
+
relation: str,
|
|
36
|
+
source: dict[str, Any],
|
|
37
|
+
target: dict[str, Any],
|
|
38
|
+
attributes: dict[str, Any] | None = None,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
merged = {
|
|
41
|
+
"backend": "semantic",
|
|
42
|
+
"attribution": "cursor_hook",
|
|
43
|
+
"evidence_source": "provider_hook",
|
|
44
|
+
"provider": "cursor",
|
|
45
|
+
"causal": False,
|
|
46
|
+
}
|
|
47
|
+
if attributes:
|
|
48
|
+
merged.update(attributes)
|
|
49
|
+
return {
|
|
50
|
+
"timestamp": timestamp,
|
|
51
|
+
"event_type": event_type,
|
|
52
|
+
"relation": relation,
|
|
53
|
+
"source": source,
|
|
54
|
+
"target": target,
|
|
55
|
+
"attributes": merged,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean_text(value: object, *, limit: int) -> tuple[str | None, bool]:
|
|
60
|
+
if not isinstance(value, str):
|
|
61
|
+
return None, False
|
|
62
|
+
text = value.replace("\x00", "")
|
|
63
|
+
return (text, False) if len(text) <= limit else (text[:limit], True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _agent() -> dict[str, Any]:
|
|
67
|
+
return _entity("agent", "agent:Cursor", name="Cursor")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _scope(payload: dict[str, Any]) -> str:
|
|
71
|
+
for key in ("conversation_id", "session_id", "generation_id"):
|
|
72
|
+
value = payload.get(key)
|
|
73
|
+
if isinstance(value, str) and value:
|
|
74
|
+
return value
|
|
75
|
+
return "unknown"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _common_attributes(payload: dict[str, Any]) -> dict[str, Any]:
|
|
79
|
+
result: dict[str, Any] = {}
|
|
80
|
+
for key in (
|
|
81
|
+
"conversation_id",
|
|
82
|
+
"generation_id",
|
|
83
|
+
"session_id",
|
|
84
|
+
"cursor_version",
|
|
85
|
+
"cwd",
|
|
86
|
+
"model",
|
|
87
|
+
"model_id",
|
|
88
|
+
):
|
|
89
|
+
value = payload.get(key)
|
|
90
|
+
if isinstance(value, (str, int, float, bool)) and value != "":
|
|
91
|
+
result[f"cursor_{key}"] = value
|
|
92
|
+
roots = payload.get("workspace_roots")
|
|
93
|
+
if isinstance(roots, list):
|
|
94
|
+
result["cursor_workspace_root_count"] = len(roots)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _tool_entity(tool_name: str) -> dict[str, Any]:
|
|
99
|
+
if tool_name.startswith("MCP:"):
|
|
100
|
+
native = tool_name[4:] or tool_name
|
|
101
|
+
return _entity(
|
|
102
|
+
"tool",
|
|
103
|
+
f"tool:cursor:mcp:{native}",
|
|
104
|
+
name=native,
|
|
105
|
+
attributes={
|
|
106
|
+
"provider": "cursor",
|
|
107
|
+
"native_name": tool_name,
|
|
108
|
+
"mcp_tool": True,
|
|
109
|
+
"mcp_server_identity_available": False,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
return _entity(
|
|
113
|
+
"tool",
|
|
114
|
+
f"tool:cursor:{tool_name}",
|
|
115
|
+
name=tool_name,
|
|
116
|
+
attributes={"provider": "cursor", "native_name": tool_name},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _tool_call_entity(payload: dict[str, Any], tool_name: str) -> dict[str, Any]:
|
|
121
|
+
tool_use_id = payload.get("tool_use_id")
|
|
122
|
+
if not isinstance(tool_use_id, str) or not tool_use_id:
|
|
123
|
+
raise ValueError("Cursor tool hook requires tool_use_id")
|
|
124
|
+
attrs = _common_attributes(payload)
|
|
125
|
+
attrs.update({"provider": "cursor", "tool_name": tool_name, "tool_use_id": tool_use_id})
|
|
126
|
+
tool_input = payload.get("tool_input")
|
|
127
|
+
if isinstance(tool_input, dict):
|
|
128
|
+
attrs["input_keys"] = sorted(str(key) for key in tool_input)
|
|
129
|
+
return _entity(
|
|
130
|
+
"tool_call",
|
|
131
|
+
f"tool-call:cursor:{_scope(payload)}:{tool_use_id}",
|
|
132
|
+
name=tool_name,
|
|
133
|
+
attributes=attrs,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _command_entity(tool_input: dict[str, Any]) -> dict[str, Any] | None:
|
|
138
|
+
command, truncated = _clean_text(tool_input.get("command"), limit=_MAX_COMMAND_CHARS)
|
|
139
|
+
if not command:
|
|
140
|
+
return None
|
|
141
|
+
digest = hashlib.sha256(command.encode("utf-8", errors="replace")).hexdigest()
|
|
142
|
+
label, _ = _clean_text(command.replace("\n", " "), limit=_MAX_LABEL_CHARS)
|
|
143
|
+
return _entity(
|
|
144
|
+
"command",
|
|
145
|
+
f"command:sha256:{digest}",
|
|
146
|
+
name=label,
|
|
147
|
+
attributes={"command": command, "truncated": truncated},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _declared_file_entity(payload: dict[str, Any], tool_input: dict[str, Any]) -> dict[str, Any] | None:
|
|
152
|
+
raw = tool_input.get("file_path")
|
|
153
|
+
if not isinstance(raw, str) or not raw:
|
|
154
|
+
raw = tool_input.get("path")
|
|
155
|
+
if not isinstance(raw, str) or not raw:
|
|
156
|
+
return None
|
|
157
|
+
candidate = Path(raw).expanduser()
|
|
158
|
+
if not candidate.is_absolute():
|
|
159
|
+
cwd = payload.get("cwd")
|
|
160
|
+
if not isinstance(cwd, str) or not cwd:
|
|
161
|
+
cwd = tool_input.get("working_directory")
|
|
162
|
+
if isinstance(cwd, str) and cwd:
|
|
163
|
+
candidate = Path(cwd) / candidate
|
|
164
|
+
try:
|
|
165
|
+
normalized = candidate.resolve(strict=False)
|
|
166
|
+
except OSError:
|
|
167
|
+
normalized = candidate.absolute()
|
|
168
|
+
return _entity(
|
|
169
|
+
"file",
|
|
170
|
+
f"file:{normalized}",
|
|
171
|
+
name=normalized.name or str(normalized),
|
|
172
|
+
attributes={"declared_by_provider_hook": True, "provider": "cursor"},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _session_start_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
|
|
177
|
+
model = payload.get("model_id")
|
|
178
|
+
if not isinstance(model, str) or not model:
|
|
179
|
+
model = payload.get("model")
|
|
180
|
+
if not isinstance(model, str) or not model:
|
|
181
|
+
return []
|
|
182
|
+
return [
|
|
183
|
+
_event(
|
|
184
|
+
timestamp=timestamp,
|
|
185
|
+
event_type="semantic.cursor.model.observed",
|
|
186
|
+
relation="USED_MODEL",
|
|
187
|
+
source=_agent(),
|
|
188
|
+
target=_entity(
|
|
189
|
+
"model",
|
|
190
|
+
f"model:cursor:{model}",
|
|
191
|
+
name=model,
|
|
192
|
+
attributes={"provider": "cursor"},
|
|
193
|
+
),
|
|
194
|
+
attributes=_common_attributes(payload),
|
|
195
|
+
)
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _pre_tool_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
|
|
200
|
+
tool_name = payload.get("tool_name")
|
|
201
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
202
|
+
raise ValueError("Cursor preToolUse requires tool_name")
|
|
203
|
+
call = _tool_call_entity(payload, tool_name)
|
|
204
|
+
tool = _tool_entity(tool_name)
|
|
205
|
+
common = _common_attributes(payload)
|
|
206
|
+
events = [
|
|
207
|
+
_event(
|
|
208
|
+
timestamp=timestamp,
|
|
209
|
+
event_type="semantic.cursor.tool.requested",
|
|
210
|
+
relation="REQUESTED_TOOL_CALL",
|
|
211
|
+
source=_agent(),
|
|
212
|
+
target=call,
|
|
213
|
+
attributes=common,
|
|
214
|
+
),
|
|
215
|
+
_event(
|
|
216
|
+
timestamp=timestamp,
|
|
217
|
+
event_type="semantic.cursor.tool.selected",
|
|
218
|
+
relation="USES_TOOL",
|
|
219
|
+
source=call,
|
|
220
|
+
target=tool,
|
|
221
|
+
attributes=common,
|
|
222
|
+
),
|
|
223
|
+
]
|
|
224
|
+
tool_input = payload.get("tool_input")
|
|
225
|
+
if isinstance(tool_input, dict):
|
|
226
|
+
if tool_name == "Shell":
|
|
227
|
+
command = _command_entity(tool_input)
|
|
228
|
+
if command is not None:
|
|
229
|
+
events.append(
|
|
230
|
+
_event(
|
|
231
|
+
timestamp=timestamp,
|
|
232
|
+
event_type="semantic.cursor.command.declared",
|
|
233
|
+
relation="DECLARED_COMMAND",
|
|
234
|
+
source=call,
|
|
235
|
+
target=command,
|
|
236
|
+
attributes=common,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
if tool_name in {"Read", "Write", "Delete"}:
|
|
240
|
+
target = _declared_file_entity(payload, tool_input)
|
|
241
|
+
if target is not None:
|
|
242
|
+
events.append(
|
|
243
|
+
_event(
|
|
244
|
+
timestamp=timestamp,
|
|
245
|
+
event_type="semantic.cursor.file.declared",
|
|
246
|
+
relation="DECLARED_TARGET",
|
|
247
|
+
source=call,
|
|
248
|
+
target=target,
|
|
249
|
+
attributes=common,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return events
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _tool_result_events(
|
|
256
|
+
payload: dict[str, Any],
|
|
257
|
+
*,
|
|
258
|
+
timestamp: str,
|
|
259
|
+
success: bool,
|
|
260
|
+
) -> list[dict[str, Any]]:
|
|
261
|
+
tool_name = payload.get("tool_name")
|
|
262
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
263
|
+
raise ValueError("Cursor tool result hook requires tool_name")
|
|
264
|
+
call = _tool_call_entity(payload, tool_name)
|
|
265
|
+
attrs = _common_attributes(payload)
|
|
266
|
+
if not success:
|
|
267
|
+
attrs["provider_reported_failure"] = True
|
|
268
|
+
return [
|
|
269
|
+
_event(
|
|
270
|
+
timestamp=timestamp,
|
|
271
|
+
event_type=(
|
|
272
|
+
"semantic.cursor.tool.returned" if success else "semantic.cursor.tool.failed"
|
|
273
|
+
),
|
|
274
|
+
relation="TOOL_CALL_RETURNED" if success else "TOOL_CALL_FAILED",
|
|
275
|
+
source=call,
|
|
276
|
+
target=_tool_entity(tool_name),
|
|
277
|
+
attributes=attrs,
|
|
278
|
+
)
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cursor_hook_to_semantic_events(
|
|
283
|
+
payload: dict[str, Any],
|
|
284
|
+
*,
|
|
285
|
+
timestamp: str | None = None,
|
|
286
|
+
) -> list[dict[str, Any]]:
|
|
287
|
+
hook_event = payload.get("hook_event_name")
|
|
288
|
+
if not isinstance(hook_event, str) or not hook_event:
|
|
289
|
+
raise ValueError("Cursor hook payload requires hook_event_name")
|
|
290
|
+
if hook_event not in _SUPPORTED_EVENTS:
|
|
291
|
+
return []
|
|
292
|
+
observed_at = timestamp or _now()
|
|
293
|
+
if hook_event == "sessionStart":
|
|
294
|
+
return _session_start_events(payload, timestamp=observed_at)
|
|
295
|
+
if hook_event == "preToolUse":
|
|
296
|
+
return _pre_tool_events(payload, timestamp=observed_at)
|
|
297
|
+
if hook_event == "postToolUse":
|
|
298
|
+
return _tool_result_events(payload, timestamp=observed_at, success=True)
|
|
299
|
+
if hook_event == "postToolUseFailure":
|
|
300
|
+
return _tool_result_events(payload, timestamp=observed_at, success=False)
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def append_semantic_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
|
|
305
|
+
output = Path(path).expanduser().resolve()
|
|
306
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
if not records:
|
|
308
|
+
return output
|
|
309
|
+
blob = "".join(
|
|
310
|
+
json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
|
|
311
|
+
for record in records
|
|
312
|
+
)
|
|
313
|
+
lock_dir = output.with_name(output.name + ".lock")
|
|
314
|
+
deadline = time.monotonic() + 5.0
|
|
315
|
+
while True:
|
|
316
|
+
try:
|
|
317
|
+
lock_dir.mkdir()
|
|
318
|
+
break
|
|
319
|
+
except FileExistsError:
|
|
320
|
+
if time.monotonic() >= deadline:
|
|
321
|
+
raise TimeoutError(f"timed out waiting for semantic sidecar lock: {lock_dir}")
|
|
322
|
+
time.sleep(0.01)
|
|
323
|
+
try:
|
|
324
|
+
with output.open("a", encoding="utf-8", newline="\n") as handle:
|
|
325
|
+
handle.write(blob)
|
|
326
|
+
handle.flush()
|
|
327
|
+
os.fsync(handle.fileno())
|
|
328
|
+
finally:
|
|
329
|
+
try:
|
|
330
|
+
lock_dir.rmdir()
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
return output
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def read_hook_payload(stream: Any = None) -> dict[str, Any]:
|
|
337
|
+
source = stream if stream is not None else sys.stdin
|
|
338
|
+
raw = source.read()
|
|
339
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
340
|
+
raise ValueError("Cursor hook stdin is empty")
|
|
341
|
+
try:
|
|
342
|
+
payload = json.loads(raw)
|
|
343
|
+
except json.JSONDecodeError as exc:
|
|
344
|
+
raise ValueError(f"Cursor hook stdin is invalid JSON: {exc.msg}") from exc
|
|
345
|
+
if not isinstance(payload, dict):
|
|
346
|
+
raise ValueError("Cursor hook stdin must be one JSON object")
|
|
347
|
+
return payload
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .cursor_adapter import append_semantic_records, cursor_hook_to_semantic_events, read_hook_payload
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cursor_hook_config(command: str = "execweave-cursor-hook") -> dict[str, Any]:
|
|
14
|
+
handler = {"command": command}
|
|
15
|
+
return {
|
|
16
|
+
"version": 1,
|
|
17
|
+
"hooks": {
|
|
18
|
+
"sessionStart": [handler],
|
|
19
|
+
"preToolUse": [handler],
|
|
20
|
+
"postToolUse": [handler],
|
|
21
|
+
"postToolUseFailure": [handler],
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_sidecar(payload: dict[str, Any]) -> Path:
|
|
27
|
+
cwd = payload.get("cwd")
|
|
28
|
+
if not isinstance(cwd, str) or not cwd:
|
|
29
|
+
roots = payload.get("workspace_roots")
|
|
30
|
+
if isinstance(roots, list) and roots and isinstance(roots[0], str):
|
|
31
|
+
cwd = roots[0]
|
|
32
|
+
if not isinstance(cwd, str) or not cwd:
|
|
33
|
+
raise ValueError("Cursor hook payload has no cwd/workspace root for sidecar placement")
|
|
34
|
+
scope = None
|
|
35
|
+
for key in ("conversation_id", "session_id", "generation_id"):
|
|
36
|
+
value = payload.get(key)
|
|
37
|
+
if isinstance(value, str) and value:
|
|
38
|
+
scope = value
|
|
39
|
+
break
|
|
40
|
+
if scope is None:
|
|
41
|
+
raise ValueError("Cursor hook payload has no conversation/session identifier")
|
|
42
|
+
safe_scope = "".join(
|
|
43
|
+
character if character.isalnum() or character in {"-", "_", "."} else "_"
|
|
44
|
+
for character in scope
|
|
45
|
+
)
|
|
46
|
+
return Path(cwd) / ".execweave" / "semantic" / "cursor" / f"{safe_scope}.jsonl"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="execweave-cursor-hook",
|
|
52
|
+
description="Capture Cursor hook input as local ExecWeave semantic telemetry.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--sidecar", type=Path, default=None)
|
|
55
|
+
parser.add_argument("--strict", action="store_true")
|
|
56
|
+
parser.add_argument("--print-config", action="store_true")
|
|
57
|
+
parser.add_argument("--command", default="execweave-cursor-hook")
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
args = build_parser().parse_args(argv)
|
|
63
|
+
if args.print_config:
|
|
64
|
+
print(json.dumps(cursor_hook_config(args.command), indent=2, sort_keys=True))
|
|
65
|
+
return 0
|
|
66
|
+
try:
|
|
67
|
+
payload = read_hook_payload()
|
|
68
|
+
sidecar = args.sidecar
|
|
69
|
+
if sidecar is None:
|
|
70
|
+
configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
|
|
71
|
+
sidecar = Path(configured) if configured else _default_sidecar(payload)
|
|
72
|
+
append_semantic_records(sidecar, cursor_hook_to_semantic_events(payload))
|
|
73
|
+
except (OSError, TimeoutError, ValueError) as exc:
|
|
74
|
+
print(f"ExecWeave Cursor hook warning: {exc}", file=sys.stderr)
|
|
75
|
+
if args.strict:
|
|
76
|
+
return 1
|
|
77
|
+
print("{}")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .backends import BackendName
|
|
8
|
+
from .provider_record import ProviderRecordResult, record_provider_to_viewer
|
|
9
|
+
|
|
10
|
+
CursorRecordResult = ProviderRecordResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def record_cursor_to_viewer(
|
|
14
|
+
command: list[str],
|
|
15
|
+
*,
|
|
16
|
+
watch_root: str | Path,
|
|
17
|
+
output_dir: str | Path | None = None,
|
|
18
|
+
backend: BackendName = "auto",
|
|
19
|
+
poll_interval: float = 0.10,
|
|
20
|
+
collect_filesystem: bool = True,
|
|
21
|
+
collect_network: bool = True,
|
|
22
|
+
keep_raw_trace: bool = False,
|
|
23
|
+
correlation_window_ms: int = 3000,
|
|
24
|
+
open_browser: bool = False,
|
|
25
|
+
) -> CursorRecordResult:
|
|
26
|
+
return record_provider_to_viewer(
|
|
27
|
+
command,
|
|
28
|
+
provider_name="Cursor",
|
|
29
|
+
watch_root=watch_root,
|
|
30
|
+
output_dir=output_dir,
|
|
31
|
+
backend=backend,
|
|
32
|
+
poll_interval=poll_interval,
|
|
33
|
+
collect_filesystem=collect_filesystem,
|
|
34
|
+
collect_network=collect_network,
|
|
35
|
+
keep_raw_trace=keep_raw_trace,
|
|
36
|
+
correlation_window_ms=correlation_window_ms,
|
|
37
|
+
open_browser=open_browser,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clean_command(command: list[str]) -> list[str]:
|
|
42
|
+
result = list(command)
|
|
43
|
+
if result and result[0] == "--":
|
|
44
|
+
result = result[1:]
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="execweave-cursor-record",
|
|
51
|
+
description=(
|
|
52
|
+
"Record runtime evidence, Cursor hook telemetry, and conservative "
|
|
53
|
+
"Tool-to-Process correlation in one local run."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument("--watch-root", type=Path, default=None)
|
|
57
|
+
parser.add_argument("--output-dir", type=Path, default=None)
|
|
58
|
+
parser.add_argument("--interval", type=float, default=0.10)
|
|
59
|
+
parser.add_argument("--backend", choices=["auto", "portable", "strace"], default="auto")
|
|
60
|
+
parser.add_argument("--correlation-window-ms", type=int, default=3000)
|
|
61
|
+
parser.add_argument("--no-files", action="store_true")
|
|
62
|
+
parser.add_argument("--no-network", action="store_true")
|
|
63
|
+
parser.add_argument("--keep-native-trace", action="store_true")
|
|
64
|
+
parser.add_argument("--open", action="store_true", dest="open_browser")
|
|
65
|
+
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
parser = build_parser()
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
command = _clean_command(args.command)
|
|
73
|
+
if not command:
|
|
74
|
+
parser.error("a Cursor command is required, e.g. execweave-cursor-record --open -- cursor")
|
|
75
|
+
watch_root = (args.watch_root or Path.cwd()).expanduser().resolve()
|
|
76
|
+
try:
|
|
77
|
+
result = record_cursor_to_viewer(
|
|
78
|
+
command,
|
|
79
|
+
watch_root=watch_root,
|
|
80
|
+
output_dir=args.output_dir,
|
|
81
|
+
backend=args.backend,
|
|
82
|
+
poll_interval=args.interval,
|
|
83
|
+
collect_filesystem=not args.no_files,
|
|
84
|
+
collect_network=not args.no_network,
|
|
85
|
+
keep_raw_trace=args.keep_native_trace,
|
|
86
|
+
correlation_window_ms=args.correlation_window_ms,
|
|
87
|
+
open_browser=args.open_browser,
|
|
88
|
+
)
|
|
89
|
+
except (FileExistsError, RuntimeError, ValueError, OSError) as exc:
|
|
90
|
+
parser.error(str(exc))
|
|
91
|
+
print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
|
|
92
|
+
return result.runtime.return_code
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|
execweave/filesystem.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
7
|
+
from watchdog.observers import Observer
|
|
8
|
+
|
|
9
|
+
from .schema import Entity, RuntimeEvent
|
|
10
|
+
from .sink import JsonlSink
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SessionFileEventHandler(FileSystemEventHandler):
|
|
14
|
+
"""Record filesystem changes observed inside a watched session directory.
|
|
15
|
+
|
|
16
|
+
Phase 1 intentionally records these as session-level observations. A filesystem
|
|
17
|
+
change is not attributed to a specific process until a lower-level collector
|
|
18
|
+
(for example eBPF/ETW) can prove that relationship.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
session_id: str,
|
|
25
|
+
session_entity: Entity,
|
|
26
|
+
sink: JsonlSink,
|
|
27
|
+
excluded_roots: Iterable[Path] = (),
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.session_id = session_id
|
|
31
|
+
self.session_entity = session_entity
|
|
32
|
+
self.sink = sink
|
|
33
|
+
self.excluded_roots = tuple(path.resolve() for path in excluded_roots)
|
|
34
|
+
|
|
35
|
+
def _excluded(self, path: str) -> bool:
|
|
36
|
+
candidate = Path(path).expanduser().resolve()
|
|
37
|
+
return any(candidate == root or root in candidate.parents for root in self.excluded_roots)
|
|
38
|
+
|
|
39
|
+
def _emit(self, event: FileSystemEvent) -> None:
|
|
40
|
+
if self._excluded(event.src_path):
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
src = Path(event.src_path).expanduser().resolve()
|
|
44
|
+
target_path = src
|
|
45
|
+
attributes: dict[str, object] = {
|
|
46
|
+
"filesystem_event": event.event_type,
|
|
47
|
+
"is_directory": event.is_directory,
|
|
48
|
+
"attribution": "session_observation",
|
|
49
|
+
"causal": False,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
destination = getattr(event, "dest_path", None)
|
|
53
|
+
if destination:
|
|
54
|
+
dest = Path(destination).expanduser().resolve()
|
|
55
|
+
if self._excluded(str(dest)):
|
|
56
|
+
return
|
|
57
|
+
attributes["source_path"] = str(src)
|
|
58
|
+
attributes["destination_path"] = str(dest)
|
|
59
|
+
target_path = dest
|
|
60
|
+
|
|
61
|
+
entity_type = "directory" if event.is_directory else "file"
|
|
62
|
+
target = Entity(type=entity_type, id=f"{entity_type}:{target_path}", name=target_path.name)
|
|
63
|
+
self.sink.emit(
|
|
64
|
+
RuntimeEvent.create(
|
|
65
|
+
session_id=self.session_id,
|
|
66
|
+
event_type=f"filesystem.{event.event_type}",
|
|
67
|
+
relation="OBSERVED_FILE_CHANGE",
|
|
68
|
+
source=self.session_entity,
|
|
69
|
+
target=target,
|
|
70
|
+
attributes=attributes,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def on_any_event(self, event: FileSystemEvent) -> None:
|
|
75
|
+
self._emit(event)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FileWatcher:
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
root: Path,
|
|
83
|
+
session_id: str,
|
|
84
|
+
session_entity: Entity,
|
|
85
|
+
sink: JsonlSink,
|
|
86
|
+
excluded_roots: Iterable[Path] = (),
|
|
87
|
+
) -> None:
|
|
88
|
+
self.root = root.expanduser().resolve()
|
|
89
|
+
self.handler = SessionFileEventHandler(
|
|
90
|
+
session_id=session_id,
|
|
91
|
+
session_entity=session_entity,
|
|
92
|
+
sink=sink,
|
|
93
|
+
excluded_roots=excluded_roots,
|
|
94
|
+
)
|
|
95
|
+
self.observer = Observer()
|
|
96
|
+
|
|
97
|
+
def start(self) -> None:
|
|
98
|
+
self.observer.schedule(self.handler, str(self.root), recursive=True)
|
|
99
|
+
self.observer.start()
|
|
100
|
+
|
|
101
|
+
def stop(self) -> None:
|
|
102
|
+
self.observer.stop()
|
|
103
|
+
self.observer.join(timeout=5)
|