custos-code 0.0.1__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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""OpenTelemetry GenAI spans -> ledger.
|
|
2
|
+
|
|
3
|
+
Input is OTLP/JSON (`{"resourceSpans": [...]}`, as `otel-cli`, the collector's file exporter and
|
|
4
|
+
every OTLP backend emit) or a JSONL stream of individual spans. The GenAI semantic conventions
|
|
5
|
+
make the interesting content *opt-in*: `gen_ai.operation.name=execute_tool` spans carry the tool
|
|
6
|
+
name and call id, but `gen_ai.tool.call.arguments` / `.result` and the message events only exist
|
|
7
|
+
when the instrumentation sets `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`. A span with
|
|
8
|
+
no captured result is recorded with `flags.stderr_dropped`, and when *no* span in the trace
|
|
9
|
+
carries a result the adapter writes the `no_tool_log` META row, so outcome claims come out
|
|
10
|
+
`unrecorded` -- the record is known-incomplete -- rather than confirmed off a bare span status.
|
|
11
|
+
|
|
12
|
+
Span status maps to outcome: `STATUS_CODE_ERROR` is positive evidence of failure. `UNSET` is not
|
|
13
|
+
evidence of success, and neither is `OK` on its own -- the convention lets an exporter mark a span
|
|
14
|
+
OK with nothing captured, and Tier 2 needs runner output, not just a status. exit_code 0 therefore
|
|
15
|
+
requires a captured `process.exit_code`, or an OK status *and* a captured tool result.
|
|
16
|
+
|
|
17
|
+
Owner: Ananya.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..ledger import chain, redact
|
|
27
|
+
from ..models import EventFlags, EventKind, LedgerEvent, Session
|
|
28
|
+
from ..parsers import is_piped
|
|
29
|
+
from .state import Builder, as_dict, as_list, as_text, no_tool_log
|
|
30
|
+
|
|
31
|
+
_TOOL_ALIASES = {
|
|
32
|
+
"bash": "Bash",
|
|
33
|
+
"shell": "Bash",
|
|
34
|
+
"run_command": "Bash",
|
|
35
|
+
"terminal": "Bash",
|
|
36
|
+
"edit": "Edit",
|
|
37
|
+
"write": "Edit",
|
|
38
|
+
"apply_patch": "Edit",
|
|
39
|
+
"str_replace_editor": "Edit",
|
|
40
|
+
"read": "Read",
|
|
41
|
+
"view": "Read",
|
|
42
|
+
"web_search": "WebSearch",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _attributes(raw: object) -> dict[str, Any]:
|
|
47
|
+
"""OTLP attributes are [{key, value:{stringValue|intValue|...}}]; plain dicts also accepted."""
|
|
48
|
+
if isinstance(raw, dict):
|
|
49
|
+
return dict(raw)
|
|
50
|
+
out: dict[str, Any] = {}
|
|
51
|
+
for item in as_list(raw):
|
|
52
|
+
attr = as_dict(item)
|
|
53
|
+
key = str(attr.get("key", ""))
|
|
54
|
+
value = attr.get("value")
|
|
55
|
+
if not key:
|
|
56
|
+
continue
|
|
57
|
+
if isinstance(value, dict):
|
|
58
|
+
for field in ("stringValue", "intValue", "doubleValue", "boolValue"):
|
|
59
|
+
if field in value:
|
|
60
|
+
out[key] = int(value[field]) if field == "intValue" else value[field]
|
|
61
|
+
break
|
|
62
|
+
else:
|
|
63
|
+
out[key] = as_text(value.get("arrayValue") or value.get("kvlistValue") or value)
|
|
64
|
+
else:
|
|
65
|
+
out[key] = value
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _nano_ts(value: object, fallback: datetime) -> datetime:
|
|
70
|
+
if isinstance(value, str) and value.isdigit():
|
|
71
|
+
value = int(value)
|
|
72
|
+
if isinstance(value, int | float) and value:
|
|
73
|
+
return datetime.fromtimestamp(float(value) / 1e9, tz=UTC)
|
|
74
|
+
return fallback
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _spans(payload: object) -> list[dict[str, Any]]:
|
|
78
|
+
"""Flatten OTLP resourceSpans/scopeSpans, or accept a bare span list."""
|
|
79
|
+
if isinstance(payload, list):
|
|
80
|
+
return [as_dict(s) for s in payload]
|
|
81
|
+
root = as_dict(payload)
|
|
82
|
+
if "resourceSpans" not in root:
|
|
83
|
+
return [root] if root.get("spanId") or root.get("name") else []
|
|
84
|
+
out: list[dict[str, Any]] = []
|
|
85
|
+
for resource in as_list(root.get("resourceSpans")):
|
|
86
|
+
res = as_dict(resource)
|
|
87
|
+
res_attrs = _attributes(as_dict(res.get("resource")).get("attributes"))
|
|
88
|
+
for scope in as_list(res.get("scopeSpans")):
|
|
89
|
+
for span in as_list(as_dict(scope).get("spans")):
|
|
90
|
+
s = as_dict(span)
|
|
91
|
+
s["_resource"] = res_attrs
|
|
92
|
+
out.append(s)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tool(attrs: dict[str, Any], span_name: str) -> str:
|
|
97
|
+
name = str(attrs.get("gen_ai.tool.name") or attrs.get("tool.name") or span_name.split(" ")[-1])
|
|
98
|
+
return _TOOL_ALIASES.get(name.lower(), name or "Tool")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
|
|
102
|
+
with open(path, encoding="utf-8") as fh:
|
|
103
|
+
text = fh.read().strip()
|
|
104
|
+
try:
|
|
105
|
+
payload: object = json.loads(text)
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
payload = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
108
|
+
spans = sorted(_spans(payload), key=lambda s: int(s.get("startTimeUnixNano") or 0))
|
|
109
|
+
if not spans:
|
|
110
|
+
raise ValueError(f"{path}: no spans found")
|
|
111
|
+
|
|
112
|
+
first = _attributes(spans[0].get("attributes")) | as_dict(spans[0].get("_resource"))
|
|
113
|
+
session_id = str(
|
|
114
|
+
first.get("gen_ai.conversation.id")
|
|
115
|
+
or first.get("session.id")
|
|
116
|
+
or spans[0].get("traceId")
|
|
117
|
+
or "otel-unknown"
|
|
118
|
+
)
|
|
119
|
+
epoch = datetime.fromtimestamp(0, tz=UTC)
|
|
120
|
+
started = _nano_ts(spans[0].get("startTimeUnixNano"), epoch)
|
|
121
|
+
cwd = str(first.get("process.working_directory") or "") or None
|
|
122
|
+
|
|
123
|
+
b = Builder(session_id, cwd)
|
|
124
|
+
report: str | None = None
|
|
125
|
+
ended = started
|
|
126
|
+
model: str | None = None
|
|
127
|
+
|
|
128
|
+
for span in spans:
|
|
129
|
+
attrs = _attributes(span.get("attributes")) | as_dict(span.get("_resource"))
|
|
130
|
+
start = _nano_ts(span.get("startTimeUnixNano"), started)
|
|
131
|
+
end = _nano_ts(span.get("endTimeUnixNano"), start)
|
|
132
|
+
ended = max(ended, end)
|
|
133
|
+
model = model or (
|
|
134
|
+
str(attrs.get("gen_ai.request.model")) if attrs.get("gen_ai.request.model") else None
|
|
135
|
+
)
|
|
136
|
+
operation = str(attrs.get("gen_ai.operation.name") or span.get("name") or "")
|
|
137
|
+
status = as_dict(span.get("status"))
|
|
138
|
+
failed = str(status.get("code", "")).endswith("ERROR")
|
|
139
|
+
|
|
140
|
+
if operation != "execute_tool" and not str(attrs.get("gen_ai.tool.name") or ""):
|
|
141
|
+
# inference spans carry the model's own words: report material, never evidence
|
|
142
|
+
output = as_text(attrs.get("gen_ai.output.messages") or attrs.get("gen_ai.completion"))
|
|
143
|
+
if output:
|
|
144
|
+
b.store_output(b.add(kind=EventKind.TEXT, ts=end), output)
|
|
145
|
+
report = output
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
tool = _tool(attrs, str(span.get("name", "")))
|
|
149
|
+
arguments = attrs.get("gen_ai.tool.call.arguments")
|
|
150
|
+
command = str(
|
|
151
|
+
as_dict(arguments).get("command") if isinstance(arguments, dict) else arguments or ""
|
|
152
|
+
)
|
|
153
|
+
piped = bool(command) and is_piped(command)
|
|
154
|
+
call_input = redact({"command": command} if command else {"arguments": as_text(arguments)})
|
|
155
|
+
b.add(
|
|
156
|
+
kind=EventKind.CALL,
|
|
157
|
+
ts=start,
|
|
158
|
+
tool=tool,
|
|
159
|
+
input=call_input,
|
|
160
|
+
flags=EventFlags(piped=piped, sidechain=bool(attrs.get("gen_ai.agent.id"))),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
exit_raw = attrs.get("process.exit_code")
|
|
164
|
+
result = attrs.get("gen_ai.tool.call.result")
|
|
165
|
+
ok = str(status.get("code", "")).endswith("OK") and result is not None
|
|
166
|
+
exit_code = (
|
|
167
|
+
int(exit_raw) if isinstance(exit_raw, int) else (1 if failed else 0 if ok else None)
|
|
168
|
+
)
|
|
169
|
+
event = b.add(
|
|
170
|
+
kind=EventKind.RESULT,
|
|
171
|
+
ts=end,
|
|
172
|
+
tool=tool,
|
|
173
|
+
input=call_input,
|
|
174
|
+
exit_code=exit_code,
|
|
175
|
+
duration_ms=int((end - start).total_seconds() * 1000),
|
|
176
|
+
flags=EventFlags(
|
|
177
|
+
piped=piped,
|
|
178
|
+
error=failed,
|
|
179
|
+
# content capture is opt-in in the convention; say so rather than guess
|
|
180
|
+
stderr_dropped=result is None,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
if result is not None:
|
|
184
|
+
b.store_output(event, as_text(result))
|
|
185
|
+
|
|
186
|
+
results = sum(1 for e in b.events if e.kind is EventKind.RESULT)
|
|
187
|
+
captured = sum(1 for e in b.events if e.kind is EventKind.RESULT and not e.flags.stderr_dropped)
|
|
188
|
+
if results and not captured:
|
|
189
|
+
no_tool_log(
|
|
190
|
+
b,
|
|
191
|
+
ended,
|
|
192
|
+
"trace captured no gen_ai.tool.call.result: content capture is opt-in in the GenAI "
|
|
193
|
+
"convention, so tool outcomes are not in the record",
|
|
194
|
+
evidence_class="T",
|
|
195
|
+
)
|
|
196
|
+
events = chain(b.events)
|
|
197
|
+
meta = Session(
|
|
198
|
+
id=session_id,
|
|
199
|
+
source="otel",
|
|
200
|
+
agent=str(first.get("gen_ai.agent.name") or first.get("service.name") or "otel"),
|
|
201
|
+
model=model,
|
|
202
|
+
started=started,
|
|
203
|
+
ended=ended,
|
|
204
|
+
cwd=cwd,
|
|
205
|
+
n_events=len(events),
|
|
206
|
+
ledger_root_hash=events[-1].hash if events else "",
|
|
207
|
+
# completeness is the known weak point of trace-based evidence: score it, don't assume it
|
|
208
|
+
integrity_score=round(captured / results, 2) if results else 0.5,
|
|
209
|
+
)
|
|
210
|
+
return meta, events, report
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Shared state-evidence builders for the class-R adapters (Devin cloud, Copilot, any PR bot).
|
|
2
|
+
|
|
3
|
+
Class R has no tool log by default: the agent ran elsewhere and left a PR behind. What it does
|
|
4
|
+
leave is state -- commits, CI conclusions, and the files in a checkout -- which is the state half
|
|
5
|
+
of the two-evidence rule, so edit/create/commit claims are settleable here and shell claims are
|
|
6
|
+
not. Devin and Copilot differ only in how the report and metadata are fetched, so the evidence
|
|
7
|
+
side lives here once.
|
|
8
|
+
|
|
9
|
+
Owner: Ananya.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..ledger import MAX_OUTPUT_BYTES, redact
|
|
21
|
+
from ..models import EventFlags, EventKind, LedgerEvent
|
|
22
|
+
|
|
23
|
+
FAILED_CONCLUSIONS = frozenset(
|
|
24
|
+
{"failure", "timed_out", "cancelled", "action_required", "startup_failure"}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def as_ts(value: object, fallback: datetime) -> datetime:
|
|
29
|
+
if isinstance(value, str):
|
|
30
|
+
try:
|
|
31
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
32
|
+
except ValueError:
|
|
33
|
+
return fallback
|
|
34
|
+
if isinstance(value, int | float):
|
|
35
|
+
return datetime.fromtimestamp(float(value), tz=UTC)
|
|
36
|
+
return fallback
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def as_dict(value: object) -> dict[str, Any]:
|
|
40
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def as_list(value: object) -> list[Any]:
|
|
44
|
+
return list(value) if isinstance(value, list) else []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def as_text(value: object) -> str:
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
return value
|
|
50
|
+
if value is None:
|
|
51
|
+
return ""
|
|
52
|
+
return json.dumps(value, indent=2, sort_keys=True, default=str)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def abs_path(path: str, root: str | None) -> str:
|
|
56
|
+
return path if os.path.isabs(path) or not root else os.path.normpath(os.path.join(root, path))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Builder:
|
|
60
|
+
"""Accumulates ledger events for one session, numbering and hashing output as it goes."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, session_id: str, cwd: str | None) -> None:
|
|
63
|
+
self.events: list[LedgerEvent] = []
|
|
64
|
+
self.seq = 0
|
|
65
|
+
self.session_id = session_id
|
|
66
|
+
self.cwd = cwd
|
|
67
|
+
|
|
68
|
+
def add(self, **fields: Any) -> LedgerEvent:
|
|
69
|
+
event = LedgerEvent(seq=self.seq, session_id=self.session_id, cwd=self.cwd, **fields)
|
|
70
|
+
self.events.append(event)
|
|
71
|
+
self.seq += 1
|
|
72
|
+
return event
|
|
73
|
+
|
|
74
|
+
def store_output(self, event: LedgerEvent, text: str) -> None:
|
|
75
|
+
blob = str(redact(text))
|
|
76
|
+
raw = blob.encode()
|
|
77
|
+
event.output_hash = hashlib.sha256(raw).hexdigest()
|
|
78
|
+
event.output = raw[:MAX_OUTPUT_BYTES].decode(errors="ignore")
|
|
79
|
+
event.flags.truncated = event.flags.truncated or len(raw) > MAX_OUTPUT_BYTES
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def commits(b: Builder, git: dict[str, Any], fallback: datetime) -> datetime:
|
|
83
|
+
"""Commits satisfy invariant 5 for edit/create claims: the transcript alone never could."""
|
|
84
|
+
root = str(git.get("root") or b.cwd or "") or None
|
|
85
|
+
ts = fallback
|
|
86
|
+
for raw in as_list(git.get("commits")):
|
|
87
|
+
commit = as_dict(raw)
|
|
88
|
+
ts = as_ts(commit.get("ts") or commit.get("date"), ts)
|
|
89
|
+
files = [abs_path(str(f), root) for f in as_list(commit.get("files"))]
|
|
90
|
+
event = b.add(
|
|
91
|
+
kind=EventKind.RESULT,
|
|
92
|
+
ts=ts,
|
|
93
|
+
tool="Git",
|
|
94
|
+
exit_code=0,
|
|
95
|
+
paths=files,
|
|
96
|
+
input=redact(
|
|
97
|
+
{"sha": str(commit.get("sha", "")), "subject": str(commit.get("subject", ""))}
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
b.store_output(
|
|
101
|
+
event, as_text(commit.get("stat") or commit.get("body") or commit.get("subject"))
|
|
102
|
+
)
|
|
103
|
+
return ts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def checks(b: Builder, runs: list[Any], fallback: datetime) -> datetime:
|
|
107
|
+
"""CI runs are the outcome evidence class R has; a failed conclusion is positive evidence."""
|
|
108
|
+
ts = fallback
|
|
109
|
+
for raw in runs:
|
|
110
|
+
check = as_dict(raw)
|
|
111
|
+
ts = as_ts(check.get("completed_at") or check.get("started_at"), ts)
|
|
112
|
+
conclusion = str(check.get("conclusion") or check.get("status") or "").lower()
|
|
113
|
+
failed = conclusion in FAILED_CONCLUSIONS
|
|
114
|
+
started = as_ts(check.get("started_at"), ts)
|
|
115
|
+
duration = int((ts - started).total_seconds() * 1000) if ts > started else None
|
|
116
|
+
meta = redact({"name": str(check.get("name", "")), "url": str(check.get("url", ""))})
|
|
117
|
+
b.add(kind=EventKind.CALL, ts=started, tool="CI", input=meta)
|
|
118
|
+
event = b.add(
|
|
119
|
+
kind=EventKind.RESULT,
|
|
120
|
+
ts=ts,
|
|
121
|
+
tool="CI",
|
|
122
|
+
duration_ms=duration,
|
|
123
|
+
input=meta,
|
|
124
|
+
exit_code=0 if conclusion == "success" else 1 if failed else None,
|
|
125
|
+
flags=EventFlags(error=failed),
|
|
126
|
+
)
|
|
127
|
+
b.store_output(event, as_text(check.get("output") or check.get("logs") or conclusion))
|
|
128
|
+
return ts
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def checkout(b: Builder, probes: dict[str, Any], fallback: datetime) -> None:
|
|
132
|
+
"""Filesystem probes taken after the session: `exists: false` is positive evidence of absence."""
|
|
133
|
+
root = str(probes.get("root") or b.cwd or "") or None
|
|
134
|
+
for raw in as_list(probes.get("files")):
|
|
135
|
+
probe = as_dict(raw)
|
|
136
|
+
path = abs_path(str(probe.get("path", "")), root)
|
|
137
|
+
if not path:
|
|
138
|
+
continue
|
|
139
|
+
exists = bool(probe.get("exists", True))
|
|
140
|
+
event = b.add(
|
|
141
|
+
kind=EventKind.RESULT,
|
|
142
|
+
ts=as_ts(probe.get("ts"), fallback),
|
|
143
|
+
tool="Read",
|
|
144
|
+
paths=[path],
|
|
145
|
+
exit_code=0 if exists else 1,
|
|
146
|
+
flags=EventFlags(error=not exists),
|
|
147
|
+
input={"path": path, "probe": "stat"},
|
|
148
|
+
)
|
|
149
|
+
b.store_output(
|
|
150
|
+
event,
|
|
151
|
+
json.dumps(
|
|
152
|
+
{"exists": exists, "sha256": probe.get("sha256"), "bytes": probe.get("bytes")},
|
|
153
|
+
sort_keys=True,
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def no_tool_log(b: Builder, ts: datetime, note: str, evidence_class: str = "R") -> LedgerEvent:
|
|
159
|
+
"""Record the instrumentation gap explicitly, so Tier 1/2 answer `unrecorded`, not `unwitnessed`."""
|
|
160
|
+
return b.add(
|
|
161
|
+
kind=EventKind.META,
|
|
162
|
+
ts=ts,
|
|
163
|
+
input={"event": "no_tool_log", "class": evidence_class, "note": note},
|
|
164
|
+
)
|