Skip to content

Commit 89ebfe6

Browse files
barisozbaskrishnesh1claude
authored
feat(sensor): add opencode support and complete the platform matrix (#30)
* feat(sensor): add opencode support and complete the platform matrix Brings the open-source Sensor up to date with the agents and operating systems it now needs to cover. All changes are additive: no source key, session-id format or event uuid changes, so existing detection pipelines are unaffected. New source: opencode (github.com/sst/opencode) Reads both storage backends — the current SQLite database (opencode.db, or opencode-<channel>.db on non-stable channels, opened read-only) and the older JSON file tree in both its project-scoped and legacy layouts. Honors $XDG_DATA_HOME and $OPENCODE_DB. Tools are classified against the built-in registry, so anything else with an underscore is recorded as an MCP tool with its server_name split out. Windows support for the parsers that were missing it cursor ~/AppData/Roaming/Cursor/User/globalStorage/state.vscdb cline ~/AppData/Roaming/Cursor/User/globalStorage/saoudrizwan.claude-dev/tasks warp ~/AppData/Local/warp/Warp/data/warp.sqlite desktop ~/AppData/Roaming/Claude/local-agent-mode-sessions Each parser now resolves against an ordered candidate list instead of an either/or pair. Warp also gains the sandboxed macOS group-container path (~/Library/Group Containers/2BBY89MBSN.dev.warp/...), which is where the database actually lives on current builds — it was previously unreachable. Claude Desktop agent mode: Dispatch sessions Delegated background agents are stored one level deeper, under agent/local_ditto_<uuid>/, and were not discovered at all. They now surface under the existing claude_desktop source with a claude_desktop_dispatch_ session-id prefix and an is_dispatch flag, so unattended runs can be scored separately from interactive ones. Interactive session ids are unchanged. Session context also picks up plugins, skills, claude_code_version, the memory/skills/plugins toggles and the available slash commands. Observer: ingest_all() iterates a SOURCES table and resolves each parser as self.<source>_parser, replacing seven near-identical branches. Platform-only sources are declared in PLATFORM_RESTRICTED_SOURCES and the CLI derives its --source choices from SOURCES, so adding an agent touches neither. Also: 44 new tests, a Python 3.9-3.13 CI matrix for the Sensor job (the package already declared >=3.9 but only 3.12 was exercised), OS and Python classifiers in pyproject, and docs for the new source, platform matrix, session_context and environment variables. * fix(sensor): resolve Windows app-data roots from the environment; test 3.10 Addresses review on #30. %APPDATA% and %LOCALAPPDATA% point outside the user profile on roaming-profile and redirected-folder setups, so the profile-relative AppData paths added for Cursor, Cline, Warp and Claude Desktop would silently find nothing on exactly the managed Windows fleets this sensor targets. The new utils/platform_paths.py consults the environment first and keeps the profile-relative location only as a fallback, matching how the parsers already honor XDG_DATA_HOME / XDG_CACHE_HOME / OPENCODE_DB. Adopts the env-first approach from #25 by @krishnesh1, and extends it beyond Cursor/Cline to Warp (%LOCALAPPDATA%) and Claude Desktop. Closes the failure mode reported in #21 by @Rahul-s-007. Also adds Python 3.10 to the Sensor CI matrix so every version advertised in the package classifiers is exercised, documents APPDATA/LOCALAPPDATA in the Sensor README, and adds six tests covering env-set/unset/empty resolution plus an end-to-end redirected-%APPDATA% discovery test. Co-authored-by: krishnesh1 <krishnesh1@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(sensor): report aggregate "Sensor tests" check for branch protection Branch protection requires a status context named exactly "Sensor tests" - the job name before it became a per-version matrix. The matrix legs report as "Sensor tests (py3.X)", so the required context was never reported and the PR sat at "Expected - Waiting for status to be reported" with every real check green. Add a gate job with the original name that needs the matrix and fails unless every leg succeeded. Runs under if: always() because a skipped job would otherwise satisfy the required check even when a leg fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: krishnesh1 <krishnesh1@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6c3b2ff commit 89ebfe6

18 files changed

Lines changed: 2035 additions & 336 deletions

.github/workflows/ci.yml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,12 @@ jobs:
3939
run: uv run pytest tests/ -q
4040

4141
test-sensor:
42-
name: Sensor tests
42+
name: Sensor tests (py${{ matrix.python-version }})
4343
runs-on: ubuntu-latest
44+
strategy:
45+
fail-fast: false
46+
matrix:
47+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
4448
defaults:
4549
run:
4650
working-directory: Sensor
@@ -54,13 +58,27 @@ jobs:
5458
enable-cache: true
5559

5660
- name: Set up Python
57-
run: uv python install 3.12
61+
run: uv python install ${{ matrix.python-version }}
5862

5963
- name: Install dependencies
60-
run: uv sync --extra dev
64+
run: uv sync --extra dev --python ${{ matrix.python-version }}
6165

6266
- name: Run tests
63-
run: uv run pytest tests/ -q
67+
run: uv run --python ${{ matrix.python-version }} pytest tests/ -q
6468

6569
- name: Build distributions
70+
if: matrix.python-version == '3.12'
6671
run: uv build
72+
73+
# Aggregate gate for the matrix above. Branch protection requires a check
74+
# named exactly "Sensor tests" (the job's pre-matrix name); this reports it.
75+
# `if: always()` matters: without it a failed leg would leave this job
76+
# skipped, and skipped jobs satisfy required checks.
77+
test-sensor-gate:
78+
name: Sensor tests
79+
runs-on: ubuntu-latest
80+
needs: test-sensor
81+
if: always()
82+
steps:
83+
- name: All Sensor matrix legs must pass
84+
run: test "${{ needs.test-sensor.result }}" = "success"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This repository contains the open-source **ADR Sensor**, **ADR-Bench**, and **AD
1919

2020
| Path | ADR component | Description |
2121
| -------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------ |
22-
| [Sensor/](Sensor/) | ADR Observability | Collect and normalize agent telemetry from Claude Code, Cursor, Codex, and others |
22+
| [Sensor/](Sensor/) | ADR Observability | Collect and normalize agent telemetry from Claude Code, Cursor, Codex, opencode, Claude Desktop, and others |
2323
| [Detection/](Detection/) | ADR Benchmark + Detection | Dual-agent detector, 133 MCP servers, 303 benchmark tasks, baselines, figure scripts |
2424
| [docs/REPRODUCIBILITY.md](docs/REPRODUCIBILITY.md) | Evaluation | Step-by-step workflow to reproduce benchmark detection and paper figures |
2525

Sensor/CONTRIBUTING.md

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,35 +73,57 @@ class MyAgentParser(BaseParser):
7373

7474
### Step 2: Register in Observer
7575

76-
Add your parser to `adr_sensor/observer.py`:
76+
Export the parser from `adr_sensor/parsers/__init__.py`, then register it in
77+
`adr_sensor/observer.py`:
7778

7879
```python
7980
from .parsers.my_agent_parser import MyAgentParser
8081

81-
# In __init__:
82-
self.my_agent_parser = MyAgentParser()
83-
84-
# In ingest_all:
85-
if source_filter in ["all", "my_agent"]:
86-
print("Ingesting MyAgent logs...")
87-
try:
88-
entries = self.my_agent_parser.parse_all()
89-
filtered = [e for e in entries if e.has_meaningful_content()]
90-
all_entries.extend(filtered)
91-
except Exception as e:
92-
print(f"Error ingesting MyAgent logs: {e}")
82+
83+
class AgentObserver:
84+
SOURCES = (
85+
...,
86+
("my_agent", "MyAgent"), # (source key, display label)
87+
)
88+
89+
def __init__(self, ...):
90+
...
91+
# The parser must be named <source key>_parser
92+
self.my_agent_parser = MyAgentParser()
93+
```
94+
95+
`ingest_all()` iterates `SOURCES` and resolves each parser as
96+
`self.<source>_parser`, so no per-source branch is needed — it handles the
97+
`has_meaningful_content()` filter, error isolation and `error.log` reporting for you.
98+
99+
If the agent only exists on some operating systems, add it to
100+
`PLATFORM_RESTRICTED_SOURCES` so it is skipped elsewhere instead of failing:
101+
102+
```python
103+
PLATFORM_RESTRICTED_SOURCES = {
104+
"claude_desktop": ("Darwin", "Windows"),
105+
"my_agent": ("Darwin",),
106+
}
93107
```
94108

95109
### Step 3: Add CLI Source
96110

97-
Update `adr_sensor/cli.py` to add the new source choice.
111+
Nothing to do — `adr_sensor/cli.py` builds its `--source` choices from
112+
`AgentObserver.SOURCES`. Add an example line to the CLI epilog if the new source
113+
needs explanation.
114+
115+
Source keys are part of the Sensor's public contract — downstream detection
116+
pipelines filter on them. Treat renaming one as a breaking change and avoid it;
117+
prefer adding a new key alongside the existing one.
98118

99119
### Step 4: Write Tests
100120

101-
Create `tests/test_my_agent_parser.py` with test cases covering:
121+
Add test cases to `tests/test_parsers.py` (or `tests/test_my_agent_parser.py` for a
122+
larger parser) covering:
102123
- Parsing valid log files
103124
- Handling missing directories
104125
- Handling malformed data
126+
- Age filtering, if the parser supports `max_age_days`
105127
- Edge cases
106128

107129
## Code Style

Sensor/README.md

Lines changed: 149 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -10,46 +10,76 @@ ADR Sensor is a Python library that collects telemetry from AI coding agents to
1010
## Supported AI Agents
1111

1212

13-
| Agent | Log Format | Platform |
14-
| ----------------------------- | ----------------------------- | ------------ |
15-
| **Claude Code** | JSONL (`~/.claude/projects/`) | macOS, Linux |
16-
| **Cursor IDE** | SQLite (`state.vscdb`) | macOS, Linux |
17-
| **Cline (Claude Dev)** | JSON task files | macOS, Linux |
18-
| **Claude Desktop Agent Mode** | JSONL audit logs | macOS |
19-
| **OpenAI Codex CLI** | JSONL (`~/.codex/sessions/`) | macOS, Linux |
20-
| **Warp Terminal** | SQLite (`warp.sqlite`) | macOS |
13+
| Agent | Source key | Log Format | Platform |
14+
| -------------------------- | ---------------- | ----------------------------------- | ---------------------- |
15+
| **Claude Code** | `claude` | JSONL (`~/.claude/projects/`) | macOS, Linux, Windows |
16+
| **Cursor IDE** | `cursor` | SQLite (`state.vscdb`) | macOS, Linux, Windows |
17+
| **Cline (Claude Dev)** | `cline` | JSON task files | macOS, Linux, Windows |
18+
| **Claude Desktop** | `claude_desktop` | JSONL audit logs | macOS, Windows |
19+
| **OpenAI Codex CLI** | `codex` | JSONL (`~/.codex/sessions/`) | macOS, Linux, Windows |
20+
| **Warp Terminal** | `warp` | SQLite (`warp.sqlite`) | macOS, Windows |
21+
| **opencode** | `opencode` | SQLite (`opencode.db`) or JSON tree | macOS, Linux |
22+
23+
### Claude Desktop Agent Mode
24+
25+
The `claude_desktop` source covers Claude Desktop's local agent mode (released as
26+
Claude Cowork), on both macOS and Windows. Two kinds of session are captured:
27+
28+
- **Interactive sessions**`.../local-agent-mode-sessions/<user>/<org>/local_<uuid>/audit.jsonl`
29+
- **Dispatch sessions** (delegated background agents) — `.../<user>/<org>/agent/local_ditto_<uuid>/audit.jsonl`
30+
31+
Both emit `source: "claude_desktop"`. Dispatch sessions get a distinct
32+
`claude_desktop_dispatch_` session-id prefix and an `is_dispatch: true` flag in
33+
`session_context`, so detection rules can treat unattended runs differently from
34+
interactive ones. Interactive session ids are unchanged.
35+
36+
### opencode
37+
38+
[opencode](https://github.com/sst/opencode) uses the XDG layout on every platform,
39+
so its data directory is `~/.local/share/opencode` on both Linux and macOS
40+
(`$XDG_DATA_HOME` and `$OPENCODE_DB` are honored when set). Both storage backends
41+
are read:
42+
43+
- **SQLite** (current releases) — `opencode.db`, or `opencode-<channel>.db` on
44+
non-stable channels. Opened read-only so a running opencode process is never disturbed.
45+
- **JSON file tree** (older releases) — a `storage/` directory of per-session,
46+
per-message and per-part JSON files, in both the project-scoped and legacy layouts.
47+
48+
MCP tools are namespaced by opencode as `<server>_<tool>`, so any tool that is not a
49+
known built-in and contains an underscore is recorded as `tool_type: "mcp_tool"` with
50+
its `server_name` populated.
2151

2252

2353
## Architecture
2454

2555
```
26-
┌─────────────────────────────────────────────────────────┐
27-
│ AI Agent Logs │
28-
Claude Code │ Cursor │ Cline │ Codex │ Warp │ Desktop
29-
└──────┬──────┴───┬────┴───┬───┴───┬───┴──┬───┴────┬─────┘
30-
│ │ │ │ │
31-
▼ ▼ ▼ ▼ ▼
32-
┌─────────────────────────────────────────────────────────┐
33-
│ Source-Specific Parsers │
34-
│ (Each implements BaseParser)
35-
└─────────────────────┬───────────────────────────────────┘
36-
37-
38-
┌─────────────────────────────────────────────────────────┐
39-
│ Unified Schema (AgentEvent) │
40-
│ session_id │ timestamp │ chat_history │ tools │ model │
41-
└─────────────────────┬───────────────────────────────────┘
42-
43-
44-
┌─────────────────────────────────────────────────────────┐
45-
│ AgentObserver (Orchestrator) │
46-
│ Ingest → Filter → Display → Export │
47-
└─────────────────────┬───────────────────────────────────┘
48-
49-
┌───────┴───────┐
50-
▼ ▼
51-
JSON/JSONL Your Detection
52-
Export Pipeline / SIEM
56+
┌─────────────────────────────────────────────────────────────────
57+
AI Agent Logs
58+
│ Claude Code │ Cursor │ Cline │ Codex │ Warp │ Desktop │ opencode
59+
└──────┬──────┴───┬────┴───┬───┴───┬───┴──┬───┴───┬────┴─────┬────┘
60+
│ │ │ │ │
61+
▼ ▼ ▼ ▼ ▼
62+
┌─────────────────────────────────────────────────────────────────
63+
Source-Specific Parsers
64+
(Each implements BaseParser) │
65+
└─────────────────────────────┬───────────────────────────────────┘
66+
67+
68+
┌─────────────────────────────────────────────────────────────────
69+
Unified Schema (AgentEvent)
70+
session_id │ timestamp │ chat_history │ tools │ model
71+
└─────────────────────────────┬───────────────────────────────────┘
72+
73+
74+
┌─────────────────────────────────────────────────────────────────
75+
AgentObserver (Orchestrator)
76+
Ingest → Filter → Display → Export
77+
└─────────────────────────────┬───────────────────────────────────┘
78+
79+
┌───────┴───────┐
80+
▼ ▼
81+
JSON/JSONL Your Detection
82+
Export Pipeline / SIEM
5383
```
5484

5585
## Quick Start
@@ -80,6 +110,8 @@ adr-sensor
80110
adr-sensor --source claude
81111
adr-sensor --source cursor
82112
adr-sensor --source codex
113+
adr-sensor --source claude_desktop
114+
adr-sensor --source opencode
83115

84116
# Save individual session files (incremental)
85117
adr-sensor --save-sessions
@@ -94,6 +126,9 @@ adr-sensor --all-history
94126
adr-sensor --output-dir ./my-output
95127
```
96128

129+
Sources whose agent only runs on some operating systems are skipped automatically
130+
on other platforms — `--source all` on Linux will not attempt `claude_desktop`, for example.
131+
97132
### Python API
98133

99134
```python
@@ -167,45 +202,110 @@ Each parsed session produces an `AgentEvent` with the following structure:
167202
}
168203
```
169204

205+
### `session_context`
206+
207+
Parsers that can recover session-level configuration attach it under
208+
`session_context`. This is the agent's own view of what it was allowed to do, which
209+
is often more useful for detection than the conversation itself. Claude Desktop
210+
agent mode populates the richest version:
211+
212+
```json
213+
{
214+
"session_context": {
215+
"title": "Config review",
216+
"is_dispatch": true,
217+
"session_type": "dispatch",
218+
"cli_session_id": "cli-99",
219+
"memory_enabled": true,
220+
"skills_enabled": false,
221+
"plugins_enabled": true,
222+
"available_slash_commands": ["review", "deploy"],
223+
"init": {
224+
"tools": ["Bash", "Read"],
225+
"mcp_servers": [{"name": "github"}],
226+
"permission_mode": "acceptEdits",
227+
"model": "claude-sonnet-4",
228+
"claude_code_version": "2.1.0",
229+
"plugins": ["reviewer"],
230+
"skills": ["pdf"]
231+
}
232+
}
233+
}
234+
```
235+
170236
## Adding a New Parser
171237

172238
ADR Sensor is designed to be extensible. To add support for a new AI agent:
173239

174240
1. Create a new parser in `adr_sensor/parsers/`:
175241

176242
```python
243+
from pathlib import Path
244+
177245
from adr_sensor.parsers.base_parser import BaseParser
178246
from adr_sensor.schemas.agent_event_schema import AgentEvent, ChatMessage, ToolUsage
179247

