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,172 @@
|
|
|
1
|
+
"""Devin Path C: no tool log, so the ledger is built from state (see docs/DEVIN.md, A2).
|
|
2
|
+
|
|
3
|
+
The public v1 API exposes session metadata, chat messages, `structured_output` and the PRs a
|
|
4
|
+
session opened -- never the shell it ran. Path C therefore reads a *bundle*: the API's
|
|
5
|
+
GetSessionResponse plus whatever state evidence the caller could collect. Report = PR description
|
|
6
|
+
+ structured_output + last Devin message. Evidence = git + CI + checkout, which is the state half
|
|
7
|
+
of invariant 4: an edit or commit claim can be confirmed here, while "ran the tests locally"
|
|
8
|
+
cannot. Path C sessions therefore carry a `no_tool_log` META row and a reduced integrity score so
|
|
9
|
+
Tier 1/2 answer `unrecorded` rather than inventing a witness.
|
|
10
|
+
|
|
11
|
+
Bundle (JSON, one object) -- either a raw GetSessionResponse or:
|
|
12
|
+
{"session": <GetSessionResponse>,
|
|
13
|
+
"git": {"root": "/abs", "branch": ..., "commits": [{"sha","subject","ts","files":[...],"stat":...}]},
|
|
14
|
+
"checks": [{"name","conclusion","started_at","completed_at","url","output"}],
|
|
15
|
+
"checkout": {"root": "/abs", "files": [{"path","exists","sha256","bytes"}]}}
|
|
16
|
+
|
|
17
|
+
`fetch_bundle` builds one from the v1 API (GET /v1/sessions/{id}); `nudge` is the correction loop
|
|
18
|
+
for a running session (POST /v1/sessions/{id}/message). Both are opt-in network calls.
|
|
19
|
+
|
|
20
|
+
Owner: Ananya.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from ..ledger import chain, redact
|
|
33
|
+
from ..models import EventKind, LedgerEvent, Session
|
|
34
|
+
from .state import Builder, as_dict, as_list, as_text, as_ts, checkout, checks, commits, no_tool_log
|
|
35
|
+
|
|
36
|
+
API_ROOT = "https://api.devin.ai/v1"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _messages(b: Builder, messages: list[Any], fallback: datetime) -> tuple[datetime, str | None]:
|
|
40
|
+
"""Chat messages are report material, not evidence: Devin's own text never witnesses an action."""
|
|
41
|
+
last_devin: str | None = None
|
|
42
|
+
ts = fallback
|
|
43
|
+
for raw in messages:
|
|
44
|
+
msg = as_dict(raw)
|
|
45
|
+
ts = as_ts(msg.get("timestamp") or msg.get("created_at"), ts)
|
|
46
|
+
body = as_text(msg.get("message") or msg.get("content"))
|
|
47
|
+
if not body:
|
|
48
|
+
continue
|
|
49
|
+
kind = EventKind.USER if str(msg.get("type", "")).startswith("user") else EventKind.TEXT
|
|
50
|
+
b.store_output(b.add(kind=kind, ts=ts), body)
|
|
51
|
+
if kind is EventKind.TEXT:
|
|
52
|
+
last_devin = body
|
|
53
|
+
return ts, last_devin
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_report(
|
|
57
|
+
session: dict[str, Any], pr: dict[str, Any], last_message: str | None
|
|
58
|
+
) -> str | None:
|
|
59
|
+
"""Path C's report is the union of the three places Devin states what it did."""
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
body = as_text(pr.get("body") or pr.get("description"))
|
|
62
|
+
if body:
|
|
63
|
+
parts.append(f"## Pull request {pr.get('url') or pr.get('html_url') or ''}\n{body}".strip())
|
|
64
|
+
structured = session.get("structured_output")
|
|
65
|
+
if structured:
|
|
66
|
+
parts.append(f"## structured_output\n{as_text(structured)}")
|
|
67
|
+
if last_message:
|
|
68
|
+
parts.append(f"## Final message\n{last_message}")
|
|
69
|
+
return "\n\n".join(parts) if parts else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
|
|
73
|
+
with open(path, encoding="utf-8") as fh:
|
|
74
|
+
bundle = json.load(fh)
|
|
75
|
+
if not isinstance(bundle, dict):
|
|
76
|
+
raise ValueError(f"{path}: expected a Devin bundle object")
|
|
77
|
+
session = as_dict(bundle.get("session") or bundle)
|
|
78
|
+
git = as_dict(bundle.get("git"))
|
|
79
|
+
probes = as_dict(bundle.get("checkout"))
|
|
80
|
+
|
|
81
|
+
session_id = str(session.get("session_id") or session.get("id") or "devin-unknown")
|
|
82
|
+
started = as_ts(session.get("created_at"), datetime.fromtimestamp(0, tz=UTC))
|
|
83
|
+
prs = as_list(session.get("pull_requests")) or (
|
|
84
|
+
[session["pull_request"]] if session.get("pull_request") else []
|
|
85
|
+
)
|
|
86
|
+
pr = as_dict(prs[0]) if prs else {}
|
|
87
|
+
cwd = str(git.get("root") or probes.get("root") or "") or None
|
|
88
|
+
|
|
89
|
+
b = Builder(session_id, cwd)
|
|
90
|
+
b.add(
|
|
91
|
+
kind=EventKind.META,
|
|
92
|
+
ts=started,
|
|
93
|
+
input=redact(
|
|
94
|
+
{
|
|
95
|
+
"event": "session_meta",
|
|
96
|
+
"path": "C",
|
|
97
|
+
"status": str(session.get("status_enum", "")),
|
|
98
|
+
"snapshot_id": str(session.get("snapshot_id") or ""),
|
|
99
|
+
"playbook_id": str(session.get("playbook_id") or ""),
|
|
100
|
+
"pull_request": str(pr.get("url") or pr.get("html_url") or ""),
|
|
101
|
+
}
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
no_tool_log(
|
|
105
|
+
b, started, "Devin public API exposes no tool calls; evidence is git + CI + checkout", "C"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
ts, last_devin = _messages(b, as_list(session.get("messages")), started)
|
|
109
|
+
ts = commits(b, git, ts)
|
|
110
|
+
ts = checks(b, as_list(bundle.get("checks")), ts)
|
|
111
|
+
checkout(b, probes, ts)
|
|
112
|
+
|
|
113
|
+
events = chain(b.events)
|
|
114
|
+
meta = Session(
|
|
115
|
+
id=session_id,
|
|
116
|
+
source="devin",
|
|
117
|
+
agent=str(session.get("agent") or "devin"),
|
|
118
|
+
model=str(session.get("model")) if session.get("model") else None,
|
|
119
|
+
started=started,
|
|
120
|
+
ended=ts,
|
|
121
|
+
cwd=cwd,
|
|
122
|
+
git_branch=str(git.get("branch") or pr.get("head") or "") or None,
|
|
123
|
+
n_events=len(events),
|
|
124
|
+
ledger_root_hash=events[-1].hash if events else "",
|
|
125
|
+
# Path C sees state only; half the ladder is blind here and the score must say so.
|
|
126
|
+
integrity_score=0.5,
|
|
127
|
+
)
|
|
128
|
+
return meta, events, build_report(session, pr, last_devin)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _api(method: str, url: str, token: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
132
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
133
|
+
request = urllib.request.Request(
|
|
134
|
+
url,
|
|
135
|
+
data=data,
|
|
136
|
+
method=method,
|
|
137
|
+
headers={
|
|
138
|
+
"Authorization": f"Bearer {token}",
|
|
139
|
+
"Content-Type": "application/json",
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - fixed https host
|
|
143
|
+
payload = response.read().decode()
|
|
144
|
+
return as_dict(json.loads(payload)) if payload.strip() else {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _key(token: str | None) -> str:
|
|
148
|
+
key = token or os.environ.get("DEVIN_API_KEY", "")
|
|
149
|
+
if not key:
|
|
150
|
+
raise ValueError("no Devin API key: pass token= or set DEVIN_API_KEY")
|
|
151
|
+
return key
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fetch_bundle(
|
|
155
|
+
session_id: str, token: str | None = None, *, api_root: str = API_ROOT
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
"""GET /v1/sessions/{id}. Nothing leaves the machine unless the caller asks for this."""
|
|
158
|
+
return {"session": _api("GET", f"{api_root}/sessions/{session_id}", _key(token))}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def nudge(
|
|
162
|
+
session_id: str, message: str, token: str | None = None, *, api_root: str = API_ROOT
|
|
163
|
+
) -> dict[str, Any]:
|
|
164
|
+
"""Correction loop for a running session: POST /v1/sessions/{id}/message (docs/DEVIN.md)."""
|
|
165
|
+
try:
|
|
166
|
+
return _api(
|
|
167
|
+
"POST", f"{api_root}/sessions/{session_id}/message", _key(token), {"message": message}
|
|
168
|
+
)
|
|
169
|
+
except (
|
|
170
|
+
urllib.error.HTTPError
|
|
171
|
+
) as exc: # a finished session refuses messages; caller falls back to a PR comment
|
|
172
|
+
raise RuntimeError(f"devin message rejected ({exc.code}): {exc.reason}") from exc
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Class M: the recorder that needs no harness at all (docs/ADAPTERS.md §4).
|
|
2
|
+
|
|
3
|
+
The floor of the coverage argument: with no hooks and no session file, a shell trap still knows
|
|
4
|
+
every command, its exit status, its cwd and its pid chain, and `git reflog` still knows every ref
|
|
5
|
+
move. `install_snippet` emits the bash/zsh lines `custos-code record` writes into the user's rc file;
|
|
6
|
+
this module parses what they log.
|
|
7
|
+
|
|
8
|
+
Wire format, one JSON object per line in ~/.custos-code/machine/<host>-<date>.jsonl:
|
|
9
|
+
{"recorder":"custos-code-machine","v":1,"event":"start|end|fs|git","ts":<epoch float>,
|
|
10
|
+
"cmd":"pytest -q","cwd":"/abs","tty":"/dev/ttys004","pid":123,"ppid":99,"ppid_chain":["bash","codex"],
|
|
11
|
+
"exit":0,"dur_ms":2250,"out":"<optional captured output>",
|
|
12
|
+
"path":"src/a.py","op":"modified", # event=fs
|
|
13
|
+
"ref":"HEAD","from":"abc","to":"def","subject":"..."} # event=git
|
|
14
|
+
|
|
15
|
+
Attribution: class M cannot prove which agent ran a command, so the recorded pid/ppid are a hint
|
|
16
|
+
and nothing more. Alone, these rows support outcome claims but leave tool attribution
|
|
17
|
+
`unwitnessed` rather than guessing from timing. Merging them into a class-H/F ledger to fill its
|
|
18
|
+
exit-code gaps is deliberately not implemented: the join is timing-based, the machine log is
|
|
19
|
+
writable by anything the agent runs, and a merge would have to re-chain and mark every borrowed
|
|
20
|
+
row to keep invariant 1 legible. See docs/ADAPTERS.md §4.
|
|
21
|
+
|
|
22
|
+
Owner: Ananya.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import socket
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from ..ledger import MAX_OUTPUT_BYTES, chain, redact
|
|
35
|
+
from ..models import EventFlags, EventKind, LedgerEvent, Session
|
|
36
|
+
from ..parsers import is_piped
|
|
37
|
+
|
|
38
|
+
LOG_DIR = os.path.expanduser("~/.custos-code/machine")
|
|
39
|
+
WIRE_VERSION = 1
|
|
40
|
+
# The product rename did not change the wire schema. Keep historical logs readable.
|
|
41
|
+
RECORDER_NAMES = ("custos-code-machine", "receipts-machine")
|
|
42
|
+
|
|
43
|
+
BASH_SNIPPET = r"""# >>> custos-code recorder (class M) >>>
|
|
44
|
+
__custos_code_log() { printf '%s\n' "$1" >> "$CUSTOS_CODE_MACHINE_LOG"; }
|
|
45
|
+
__custos_code_preexec() {
|
|
46
|
+
[ -n "$COMP_LINE" ] && return
|
|
47
|
+
[ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return
|
|
48
|
+
__CUSTOS_CODE_CMD="$BASH_COMMAND"; __CUSTOS_CODE_T0=$(date +%s.%N)
|
|
49
|
+
__custos_code_log "$(CUSTOS_CODE_EVENT=start CUSTOS_CODE_CMD="$__CUSTOS_CODE_CMD" CUSTOS_CODE_PID=$$ CUSTOS_CODE_PPID=$PPID CUSTOS_CODE_TTY="$CUSTOS_CODE_TTY" custos-code _record-line)"
|
|
50
|
+
}
|
|
51
|
+
__custos_code_precmd() {
|
|
52
|
+
local rc=$?
|
|
53
|
+
[ -z "$__CUSTOS_CODE_CMD" ] && return
|
|
54
|
+
__custos_code_log "$(CUSTOS_CODE_EVENT=end CUSTOS_CODE_CMD="$__CUSTOS_CODE_CMD" CUSTOS_CODE_RC=$rc CUSTOS_CODE_T0="$__CUSTOS_CODE_T0" CUSTOS_CODE_PID=$$ CUSTOS_CODE_PPID=$PPID CUSTOS_CODE_TTY="$CUSTOS_CODE_TTY" custos-code _record-line)"
|
|
55
|
+
__CUSTOS_CODE_CMD=
|
|
56
|
+
}
|
|
57
|
+
export CUSTOS_CODE_TTY="${CUSTOS_CODE_TTY:-$(tty 2>/dev/null || echo)}"
|
|
58
|
+
export CUSTOS_CODE_MACHINE_LOG="${CUSTOS_CODE_MACHINE_LOG:-$HOME/.custos-code/machine/$(hostname -s)-$(date +%F).jsonl}"
|
|
59
|
+
mkdir -p "$(dirname "$CUSTOS_CODE_MACHINE_LOG")"
|
|
60
|
+
trap '__custos_code_preexec' DEBUG
|
|
61
|
+
PROMPT_COMMAND="__custos_code_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
|
|
62
|
+
# <<< custos-code recorder (class M) <<<
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
ZSH_SNIPPET = r"""# >>> custos-code recorder (class M) >>>
|
|
66
|
+
export CUSTOS_CODE_TTY="${CUSTOS_CODE_TTY:-$(tty 2>/dev/null || echo)}"
|
|
67
|
+
export CUSTOS_CODE_MACHINE_LOG="${CUSTOS_CODE_MACHINE_LOG:-$HOME/.custos-code/machine/$(hostname -s)-$(date +%F).jsonl}"
|
|
68
|
+
mkdir -p "${CUSTOS_CODE_MACHINE_LOG:h}"
|
|
69
|
+
__custos_code_preexec() {
|
|
70
|
+
__CUSTOS_CODE_CMD=$1; __CUSTOS_CODE_T0=$EPOCHREALTIME
|
|
71
|
+
CUSTOS_CODE_EVENT=start CUSTOS_CODE_CMD="$__CUSTOS_CODE_CMD" CUSTOS_CODE_PID=$$ CUSTOS_CODE_PPID=$PPID \
|
|
72
|
+
CUSTOS_CODE_TTY="$CUSTOS_CODE_TTY" custos-code _record-line >> "$CUSTOS_CODE_MACHINE_LOG"
|
|
73
|
+
}
|
|
74
|
+
__custos_code_precmd() {
|
|
75
|
+
local rc=$?
|
|
76
|
+
[[ -z $__CUSTOS_CODE_CMD ]] && return
|
|
77
|
+
CUSTOS_CODE_EVENT=end CUSTOS_CODE_CMD="$__CUSTOS_CODE_CMD" CUSTOS_CODE_RC=$rc CUSTOS_CODE_T0="$__CUSTOS_CODE_T0" \
|
|
78
|
+
CUSTOS_CODE_PID=$$ CUSTOS_CODE_PPID=$PPID CUSTOS_CODE_TTY="$CUSTOS_CODE_TTY" \
|
|
79
|
+
custos-code _record-line >> "$CUSTOS_CODE_MACHINE_LOG"
|
|
80
|
+
__CUSTOS_CODE_CMD=
|
|
81
|
+
}
|
|
82
|
+
autoload -Uz add-zsh-hook
|
|
83
|
+
add-zsh-hook preexec __custos_code_preexec
|
|
84
|
+
add-zsh-hook precmd __custos_code_precmd
|
|
85
|
+
# <<< custos-code recorder (class M) <<<
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def install_snippet(shell: str) -> str:
|
|
90
|
+
"""The rc-file lines for `shell`. Non-interactive shells need the PATH-first wrapper instead."""
|
|
91
|
+
if shell in ("bash", "sh"):
|
|
92
|
+
return BASH_SNIPPET
|
|
93
|
+
if shell == "zsh":
|
|
94
|
+
return ZSH_SNIPPET
|
|
95
|
+
raise ValueError(f"no recorder snippet for {shell!r}; supported: bash, sh, zsh")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# docs/ADAPTERS.md §4 promises this ("non-interactive shells: a PATH-first bash and sh wrapper
|
|
99
|
+
# that logs argv and exit status and execs the real shell") and §7's VERIFY names exactly the gap
|
|
100
|
+
# it closes: "DEBUG trap behaviour inside the shells Claude Code and Codex spawn (they run
|
|
101
|
+
# `bash -c`/`zsh -lc`)". They do not source ~/.bashrc -- POSIX shells only read startup files for
|
|
102
|
+
# interactive or login shells, and `bash -c "cmd"` is neither -- so BASH_SNIPPET's `trap ... DEBUG`
|
|
103
|
+
# and ZSH_SNIPPET's `preexec` hook never attach inside a command an agent spawns this way. Nothing
|
|
104
|
+
# in this module implemented the wrapper before now; `install_snippet` only ever returned the
|
|
105
|
+
# interactive rc-file forms.
|
|
106
|
+
#
|
|
107
|
+
# `{real}` is resolved once, at install time, to an absolute path outside the wrapper's own
|
|
108
|
+
# directory (the same "bake in the real path instead of re-resolving through PATH" idiom already
|
|
109
|
+
# used by `hooks._run.sh`'s `custos_code_cmd` and `rerun._worker_argv`) -- re-resolving "bash" via
|
|
110
|
+
# PATH inside the wrapper would just find itself again if its own directory is still first.
|
|
111
|
+
#
|
|
112
|
+
# The shebang is `#!/bin/bash`, not `#!/usr/bin/env bash`: `env` re-resolves `bash` through PATH,
|
|
113
|
+
# and `--wrapper --install` puts this wrapper's own directory *first* on PATH, so `env` would find
|
|
114
|
+
# the wrapper again, whose shebang runs `env bash` again -- forever. `REAL` is baked in precisely
|
|
115
|
+
# to avoid this trap for the interpreter *inside* the script; the shebang needs the same treatment
|
|
116
|
+
# for the interpreter that runs the script itself, and unlike `REAL` it cannot be resolved at
|
|
117
|
+
# install time (it has to be correct before the script has run a single line), so it is the one
|
|
118
|
+
# absolute path in this file that is not `which`-resolved -- `/bin/bash` is as close to universal
|
|
119
|
+
# as a hardcoded path gets on the platforms this targets.
|
|
120
|
+
#
|
|
121
|
+
# The two `custos-code _record-line` calls redirect stderr to /dev/null *before* redirecting stdout
|
|
122
|
+
# to the log (`2>/dev/null >> "$LOG"`, not `>> "$LOG" 2>/dev/null`): bash sets up redirections in
|
|
123
|
+
# order, so if the log's directory does not exist, the `>>` open failure is itself an error, and
|
|
124
|
+
# whichever fd swap happened first decides where that error goes. With `2>/dev/null` first, it's
|
|
125
|
+
# already gone before the failing `>>` has anywhere else to send it. The same reasoning is why
|
|
126
|
+
# `mkdir -p` gets its own `2>/dev/null`: with no redirect at all, a permission-denied `mkdir` would
|
|
127
|
+
# print straight to the wrapped command's own stderr -- exactly the failure mode `3433813` fixed
|
|
128
|
+
# elsewhere (custos-code' own instrumentation manufacturing the evidence a rule then judges).
|
|
129
|
+
#
|
|
130
|
+
# `CUSTOS_CODE_MACHINE_LOG` is assigned, not exported: exporting it would hand every child process
|
|
131
|
+
# (including `$REAL "$@"` and everything it spawns) the ledger's own path, letting an agent that
|
|
132
|
+
# only needed to run a command also overwrite or forge rows in the log describing it. Making that
|
|
133
|
+
# safe against a *deliberately* adversarial agent needs harness signatures and per-row provenance
|
|
134
|
+
# marking -- real design work, tracked separately -- so this only closes the accidental case for
|
|
135
|
+
# now: nothing downstream of the wrapper can find the path by looking at its own environment.
|
|
136
|
+
WRAPPER_TEMPLATE = r"""#!/bin/bash
|
|
137
|
+
# >>> custos-code recorder (class M), PATH-first wrapper >>>
|
|
138
|
+
# Installed by `custos-code record --wrapper`; intercepts a PATH lookup for {name} that an
|
|
139
|
+
# agent-spawned, non-interactive shell (`{name} -c "cmd"`) would otherwise resolve straight to the
|
|
140
|
+
# real interpreter, invisibly to install_snippet's rc-file hooks. Logs start/end the same way the
|
|
141
|
+
# interactive snippets do (`custos-code _record-line`, same wire format), then runs the real {name}
|
|
142
|
+
# and exits with its exact status. Never captures stdout/stderr: those pass straight through.
|
|
143
|
+
REAL={real}
|
|
144
|
+
case "$1" in
|
|
145
|
+
-*c*) if [ "$#" -ge 2 ]; then __CUSTOS_CODE_CMD="$2"; else __CUSTOS_CODE_CMD="$*"; fi ;;
|
|
146
|
+
# a login/command flag bundle (-c, -lc, -ic, ...) carries the command as $2; anything else
|
|
147
|
+
# (including a script on stdin, which has no argv command at all) falls back to argv itself.
|
|
148
|
+
*) __CUSTOS_CODE_CMD="$*" ;;
|
|
149
|
+
esac
|
|
150
|
+
CUSTOS_CODE_MACHINE_LOG="${{CUSTOS_CODE_MACHINE_LOG:-$HOME/.custos-code/machine/$(hostname -s)-$(date +%F).jsonl}}"
|
|
151
|
+
mkdir -p "$(dirname "$CUSTOS_CODE_MACHINE_LOG")" 2>/dev/null || true
|
|
152
|
+
__T0=$(date +%s.%N)
|
|
153
|
+
CUSTOS_CODE_EVENT=start CUSTOS_CODE_CMD="$__CUSTOS_CODE_CMD" CUSTOS_CODE_PID=$$ CUSTOS_CODE_PPID=$PPID \
|
|
154
|
+
custos-code _record-line 2>/dev/null >> "$CUSTOS_CODE_MACHINE_LOG" || true
|
|
155
|
+
"$REAL" "$@"
|
|
156
|
+
__RC=$?
|
|
157
|
+
CUSTOS_CODE_EVENT=end CUSTOS_CODE_CMD="$__CUSTOS_CODE_CMD" CUSTOS_CODE_RC=$__RC CUSTOS_CODE_T0="$__T0" \
|
|
158
|
+
CUSTOS_CODE_PID=$$ CUSTOS_CODE_PPID=$PPID \
|
|
159
|
+
custos-code _record-line 2>/dev/null >> "$CUSTOS_CODE_MACHINE_LOG" || true
|
|
160
|
+
exit $__RC
|
|
161
|
+
# <<< custos-code recorder (class M) <<<
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
WRAPPER_DIR = os.path.expanduser("~/.custos-code/bin")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def wrapper_script(name: str, real_path: str) -> str:
|
|
168
|
+
"""Render the PATH-first wrapper for `name` (`bash` or `sh`), calling through to `real_path`."""
|
|
169
|
+
import shlex
|
|
170
|
+
|
|
171
|
+
return WRAPPER_TEMPLATE.format(name=name, real=shlex.quote(real_path))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def install_wrapper(bin_dir: str | None = None, which: Any = None) -> dict[str, str]:
|
|
175
|
+
"""Write `bash`/`sh` wrapper scripts into `bin_dir` (default `~/.custos-code/bin`), executable,
|
|
176
|
+
each baked with the real interpreter's current, already-resolved absolute path. Returns
|
|
177
|
+
{name: written_path}; raises FileNotFoundError naming whichever of bash/sh isn't on PATH at
|
|
178
|
+
all, since a wrapper with nothing real to call through to would only break the shell.
|
|
179
|
+
|
|
180
|
+
Installing the *directory* onto PATH (ahead of the system one) is the caller's job -- this
|
|
181
|
+
only ever writes files under `bin_dir`, never touches PATH, an rc file, or anything outside it.
|
|
182
|
+
"""
|
|
183
|
+
import shutil as _shutil
|
|
184
|
+
import stat
|
|
185
|
+
|
|
186
|
+
which = which or _shutil.which
|
|
187
|
+
target = bin_dir or WRAPPER_DIR
|
|
188
|
+
os.makedirs(target, exist_ok=True)
|
|
189
|
+
written: dict[str, str] = {}
|
|
190
|
+
for name in ("bash", "sh"):
|
|
191
|
+
real = which(name)
|
|
192
|
+
if not real or os.path.dirname(os.path.abspath(real)) == os.path.abspath(target):
|
|
193
|
+
raise FileNotFoundError(
|
|
194
|
+
f"no real {name!r} found on PATH outside {target} to wrap -- refusing to install "
|
|
195
|
+
"a wrapper that could only ever call itself"
|
|
196
|
+
)
|
|
197
|
+
path = os.path.join(target, name)
|
|
198
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
199
|
+
fh.write(wrapper_script(name, os.path.abspath(real)))
|
|
200
|
+
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
201
|
+
written[name] = path
|
|
202
|
+
return written
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def default_log(now: datetime | None = None) -> str:
|
|
206
|
+
stamp = (now or datetime.now()).strftime("%Y-%m-%d")
|
|
207
|
+
return os.path.join(LOG_DIR, f"{socket.gethostname().split('.')[0]}-{stamp}.jsonl")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def record_line(env: dict[str, str] | None = None) -> str:
|
|
211
|
+
"""Build one wire line from the recorder's environment. Called by `custos-code _record-line`."""
|
|
212
|
+
e = dict(os.environ if env is None else env)
|
|
213
|
+
event = e.get("CUSTOS_CODE_EVENT", "end")
|
|
214
|
+
now = float(e.get("CUSTOS_CODE_TS") or datetime.now(tz=UTC).timestamp())
|
|
215
|
+
# the snippets pass the shell's own pid; without them, this process's parent *is* that shell
|
|
216
|
+
pid = _pid(e.get("CUSTOS_CODE_PID", "")) or os.getppid()
|
|
217
|
+
line: dict[str, Any] = {
|
|
218
|
+
"recorder": "custos-code-machine",
|
|
219
|
+
"v": WIRE_VERSION,
|
|
220
|
+
"event": event,
|
|
221
|
+
"ts": now,
|
|
222
|
+
"cmd": e.get("CUSTOS_CODE_CMD", ""),
|
|
223
|
+
"cwd": e.get("PWD", ""),
|
|
224
|
+
"tty": e.get("CUSTOS_CODE_TTY", ""),
|
|
225
|
+
"pid": pid,
|
|
226
|
+
"ppid": _pid(e.get("CUSTOS_CODE_PPID", "")),
|
|
227
|
+
}
|
|
228
|
+
if event == "end":
|
|
229
|
+
rc = e.get("CUSTOS_CODE_RC", "")
|
|
230
|
+
line["exit"] = int(rc) if rc.lstrip("-").isdigit() else None
|
|
231
|
+
try:
|
|
232
|
+
line["dur_ms"] = int((now - float(e["CUSTOS_CODE_T0"])) * 1000)
|
|
233
|
+
except (KeyError, ValueError):
|
|
234
|
+
line["dur_ms"] = None
|
|
235
|
+
return json.dumps(redact(line), sort_keys=True)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _pid(value: str) -> int | None:
|
|
239
|
+
return int(value) if value.isdigit() else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _paths(record: dict[str, Any], cwd: str | None) -> list[str]:
|
|
243
|
+
path = record.get("path")
|
|
244
|
+
if not isinstance(path, str) or not path:
|
|
245
|
+
return []
|
|
246
|
+
return [path if os.path.isabs(path) or not cwd else os.path.normpath(os.path.join(cwd, path))]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
|
|
250
|
+
"""Machine log -> ledger. There is no report here: class M gets it from the UI or the PR."""
|
|
251
|
+
events: list[LedgerEvent] = []
|
|
252
|
+
seq = 0
|
|
253
|
+
started: datetime | None = None
|
|
254
|
+
ended = datetime.fromtimestamp(0, tz=UTC)
|
|
255
|
+
cwd: str | None = None
|
|
256
|
+
host = os.path.basename(path).rsplit("-", 3)[0]
|
|
257
|
+
session_id = f"machine:{os.path.basename(path)}"
|
|
258
|
+
open_calls: dict[str, int] = {}
|
|
259
|
+
|
|
260
|
+
with open(path, encoding="utf-8") as fh:
|
|
261
|
+
for line in fh:
|
|
262
|
+
line = line.strip()
|
|
263
|
+
if not line:
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
rec = json.loads(line)
|
|
267
|
+
except json.JSONDecodeError:
|
|
268
|
+
continue
|
|
269
|
+
if not isinstance(rec, dict) or rec.get("recorder") not in RECORDER_NAMES:
|
|
270
|
+
continue
|
|
271
|
+
ts = datetime.fromtimestamp(float(rec.get("ts") or 0), tz=UTC)
|
|
272
|
+
started = started or ts
|
|
273
|
+
ended = max(ended, ts)
|
|
274
|
+
cwd = str(rec.get("cwd") or cwd or "") or None
|
|
275
|
+
kind_name = str(rec.get("event", ""))
|
|
276
|
+
command = str(rec.get("cmd", ""))
|
|
277
|
+
key = f"{rec.get('pid')}:{command}"
|
|
278
|
+
|
|
279
|
+
if kind_name == "start":
|
|
280
|
+
events.append(
|
|
281
|
+
LedgerEvent(
|
|
282
|
+
seq=seq,
|
|
283
|
+
ts=ts,
|
|
284
|
+
session_id=session_id,
|
|
285
|
+
kind=EventKind.CALL,
|
|
286
|
+
tool="Bash",
|
|
287
|
+
cwd=cwd,
|
|
288
|
+
input=redact(
|
|
289
|
+
{"command": command, "ppid_chain": rec.get("ppid_chain") or []}
|
|
290
|
+
),
|
|
291
|
+
flags=EventFlags(piped=is_piped(command)),
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
open_calls[key] = seq
|
|
295
|
+
seq += 1
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
if kind_name == "end":
|
|
299
|
+
exit_code = rec.get("exit")
|
|
300
|
+
event = LedgerEvent(
|
|
301
|
+
seq=seq,
|
|
302
|
+
ts=ts,
|
|
303
|
+
session_id=session_id,
|
|
304
|
+
kind=EventKind.RESULT,
|
|
305
|
+
tool="Bash",
|
|
306
|
+
cwd=cwd,
|
|
307
|
+
input=redact({"command": command}),
|
|
308
|
+
exit_code=int(exit_code) if isinstance(exit_code, int) else None,
|
|
309
|
+
duration_ms=rec.get("dur_ms") if isinstance(rec.get("dur_ms"), int) else None,
|
|
310
|
+
flags=EventFlags(piped=is_piped(command), error=bool(exit_code)),
|
|
311
|
+
)
|
|
312
|
+
out = rec.get("out")
|
|
313
|
+
if isinstance(out, str) and out:
|
|
314
|
+
blob = str(redact(out))
|
|
315
|
+
raw = blob.encode()
|
|
316
|
+
event.output_hash = hashlib.sha256(raw).hexdigest()
|
|
317
|
+
event.output = raw[:MAX_OUTPUT_BYTES].decode(errors="ignore")
|
|
318
|
+
event.flags.truncated = len(raw) > MAX_OUTPUT_BYTES
|
|
319
|
+
else:
|
|
320
|
+
# the shell trap sees status, never stdout: outcome is known, output is not
|
|
321
|
+
event.flags.stderr_dropped = True
|
|
322
|
+
open_calls.pop(key, None)
|
|
323
|
+
events.append(event)
|
|
324
|
+
seq += 1
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
if kind_name == "fs":
|
|
328
|
+
events.append(
|
|
329
|
+
LedgerEvent(
|
|
330
|
+
seq=seq,
|
|
331
|
+
ts=ts,
|
|
332
|
+
session_id=session_id,
|
|
333
|
+
kind=EventKind.RESULT,
|
|
334
|
+
tool="Edit",
|
|
335
|
+
cwd=cwd,
|
|
336
|
+
paths=_paths(rec, cwd),
|
|
337
|
+
input={"op": str(rec.get("op", "modified")), "watcher": "fs"},
|
|
338
|
+
exit_code=0,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
seq += 1
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if kind_name == "git":
|
|
345
|
+
events.append(
|
|
346
|
+
LedgerEvent(
|
|
347
|
+
seq=seq,
|
|
348
|
+
ts=ts,
|
|
349
|
+
session_id=session_id,
|
|
350
|
+
kind=EventKind.RESULT,
|
|
351
|
+
tool="Git",
|
|
352
|
+
cwd=cwd,
|
|
353
|
+
exit_code=0,
|
|
354
|
+
input=redact(
|
|
355
|
+
{
|
|
356
|
+
"ref": str(rec.get("ref", "")),
|
|
357
|
+
"from": str(rec.get("from", "")),
|
|
358
|
+
"to": str(rec.get("to", "")),
|
|
359
|
+
"subject": str(rec.get("subject", "")),
|
|
360
|
+
}
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
seq += 1
|
|
365
|
+
|
|
366
|
+
chained = chain(events)
|
|
367
|
+
meta = Session(
|
|
368
|
+
id=session_id,
|
|
369
|
+
source="machine",
|
|
370
|
+
agent=f"unknown ({host})",
|
|
371
|
+
started=started,
|
|
372
|
+
ended=ended,
|
|
373
|
+
cwd=cwd,
|
|
374
|
+
n_events=len(chained),
|
|
375
|
+
ledger_root_hash=chained[-1].hash if chained else "",
|
|
376
|
+
# no tool attribution and no captured stdout: honest about what this class can settle
|
|
377
|
+
integrity_score=0.6,
|
|
378
|
+
)
|
|
379
|
+
return meta, chained, None
|