248+
180249
class MyAgentParser(BaseParser):
181-
def __init__(self):
250+
def __init__(self, max_age_days: int = 14):
182251
self.base_path = Path.home() / ".my-agent/logs"
252+
self.max_age_days = max_age_days
183253

184254
def parse_all(self) -> list[AgentEvent]:
185255
entries = []
186-
# Parse your agent's log files
187-
# Convert to AgentEvent objects
256+
# Parse your agent's log files and convert them to AgentEvent objects
188257
return entries
189258
```
190259

191-
1. Register it in `adr_sensor/observer.py`:
260+
2. Export it from `adr_sensor/parsers/__init__.py`, then register it in
261+
`adr_sensor/observer.py` by constructing it as `self.<source>_parser` and adding
262+
the source key to `AgentObserver.SOURCES`:
192263

193264
```python
194-
from .parsers.my_agent_parser import MyAgentParser
195-
196265
class AgentObserver:
266+
SOURCES = (
267+
...,
268+
("my_agent", "My Agent"),
269+
)
270+
197271
def __init__(self, ...):
198272
...
199273
self.my_agent_parser = MyAgentParser()
200-
201-
def ingest_all(self, source_filter="all"):
202-
...
203-
if source_filter in ["all", "my_agent"]:
204-
entries = self.my_agent_parser.parse_all()
205-
all_entries.extend(entries)
206274
```
207275

208-
1. Add tests in `tests/`.
276+
`ingest_all()` walks `SOURCES` and looks the parser up as `self.<source>_parser`, so
277+
no per-source branch is needed. If the agent only exists on some operating systems,
278+
add it to `PLATFORM_RESTRICTED_SOURCES` and it will be skipped elsewhere. The CLI
279+
builds its `--source` choices from `SOURCES`, so it picks the new agent up for free.
280+
281+
3. Add tests in `tests/`.
282+
283+
## Environment
284+
285+
### Runtime support
286+
287+
| | |
288+
| --------------- | ------------------------------------------- |
289+
| Python | 3.9, 3.10, 3.11, 3.12, 3.13 |
290+
| Operating system| macOS, Linux, Windows |
291+
| Dependencies | `tabulate` (runtime only — no native deps) |
292+
293+
Which sources yield data depends on the host OS and on which agents are installed;
294+
see the platform column in [Supported AI Agents](#supported-ai-agents). Sources that
295+
cannot run on the current platform are skipped rather than failing.
296+
297+
### Environment variables
298+
299+
| Variable | Read by | Effect |
300+
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
301+
| `XDG_CACHE_HOME` | `AgentObserver` | Base for `--save-sessions` output (`$XDG_CACHE_HOME/adr_sensor`, default `~/.cache/adr_sensor`) |
302+
| `XDG_DATA_HOME` | opencode parser | Overrides the opencode data directory (default `~/.local/share/opencode`) |
303+
| `OPENCODE_DB` | opencode parser | Overrides the opencode SQLite filename or path (`:memory:` is ignored) |
304+
| `APPDATA` | Cursor, Cline, Claude Desktop parsers | Windows roaming app-data root. Consulted first so redirected/roaming profiles resolve correctly (default `~/AppData/Roaming`) |
305+
| `LOCALAPPDATA` | Warp parser | Windows local app-data root, same redirected-profile handling (default `~/AppData/Local`) |
306+
307+
Errors during ingestion never abort the run: each source is isolated, and failures
308+
are appended as single-line JSON records to `error.log` in the output directory.
209309

210310
## Security Use Cases
211311

@@ -249,6 +349,7 @@ adr-sensor/
249349
│ │ ├── cline_parser.py
250350
│ │ ├── claude_desktop_parser.py
251351
│ │ ├── codex_parser.py
352+
│ │ ├── opencode_parser.py
252353
│ │ └── warp_parser.py
253354
│ ├── schemas/
254355
│ │ ├── agent_event_schema.py # AgentEvent, ChatMessage, ToolUsage

0 commit comments

Comments
 (0)