diff-sentry 0.4.2__tar.gz → 0.5.0__tar.gz

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.
Files changed (111) hide show
  1. diff_sentry-0.5.0/.claude/rules/detect-setup.md +23 -0
  2. diff_sentry-0.5.0/.claude/rules/handoff.md +45 -0
  3. diff_sentry-0.5.0/.claude/rules/indicators.md +42 -0
  4. diff_sentry-0.5.0/.claude/rules/rubric-and-eval.md +42 -0
  5. diff_sentry-0.5.0/.claude/skills/release/SKILL.md +32 -0
  6. diff_sentry-0.5.0/.env.example +55 -0
  7. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/.github/workflows/ci.yml +17 -13
  8. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/.github/workflows/release.yml +4 -5
  9. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/.gitignore +1 -1
  10. diff_sentry-0.5.0/AGENTS.md +126 -0
  11. diff_sentry-0.5.0/CHANGELOG.md +171 -0
  12. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/PKG-INFO +20 -20
  13. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/README.md +17 -17
  14. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/VENDOR.md +5 -6
  15. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/action.yml +4 -4
  16. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/__init__.py +5 -5
  17. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/assemble.py +4 -3
  18. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/config.py +10 -12
  19. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/deep_classify.py +7 -8
  20. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/detect.py +2 -2
  21. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/indicators.py +28 -39
  22. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/ingest.py +3 -5
  23. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/normalize.py +4 -5
  24. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/response.py +8 -5
  25. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/rl_export.py +37 -8
  26. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/rubric.py +3 -5
  27. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/schema.py +9 -7
  28. diff_sentry-0.5.0/eval/README.md +113 -0
  29. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/diff_sentry_eval/cli.py +3 -2
  30. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/diff_sentry_eval/judge.py +1 -1
  31. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/diff_sentry_eval/schema.py +1 -1
  32. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/diff_sentry_eval/score.py +14 -16
  33. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/pyproject.toml +2 -3
  34. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/tests/conftest.py +5 -5
  35. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/tests/test_score.py +4 -4
  36. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/pyproject.toml +17 -20
  37. diff_sentry-0.5.0/studio/DESIGN.md +227 -0
  38. diff_sentry-0.5.0/studio/README.md +209 -0
  39. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/diff_sentry_studio/app.py +34 -13
  40. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/diff_sentry_studio/iterations.py +18 -11
  41. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/diff_sentry_studio/live.py +4 -4
  42. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/diff_sentry_studio/mapper.py +5 -5
  43. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/pyproject.toml +1 -2
  44. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/app.js +6 -3
  45. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/replay-core.js +1 -1
  46. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/run-core.js +14 -4
  47. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/style.css +3 -3
  48. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/replay-core.test.js +3 -3
  49. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/run-core.test.js +18 -0
  50. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/static-contract.test.js +3 -3
  51. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/test_app.py +42 -2
  52. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/test_iterations.py +13 -1
  53. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/test_mapper.py +9 -0
  54. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_assemble.py +2 -2
  55. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_cli.py +3 -4
  56. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_deep_classify.py +3 -3
  57. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_detect.py +1 -2
  58. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_export.py +21 -2
  59. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_forward_offline.py +5 -5
  60. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_incident_hackerbot_claw.py +8 -8
  61. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_indicators.py +9 -9
  62. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_ingest.py +2 -2
  63. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_normalize.py +1 -1
  64. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_response.py +2 -2
  65. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_subscription.py +1 -1
  66. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/uv.lock +12 -318
  67. diff_sentry-0.4.2/.claude/rules/handoff.md +0 -56
  68. diff_sentry-0.4.2/.env.example +0 -50
  69. diff_sentry-0.4.2/CHANGELOG.md +0 -248
  70. diff_sentry-0.4.2/CLAUDE.md +0 -217
  71. diff_sentry-0.4.2/eval/README.md +0 -105
  72. diff_sentry-0.4.2/studio/DESIGN.md +0 -206
  73. diff_sentry-0.4.2/studio/README.md +0 -208
  74. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/.python-version +0 -0
  75. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/LICENSE +0 -0
  76. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/__main__.py +0 -0
  77. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/cli.py +0 -0
  78. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/emit.py +0 -0
  79. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/fetch_tool.py +0 -0
  80. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/skills/ci-injection-patterns.md +0 -0
  81. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
  82. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/diff_sentry/skills/triage-a-change.md +0 -0
  83. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/diff_sentry_eval/__init__.py +0 -0
  84. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/diff_sentry_eval/__main__.py +0 -0
  85. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/diff_sentry_eval/taskset.py +0 -0
  86. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/taskset.example.json +0 -0
  87. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/tests/test_boundary.py +0 -0
  88. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/tests/test_cli.py +0 -0
  89. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/tests/test_judge.py +0 -0
  90. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/tests/test_schema.py +0 -0
  91. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/eval/tests/test_taskset.py +0 -0
  92. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/diff_sentry_studio/__init__.py +0 -0
  93. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/index.html +0 -0
  94. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/trajectory.js +0 -0
  95. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  96. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  97. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/test_live.py +0 -0
  98. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/studio/tests/trajectory.test.js +0 -0
  99. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/__init__.py +0 -0
  100. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/conftest.py +0 -0
  101. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/corpus/change_corpus.json +0 -0
  102. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  103. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/fixtures/known-malicious.diff +0 -0
  104. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_config.py +0 -0
  105. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_detection_quality.py +0 -0
  106. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_emit.py +0 -0
  107. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_fetch_tool.py +0 -0
  108. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_injection_offline.py +0 -0
  109. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_provenance.py +0 -0
  110. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_rubric.py +0 -0
  111. {diff_sentry-0.4.2 → diff_sentry-0.5.0}/tests/test_schema.py +0 -0
@@ -0,0 +1,23 @@
1
+ ---
2
+ paths:
3
+ - "diff_sentry/detect.py"
4
+ - "diff_sentry/config.py"
5
+ - "diff_sentry/cli.py"
6
+ - "diff_sentry/deep_classify.py"
7
+ - "diff_sentry/__init__.py"
8
+ - ".env.example"
9
+ ---
10
+
11
+ # Model roles, auth, and run failures
12
+
13
+ - **Failures name themselves.** With `max_retries=1` (set in `detect.setup`), a non-retryable LM error
14
+ (auth / billing / configuration / unsupported model) escapes after one attempt as the raw
15
+ `dspy.LMError` subclass. An `RLMTaskError` means the single attempt ended in an adapter parse failure,
16
+ an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run` turns both into
17
+ a `status=failed` response. Check the endpoint before suspecting the schema.
18
+ - **Subscription auth is a lazy, opt-in branch.** A role whose model is `claude-agent-sdk/<id>` runs on
19
+ rlm-harness's `ClaudeAgentLM`, imported only inside `detect._maybe_subscription_lm` and never from
20
+ `__init__.py`, so `import diff_sentry` stays dspy-free and a proxy-only install never touches the SDK.
21
+ - **The classifier never runs on the subscription.** It is a separate OpenAI-compatible client
22
+ (`deep_classify._selfclassify_chat`), so `config.from_env` rejects a `claude-agent-sdk/` classifier
23
+ model, whether set explicitly or inherited from `DS_SUB_LM`. Mixed auth across roles is by design.
@@ -0,0 +1,45 @@
1
+ # Context preservation (read before auto-compacting)
2
+
3
+ Durable knowledge belongs in tracked files; a handoff summary carries only the in-flight session state
4
+ they do not hold yet.
5
+
6
+ - **Stable invariants** → `AGENTS.md` **Hard invariants**, or the topic rule under `.claude/rules/`.
7
+ - **Shipped changes** → the commit message, and a `CHANGELOG.md` entry at release.
8
+ - **Open / proposed work** → the issue tracker, or `AGENTS.md` **Layout** (next increments).
9
+
10
+ A handoff summary covers, in order:
11
+
12
+ 1. **Decisions agreed this session** that are not yet in those files, each with its reason. Promote
13
+ durable ones into `AGENTS.md` or a topic rule before they fade.
14
+ 2. **Files / symbols changed**, as `path:symbol — final shape` one-liners (e.g.
15
+ `assemble.py:assemble_verdict — signal = verdict∈emit_on OR severity≥floor`). No diffs, no
16
+ intermediate revisions.
17
+ 3. **Current status**: what passes (`uv run pytest`, the eval suite, `uvx ruff check .`) with counts,
18
+ what is broken, the last command and its result. One paragraph.
19
+ 4. **Open items** not yet tracked, each marked `proposed`, `accepted-not-done`, or `rejected`.
20
+ 5. **Seams touched**: `classify_backend` (`"self"` or a dedicated backend in progress), the host-side
21
+ pre-filter / live GitHub+SIEM wiring, and the rlm-harness pin. A resumed session must not re-open a
22
+ seam that moved.
23
+ 6. **The user's in-flight intent and acceptance criteria.**
24
+
25
+ Do not preserve anything already in `AGENTS.md`, `README.md`, `.env.example`, or `pyproject.toml`; tool
26
+ output, file listings, or file contents readable from disk; or exploration narration that led to no
27
+ decision.
28
+
29
+ Format (keep it under ~40 lines):
30
+
31
+ ```
32
+ ## Session state
33
+ - Goal: <one sentence>
34
+ - Status: <what passes, what doesn't, last command + result>
35
+ - Seams: <only the ones touched, one line each>
36
+
37
+ ## Decisions
38
+ - <decision> — <why> (→ AGENTS.md / topic rule / commit message)
39
+
40
+ ## Changed
41
+ - <path:symbol> — <what & why>
42
+
43
+ ## Open
44
+ - [proposed|accepted-not-done|rejected] <item>
45
+ ```
@@ -0,0 +1,42 @@
1
+ ---
2
+ paths:
3
+ - "diff_sentry/indicators.py"
4
+ - "diff_sentry/normalize.py"
5
+ - "tests/corpus/**"
6
+ - "tests/test_indicators.py"
7
+ - "tests/test_detection_quality.py"
8
+ - "tests/test_incident_*.py"
9
+ ---
10
+
11
+ # Indicator rules
12
+
13
+ - **Deterministic, pure Python, no subprocess.** A subprocess spawned inside the live dspy.RLM/asyncio
14
+ process hangs; any heavier scanner belongs host-side, after the run. `mint_id` is a sha1 over
15
+ rule+evidence (no time, no randomness), so a baseline hit and an in-loop re-scan of the same content
16
+ dedupe to ONE union member. Hit evidence is a bounded snippet (`_MAX_EVIDENCE`), never the whole diff.
17
+ - **Severities are tuned, not monotone-paranoid.** A plain workflow-file edit is `medium`, below the
18
+ signal floor on purpose: a benign workflow PR must not force a SIEM signal, and a real payload inside
19
+ the workflow fires the high/critical shell/obfuscation rules itself. A CODEOWNERS reassignment is
20
+ `high`. A bare `pull_request_target` (the ordinary label-bot shape) is `medium`; the same trigger
21
+ checking out the PR head is `pwn-request` / `critical` (untrusted code with the base repo's secrets).
22
+ `dev-tunnel-endpoint`, `content-addressed-host` (IPFS) and `dynamic-code-eval` are sub-floor
23
+ corroborators; pairs with no innocent reading (`child_process` + `detached`, network-fetch +
24
+ file-write) are `high`. Requiring both halves is how a common primitive becomes evidence without
25
+ becoming noise.
26
+ - **Every rule ships with its negative case**, and `tests/corpus/` pins the suite's hit/miss behavior.
27
+ - **`pwn-request` has three checkout forms, each requiring the privileged trigger:** the head expression
28
+ on a `ref:` (directly or one binding hop away), a hand-rolled `git fetch` / `gh pr checkout`, and
29
+ `allow-unsafe-pr-checkout: true` (the actions/checkout v5.1+ opt-in to the fork-PR checkout it blocks
30
+ by default; the line states the intent, so no dataflow is needed). Each form has its own title: calling
31
+ an opt-in "checks out the PR HEAD" sends a reader hunting for a `ref:` that is not there.
32
+ - **A paired rule only pairs within one file.** `scan_diff` splits a unified diff on its file headers;
33
+ `scan_content` does the same for an event, whose files `raw_content` would otherwise join into one
34
+ header-less blob. `scan` and the in-loop tool use `scan_diff`, the host-side baseline uses
35
+ `scan_content`, and `normalize.content_segments` is the single definition both `raw_content` and the
36
+ baseline build on. Per-file scoping stops two halves from different files composing into a `critical`
37
+ no file contains, and keeps a hit id byte-stable between a whole-diff and a single-file scan, which is
38
+ what lets the baseline and an in-loop scan dedupe (MF3). Non-diff input falls through to a whole-text
39
+ scan; keep that fallback.
40
+ - **The in-loop tool registers as exactly `scan_indicators`.** dspy registers a tool under its
41
+ `__name__` and the prompt calls `scan_indicators(region)`; any other name makes every sandbox call a
42
+ NameError. The inner callable is renamed on purpose (`make_indicator_tool`); keep it.
@@ -0,0 +1,42 @@
1
+ ---
2
+ paths:
3
+ - "diff_sentry/rubric.py"
4
+ - "diff_sentry/rl_export.py"
5
+ - "diff_sentry/response.py"
6
+ - "diff_sentry/assemble.py"
7
+ - "eval/**"
8
+ - "studio/**"
9
+ ---
10
+
11
+ # ATLAS rubric labels and the eval member
12
+
13
+ ## The rubric is a reward-free label surface
14
+
15
+ - `rl_export.rubric_signal` attaches the ATLAS 4-category (TF/TA/TG/PA) decomposition as LABELS: a fixed,
16
+ no-LLM skeleton in `run_start` meta (`rubric_to_meta(default_rubric())`, one criterion per category,
17
+ since the task is constant; there is no `generate_rubric` path) plus deterministic per-criterion
18
+ `criteria_facts`.
19
+ - The facts are a re-lens over `run_labels` / `run_metrics`, not a second derivation: `rubric.trace_facts`
20
+ reuses them, so a criterion's `observed` cannot drift from what a trainer reads. `_CATEGORY_LENS` maps
21
+ TF↔(verdict / signal / hit_iteration_cap), TA↔(scan / deep_classify / analyst / fetch / skill counts +
22
+ circuit-breaks + analyst failures), TG↔(indicator_count / max_indicator_severity / signal /
23
+ cited_unknown), PA↔(verdict / cited_unknown).
24
+ - `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any
25
+ rubric type.
26
+ - `rubric.py` is a dspy-free leaf: it imports only `.schema` at top, and its `rl_export` reuse is a
27
+ function-level import (that path is dspy-free too), so `response.py` can import it at top.
28
+ - `response._rubric` surfaces it as `DetectionResponse.rubric` (`RubricReport`); the studio shows it as a
29
+ "labels — not a score" card that adds no judgement. Keep the card legacy-guarded: a response without a
30
+ `rubric` renders nothing.
31
+
32
+ ## The eval member measures; it never rewards
33
+
34
+ - `eval/` (`diff-sentry-eval`) scores a recorded run's assembled verdict with a fixed external ATLAS
35
+ LLM-as-judge: TF/TA/TG/PA on 0–10, TF primary, per-category means only, no composite, no threshold.
36
+ - It is a one-way reader: it reaches diff-sentry only through `verdict_from_events` / `run_labels` /
37
+ `run_metrics` / `AssembledVerdict`, and `diff_sentry` never imports `diff_sentry_eval`
38
+ (`eval/tests/test_boundary.py` enforces it).
39
+ - The judge is rubric-free (it never reads `rubric_signal`, which would bias the measure) and holds
40
+ read-never-execute: it assesses the classification statically and treats the change as untrusted.
41
+ - A run it cannot score (never finalized, no usable verdict, judge failed) is `unscored`, never a fake 0.
42
+ See `eval/README.md`.
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: release
3
+ description: Cut a diff-sentry release — bump the five version sites together, write the CHANGELOG entry, and publish the GitHub Release that ships PyPI + the Marketplace Action. Use when asked to release, bump the version, or tag vX.Y.Z.
4
+ ---
5
+
6
+ # Cut a diff-sentry release
7
+
8
+ A release moves FIVE version sites together, plus a `CHANGELOG.md` entry:
9
+
10
+ 1. `pyproject.toml` `[project].version`
11
+ 2. `diff_sentry/__init__.py` `__version__`
12
+ 3. `action.yml` `inputs.version.default` (decides which PyPI release a consumer's `uses: …@vX.Y.Z` installs)
13
+ 4. `README.md` quickstart `uses: qazbnm456/diff-sentry@vX.Y.Z`
14
+ 5. `uv.lock`, via `uv lock`
15
+
16
+ `release.yml` fails the build unless the tag, the built wheel's version, the installed `__version__`, and
17
+ the `action.yml` default all agree, and it checks that the installed console script flags a known-malicious
18
+ diff. Nothing gates the README `uses:` line: a stale one sends new adopters to the previous release, so
19
+ check it by hand.
20
+
21
+ ## Steps
22
+
23
+ 1. Freeze the boundary: `git log v<last>..HEAD`. Every `feat:` / `fix:` / `deps:` commit inside it is a
24
+ CHANGELOG candidate.
25
+ 2. Write the `CHANGELOG.md` entry in the file's existing style: one sentence per user-visible outcome,
26
+ breaking changes and required actions explicit, no debugging story.
27
+ 3. Bump sites 1–4, run `uv lock`, then verify: `uv run pytest`, the eval suite
28
+ (`uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`), `uvx ruff check .`.
29
+ 4. Commit (`chore: release X.Y.Z`) and push to `main` once the user approves.
30
+ 5. Publishing a GitHub Release tagged `vX.Y.Z` (with "Publish this Action to the GitHub Marketplace"
31
+ ticked) is what builds, verifies, and uploads to PyPI via Trusted Publishing. The upload is
32
+ irreversible; confirm with the user before publishing.
@@ -0,0 +1,55 @@
1
+ # diff-sentry: model roles and runtime knobs. Copy to .env; a live run needs these plus a Deno sandbox.
2
+
3
+ # --- Model roles (required for a live `pr`/`issue`/`classify` run) ---
4
+ # planner: a cheap, injection-resistant orchestrator that holds the diff in the REPL and triages.
5
+ DS_ROOT_LM=your-planner-model
6
+ # analyst: an expensive model reached via llm_query for a subtle source->sink (short questions only).
7
+ DS_SUB_LM=your-analyst-model
8
+ # One OpenAI-compatible proxy serves all roles. When DS_BASE_URL is set the model names are the plain
9
+ # ids your endpoint serves (e.g. qwen/qwen3-next), not litellm-prefixed.
10
+ DS_BASE_URL=https://your-openai-compatible-endpoint/v1
11
+ DS_API_KEY=sk-...
12
+
13
+ # --- OR: run planner + analyst on a Claude Pro/Max subscription (no API key) ---
14
+ # A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude
15
+ # Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra). Each call is a pure completion:
16
+ # no tools, no filesystem, no settings leakage. Prerequisites:
17
+ # - `uv sync --extra subscription`
18
+ # - the Claude Code CLI installed and logged in (`claude` then /login, or `claude setup-token` and
19
+ # export CLAUDE_CODE_OAUTH_TOKEN)
20
+ # - `unset ANTHROPIC_API_KEY`: the CLI prefers it over the subscription and would bill API credit,
21
+ # so the adapter refuses to start while it is set
22
+ # Prefer full model ids over aliases, which drift.
23
+ # DS_ROOT_LM=claude-agent-sdk/claude-sonnet-5 # planner on Sonnet 5
24
+ # DS_SUB_LM=claude-agent-sdk/claude-fable-5 # analyst on Fable 5
25
+ # DS_BASE_URL/DS_API_KEY then serve only the classifier, which always needs an OpenAI-compatible
26
+ # endpoint (a subscription-only run is not supported). Set DS_CLASSIFIER_LM to a real model id (plus
27
+ # DS_CLASSIFIER_BASE_URL/DS_CLASSIFIER_API_KEY if it is a separate box). Left unset, it inherits the
28
+ # `claude-agent-sdk/…` DS_SUB_LM and config loading fails with an actionable error.
29
+ # Budgets use the same env vars as proxy mode. Each call adds ~2-5s of CLI-spawn overhead, so keep
30
+ # these runs interactive, not batch. DS_PLANNER_MAX_TOKENS has no effect here: the Agent SDK exposes
31
+ # no token or sampling controls.
32
+
33
+ # --- Second-stage classifier (the swappable seam) ---
34
+ # Defaults to the analyst model on DS_BASE_URL/DS_API_KEY. Required when the analyst runs on the
35
+ # subscription (see above).
36
+ # DS_CLASSIFIER_LM=your-strong-classifier
37
+ # DS_CLASSIFIER_BASE_URL=https://your-classifier-endpoint/v1
38
+ # DS_CLASSIFIER_API_KEY=sk-...
39
+ # DS_CLASSIFIER_CIRCUIT_BREAK=4
40
+
41
+ # --- RLM runtime ---
42
+ # DS_INTERPRETER=pyodide # read-not-execute: the sandbox never runs the change. `deno` is an alias.
43
+ # DS_MAX_ITERATIONS=25
44
+ # DS_MAX_LLM_CALLS=8 # caps only analyst (llm_query) escalations
45
+ # DS_ADAPTER=json # portable structured output (OpenAI + vLLM/NIM)
46
+ # DS_PLANNER_MAX_TOKENS=16384 # generous, so a reasoning planner isn't truncated to empty content
47
+
48
+ # --- Enrichment fetch (off by default: an exfil surface on attacker-authored input; GitHub-allowlisted) ---
49
+ # DS_ENABLE_FETCH=false
50
+ # DS_FETCH_ALLOW_CIDRS=198.18.0.0/16 # only if behind a fake-IP proxy / split-DNS VPN
51
+
52
+ # --- Host-side SIEM emitter (opt-in; deterministic POST after the run) ---
53
+ # DS_SIEM_WEBHOOK_URL=https://your-siem/intake
54
+ # DS_SIEM_TOKEN=...
55
+ # DS_EMIT_ON=suspicious,malicious
@@ -2,8 +2,8 @@ name: CI
2
2
 
3
3
  # Offline suite only: no live LLM, no network calls in tests, no Deno sandbox — dspy-bearing paths use
4
4
  # DummyLM / rlm-harness's ScriptedInterpreter, transports are injected fakes, and the corpus pins the
5
- # indicator suite's hit/miss behavior (see CLAUDE.md "Verify"). `uv sync` pulls rlm-harness from GitHub
6
- # (uv.lock pins the exact commit).
5
+ # indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI
6
+ # at the exact version pinned in pyproject.toml / uv.lock.
7
7
  on:
8
8
  push:
9
9
  branches: [main]
@@ -39,7 +39,7 @@ jobs:
39
39
  with:
40
40
  enable-cache: true
41
41
 
42
- # --all-packages brings in the studio workspace member, --all-extras every optional surface
42
+ # --all-packages brings in the studio and eval workspace members, --all-extras every optional surface
43
43
  # (classify/observe/subscription + the studio dev extra) so both suites resolve in one env.
44
44
  - name: Sync (core + studio workspace)
45
45
  run: uv sync --all-extras --all-packages --python ${{ matrix.python-version }}
@@ -50,8 +50,8 @@ jobs:
50
50
  - name: Studio console tests
51
51
  run: uv run --python ${{ matrix.python-version }} python -m pytest studio/tests
52
52
 
53
- # The eval workspace member (diff-sentry-eval) — its OWN offline suite: the reward-free ATLAS
54
- # scorecard, stub judge, synthetic traces, no creds. `--all-packages` above already installed it.
53
+ # The eval workspace member (diff-sentry-eval) has its OWN offline suite: the reward-free ATLAS
54
+ # scorecard, stub judge, synthetic traces, no creds.
55
55
  - name: Eval scorecard tests
56
56
  run: uv run --package diff-sentry-eval --python ${{ matrix.python-version }} python -m pytest eval/tests
57
57
 
@@ -89,7 +89,7 @@ jobs:
89
89
  # tabulate all of the above. Scanning those against our own rules is a guaranteed self-hit
90
90
  # (the pipe-to-shell example inside detect.INSTRUCTIONS and the field-separator token quoted
91
91
  # in README's rule table both fire), so they are reported without gating while the machinery
92
- # and the build/CI/dependency surface stay enforced. Note this file is in the GATED half:
92
+ # and the build/CI/dependency surface stay enforced. This file is in the GATED half:
93
93
  # describe a payload shape here, never quote one.
94
94
  report-only-paths: |
95
95
  diff_sentry/indicators.py
@@ -121,9 +121,14 @@ jobs:
121
121
  ver=$(uv run --no-project --with pyyaml python -c \
122
122
  "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])")
123
123
  echo "action.yml default version: $ver"
124
- # The fixture lives under tests/ (a report-only path) rather than inline here. This file is in
125
- # the GATED half, so a payload written into it fails our own scan -- which is exactly what
126
- # happened to the first version of this job. Payload samples belong where payloads are allowed.
124
+ # The fixture lives under tests/ (a report-only path) rather than inline here: this file is in
125
+ # the GATED half, so a payload written into it fails our own scan.
126
+ # FIRST prove the install. `scan` exits 1 when it flags, and uvx ALSO exits 1 when it cannot
127
+ # resolve `diff-sentry==${ver}` -- so the negative check below cannot tell "flagged" from "never
128
+ # installed" (e.g. a release commit whose version is not on PyPI yet).
129
+ got=$(uvx "diff-sentry==${ver}" --version)
130
+ echo "installed: $got"
131
+ case "$got" in *" ${ver}") ;; *) echo "::error::expected version ${ver}, got '$got'"; exit 1;; esac
127
132
  # Must exit non-zero: a package that installs but scans nothing would otherwise pass silently.
128
133
  if uvx "diff-sentry==${ver}" scan tests/fixtures/known-malicious.diff; then
129
134
  echo "::error::the published package did not flag a known-malicious diff"
@@ -141,8 +146,7 @@ jobs:
141
146
  enable-cache: true
142
147
  - name: Lint
143
148
  # PINNED on purpose. `uvx ruff` resolves the latest release at run time, and ruff's DEFAULT
144
- # rule set is not a stable contract — 0.16 expanded it far past the pycodestyle/pyflakes
145
- # baseline, which would have turned this job red on the next push with no code change. Bump
146
- # it deliberately, with the resulting fixes in the same commit, rather than letting a
147
- # release do it for you.
149
+ # rule set is not a stable contract (0.16 widened it well past the pycodestyle/pyflakes
150
+ # baseline), so an unpinned run can turn this job red with no code change. Bump it
151
+ # deliberately, with the resulting fixes in the same commit.
148
152
  run: uvx ruff@0.16.0 check .
@@ -34,11 +34,10 @@ jobs:
34
34
  - name: Build sdist + wheel
35
35
  run: uv build
36
36
 
37
- # Verify the ARTIFACT, not the checkout — a wheel can build cleanly and still be unusable. This
38
- # project shipped for months with no `[project.scripts]` entry point at all, so `uvx diff-sentry`
39
- # (the exact command the GitHub Action runs) could not have worked; nothing in the source tree
40
- # would have told you. Installing the built wheel into a clean environment and running it is what
41
- # catches that.
37
+ # Verify the ARTIFACT, not the checkout — a wheel can build cleanly and still be unusable. Without
38
+ # a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub
39
+ # Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into
40
+ # a clean environment and running it is what catches that.
42
41
  #
43
42
  # It also settles the version question against the thing being uploaded rather than against a
44
43
  # regex over the source. Three identities have to agree: the git tag people pin, the version
@@ -23,6 +23,6 @@ output/
23
23
  # Claude Code local overrides (never commit)
24
24
  .claude/settings.local.json
25
25
 
26
- # Build artifacts. `uv build` is now a normal local step (release.yml runs it), so an
26
+ # Build artifacts. `uv build` is a normal local step (release.yml runs it), so an
27
27
  # accidental `git add .` must not sweep a wheel into the repo.
28
28
  dist/
@@ -0,0 +1,126 @@
1
+ # diff-sentry — agent guide
2
+
3
+ A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change
4
+ (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the
5
+ planner SUBMITs a judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into
6
+ a SIEM signal. `README.md` has the pipeline table and the honest caveats; rlm-harness's "Building a
7
+ consumer" is the extension contract this project lives within.
8
+
9
+ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, locked in `uv.lock`). Overlay
10
+ `uv pip install -e ../rlm-harness` only while co-developing the kit, and bump the pin once the fix ships.
11
+
12
+ ## Verify
13
+
14
+ - `uv run pytest` — the full offline suite (`uv sync --group dev` first). No live LLM, network, or Deno:
15
+ dspy paths use DummyLM / rlm-harness's `ScriptedInterpreter`, transports are injected fakes, and
16
+ `tests/corpus/` pins the indicator suite's hit/miss behavior.
17
+ - `uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests` — the eval member's own suite
18
+ (the `--package` is required; a root `uv run` does not install the member). Run it when you touch
19
+ `assemble.py`, `rl_export.py`, `rubric.py`, `schema.py`, or the trace payloads it scores.
20
+ - `uvx ruff check .` — lint (line-length 110). CI runs it as its own job, next to both suites and the
21
+ node tests (`.github/workflows/ci.yml`).
22
+ - A live run needs role creds (`DS_*`, see `.env.example`), Deno (`brew install deno`), and `gh` for
23
+ `pr`/`issue` ingest. `render`/`export` are offline; `classify` ingests offline but classifies live.
24
+
25
+ ## Running — always through the CLI
26
+
27
+ - Run via `cli` (`pr` / `issue` / `classify`), never an ad-hoc script. `cli.run(event, …)` is the
28
+ programmatic entry: it records `<out>/traces/{run_id}.jsonl` (dropping a stale one first, since
29
+ TraceRecorder appends), writes `<out>/responses/{run_id}.json`, and emits the SIEM signal host-side
30
+ after the run. It never raises: a crash still writes a `status=failed` response that can signal off the
31
+ evidence floor. Extend `cli.py` rather than driving `detect_from_event` / `build_response` directly.
32
+ - Offline: `python -m diff_sentry render <trace> <run_id>` re-renders a response;
33
+ `python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset.
34
+
35
+ ## Hard invariants — do not break
36
+
37
+ - **The change is DATA: read, never execute.** It is a REPL variable under the default `pyodide`
38
+ interpreter; nothing in it is run, built, or fetched-and-run. An embedded instruction is a
39
+ `prompt-injection` signal, never a command. Never route the interpreter to `local`.
40
+ - **Judgement-only SUBMIT; evidence is unioned on read (MF3).** `ChangeVerdict` has no hits field; the
41
+ planner may only CITE ids (`indicator_ids`). `assemble.assemble_verdict` unions every hit in the trace
42
+ (run_start `baseline_indicators` ∪ each `scan_indicators` tool_call) and derives
43
+ `signal = verdict ∈ emit_on OR max severity ≥ SIGNAL_SEVERITY_FLOOR`. A false-benign verdict cannot
44
+ suppress evidence; a cited id with no recorded hit lands in `cited_unknown_ids`. The same assembly runs
45
+ on every read path (live, re-render, `rl_export`). Never add a hits/severity/signal field to the SUBMIT
46
+ type or a second signal derivation.
47
+ - **An ungroundable input yields `inconclusive`, never a confident verdict.** `inconclusive` is a
48
+ sanctioned 4th SUBMIT value (`schema.INCONCLUSIVE_VERDICT`) mapped to `status="inconclusive"` +
49
+ `RefusalInfo(reason="insufficient_evidence")`. The host-side backstop `normalize.has_groundable_content`
50
+ downgrades even a confident verdict when no groundable content exists. It is a reward-free negative
51
+ outcome, not an escape hatch for a hard-but-real change, and not in `emit_on`, so hard indicator
52
+ evidence still signals on its own.
53
+ - **MF1: the metadata sandwich in `normalize_event`.** dspy previews ~1000 chars of head+tail, so an
54
+ identical derived-metadata header and footer deny the attacker those edges, and attacker-authored
55
+ metadata fields are capped (`_MAX_META_*`). The title/author ride in `raw_content` so the host-side
56
+ baseline catches a title-borne injection that skews the planner. Don't flatten the sandwich or lift
57
+ the caps.
58
+ - **MF2: enrichment fetch is GitHub-allowlisted and off by default** (`enable_fetch=False`). An injected
59
+ instruction could steer a fetcher to `https://attacker.tld/?leak=…`: the kit's SSRF guard blocks
60
+ internal targets, and the `github_hosts` allowlist blocks external ones, re-checked on every redirect
61
+ hop. Never add a general-purpose fetch or fetch a URL the change names. `fetch_allow_cidrs` only
62
+ relaxes the resolved-IP layer (fake-IP proxy / split DNS); the syntactic guard still refuses
63
+ localhost/metadata.
64
+ - **Indicators are deterministic, pure Python, and in-loop safe.** Rule-level invariants (severity
65
+ tuning, per-file pairing, `pwn-request`, the tool name) live in `.claude/rules/indicators.md`.
66
+ - **Models are ROLES configured by env**: `DS_ROOT_LM` planner, `DS_SUB_LM` analyst, `DS_CLASSIFIER_LM`
67
+ classifier (defaults to the analyst). Refer to them by role; no hardcoded model name.
68
+ - **The budget is hard: `max_retries=1`, no whole-RLM retry.** One change = one trajectory, so the trace
69
+ stays valid training data. A failed run is infra before it is a schema bug; see
70
+ `.claude/rules/detect-setup.md` for how the failure names itself.
71
+ - **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` (on rlm-harness's
72
+ `make_model_tool`) is the swappable second-stage seam the planner chooses to call, so the decision is a
73
+ `tool_call` in the trajectory. The analyst intercept (`intercept_sub_lm`) is tracing-only, zero
74
+ transforms. Swapping the backend touches only `classify_backend` / `_selfclassify_chat`. The planner
75
+ passes distilled findings, never the whole diff.
76
+ - **GitHub ingest and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out
77
+ to `gh` host-side (transport injectable); `emit.emit_signal` POSTs after the run, stays out of the
78
+ trajectory, and never raises. Only choices the policy makes are tools; delivering a finished result is
79
+ plumbing.
80
+ - **This is a ROLLOUT source: trajectories, not reward.** `rl_export` passes `reward=None`; labels read
81
+ the assembled verdict and metrics are objective effort counters. `deep_classify` tool_calls train the
82
+ classifier; every other action is the planner's. A metric keeps ONE meaning across rlm-harness
83
+ versions (the corpus mixes them; `run_start.payload.rlm_harness` names the writer): a quantity a newer
84
+ kit makes observable gets a NEW key that is None where the trace cannot say (e.g. `analyst_failures`).
85
+ The ATLAS rubric and the eval member stay reward-free too; see `.claude/rules/rubric-and-eval.md`.
86
+ - **Attack knowledge lives in `diff_sentry/skills/`, not the prompt.** The skill catalog is injected
87
+ (`load_skills_as_tools(discovery="inject")`) and `read_skill(name)` pulls a body on demand. Fix a wrong
88
+ convention by editing or adding a skill; `detect.INSTRUCTIONS` holds only identity, the MISSION frame,
89
+ the tool cost model, the triage loop, and output-gating rules. Skills ship via
90
+ `packages = ["diff_sentry"]`; a force-include duplicates them and breaks the build.
91
+ - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `assemble`,
92
+ `response`, `emit`, `ingest`, `rl_export`, and `rubric` must not import dspy at module top, and
93
+ `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `detect_from_event` are
94
+ lazy PEP 562 re-exports).
95
+ - **The trace self-describes the run.** `run_start` meta carries the normalized event, instructions,
96
+ source echo, `baseline_indicators`, `emit_on`, role→model names, budgets, and the rubric skeleton, so
97
+ an offline re-render/export re-derives the same `signal` (reading `emit_on` from meta, never current
98
+ config). Any new per-run config that affects read-time derivation must ride in meta.
99
+
100
+ ## Topic guides
101
+
102
+ Claude Code loads each rule automatically when you touch a matching file; other agents should read the
103
+ file before working in that area.
104
+
105
+ | Area | File |
106
+ |---|---|
107
+ | Indicator rules, severities, corpus | `.claude/rules/indicators.md` |
108
+ | Model roles, subscription auth, run failures | `.claude/rules/detect-setup.md` |
109
+ | ATLAS rubric labels, eval member, studio rubric card | `.claude/rules/rubric-and-eval.md` |
110
+ | Cutting a release (five version sites) | `.claude/skills/release/SKILL.md` |
111
+ | What to keep across context compaction | `.claude/rules/handoff.md` |
112
+
113
+ ## Layout
114
+
115
+ - rlm-harness owns the generic cores: `make_model_tool` (chat → retry → validate → circuit-break) under
116
+ `deep_classify`, and the SSRF primitives (`is_safe_url`, `resolved_host_is_safe`, `parse_cidrs`) under
117
+ `fetch_tool`, which adds the GitHub allowlist, the httpx provider, and tracing.
118
+ - When this consumer needs a workaround, fix the reusable gap in rlm-harness generically; never
119
+ special-case diff-sentry there. Consumer values (`DS_*` roles, the verdict schema, indicator rules, the
120
+ SIEM payload shape) stay here.
121
+ - Next increments: a cheap pre-filter tier (deterministic indicators + one small single-shot call) as
122
+ host-side plumbing in front of `cli.run`, not extra RLM turns; and live GitHub/SIEM wiring.
123
+ - Two workspace members, never in the `diff_sentry` wheel: `studio/` (`diff-sentry-studio`, the detection
124
+ console, behind its `live` extra) and `eval/` (`diff-sentry-eval`, the reward-free scorecard). Both read
125
+ the trace / `DetectionResponse` contract through the public surface only. Keep the root wheel at
126
+ `packages = ["diff_sentry"]` so `uv build` never sweeps them in.
@@ -0,0 +1,171 @@
1
+ # Changelog
2
+
3
+ All notable changes to diff-sentry, a BewAIre-style detector that classifies one GitHub change
4
+ (PR, issue or push) for malicious intent on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
5
+
6
+ ## 0.5.0
7
+
8
+ Upgrade from 0.4.3: dspy 3.4.0 removed the interface rlm-harness 1.10.1 hands its sandbox through, so a
9
+ fresh 0.4.3 install resolves dspy 3.4.0 and every `pr` / `issue` / `classify` run fails with a
10
+ `TypeError`. The Action's deterministic `scan` is unaffected. 0.5.0 pins `rlm-harness==1.14.0`, which
11
+ caps dspy below 3.5.
12
+
13
+ ### Changed
14
+ - **`rlm-harness==1.14.0`, which requires `dspy>=3.4.0,<3.5.0` and `pydantic>=2.11.0`** (from 1.10.1,
15
+ dspy 3.3.1). diff-sentry's own `pydantic` floor rises to match.
16
+ - **Each exported action's `state` lists its prior actions in causal order** (interleaved by time)
17
+ instead of write order; labels, rubric facts and the SIEM signal are unchanged.
18
+ - **A failed analyst escalation is recorded**: the trace gains a `sub_call` carrying the provider's
19
+ error and `cause: "endpoint"`. The new `analyst_failures` metric counts them (None for a trace
20
+ written before rlm-harness 1.13.0, which could not record them), and `analyst_calls` keeps counting
21
+ only escalations that got a response, so it means the same on traces from either side of the upgrade.
22
+ - **The agent guide is `AGENTS.md` (formerly `CLAUDE.md`)**, which Claude Code 2.1.277+ and other coding
23
+ agents read natively. Area-specific invariants moved to path-scoped `.claude/rules/`, and the release
24
+ procedure to `.claude/skills/release`.
25
+
26
+ ### Added
27
+ - **The studio's run telemetry flags refused analyst escalations**: the analyst tile turns amber and an
28
+ "N analyst escalations refused" chip appears; a trace too old to record refusals shows neither.
29
+
30
+ ### Fixed
31
+ - **The studio feed shows a refused analyst escalation as "Ask analyst failed"** with the provider's
32
+ error, instead of rendering it like one that answered.
33
+ - **The studio replays a run in causal order**: a turn's reasoning streams before the tool calls it
34
+ made, and `run_end` always streams last, even when a timestamp is missing, NaN or infinite.
35
+ - **CI's consumer-install job fails when the published version cannot be installed**, instead of
36
+ passing green because the negative scan check also exits 1.
37
+
38
+ ### Docs
39
+ - **Docs, comments and test docstrings state current facts only**, and claims that had drifted from the
40
+ code are corrected (studio replay order, `RefusalInfo` reasons, where CI resolves rlm-harness).
41
+
42
+ ## 0.4.3
43
+
44
+ Dependencies only: the `diff_sentry` package itself is unchanged.
45
+
46
+ ### Changed
47
+ - **`rlm-harness==1.10.1` and `dspy` 3.3.1** (from 1.0.0 and 3.2.1); the pin stays exact, and a fresh
48
+ install resolves fewer packages because dspy 3.3 drops several exact pins.
49
+ - **Traces record more of each run** (the kit version in `run_start`; applied `budgets`, per-attempt
50
+ `usage` and the failed run's `error_chain` in `run_end`; `duration_s` on every tool call and
51
+ `sub_call`), while labels, metrics, rubric facts and the SIEM signal are derived exactly as before.
52
+ - **A non-retryable LM error (auth, billing, configuration, unsupported model) fails after one attempt
53
+ and the `status=failed` response names the dspy error class**, so a credential problem reads
54
+ differently from a model that kept producing invalid output (`RLMTaskError`).
55
+ - **The studio's "took" label on a tool call shows the recorded `duration_s`**, falling back to the
56
+ event gap only for older traces.
57
+
58
+ ### Fixed
59
+ - **An analyst escalation appears in the trace and the exported datasets exactly once.**
60
+ - **The analyst intercept handles dspy 3.3's typed `LMResponse`.**
61
+ - **A payload containing an unpaired surrogate is recorded** instead of being dropped from the trace.
62
+
63
+ ### Docs
64
+ - **`CLAUDE.md` lists all five version sites a release moves**, noting that
65
+ `release.yml` does not gate the README's `uses:` line.
66
+
67
+ ## 0.4.2
68
+
69
+ ### Fixed
70
+ - **Paired rules (`pwn-request`, `detached-process-spawn`) only pair signals from the same file**, in both
71
+ the in-loop `scan_indicators` tool and the host-side baseline, so two unrelated files can no longer
72
+ compose a `critical` that neither contains.
73
+
74
+ ### Changed
75
+ - **`astral-sh/setup-uv` v10.0.1 and `actions/checkout` v7**, for the Node 24 runtime; the setup-uv bump
76
+ also applies inside `action.yml`, so repos running the action stop seeing the Node 20 deprecation
77
+ warning.
78
+
79
+ ### Added
80
+ - **`pwn-request` flags `allow-unsafe-pr-checkout: true` under a privileged trigger** as its third
81
+ checkout form, with its own title; an explicit `false`, a removed opt-in, or a plain `pull_request`
82
+ trigger does not fire.
83
+ - **Each hit names the file it came from** instead of the whole diff.
84
+ - **A hit id is the same whether the diff was scanned whole or one file at a time**, so the baseline and
85
+ an in-loop re-scan de-duplicate to one piece of evidence.
86
+
87
+ ## 0.4.1
88
+
89
+ ### Changed
90
+ - **`pypa/gh-action-pypi-publish` v1.14.2**, whose Twine 7 accepts the core metadata 2.5 that `uv build`
91
+ now emits.
92
+
93
+ ### Fixed
94
+ - **`pwn-request` fires only when the PR-head expression reaches a checkout** (an `actions/checkout`
95
+ `ref:` or a `git checkout` / `git fetch` / `gh pr checkout` command), so the safe `workflow_run`
96
+ publisher shape the README recommends stays at the sub-floor `privileged-fork-trigger`.
97
+ - **`pwn-request` follows one binding hop**, catching a head SHA bound to a name and then used as
98
+ `ref: ${{ env.HEAD }}`.
99
+ - **A hand-rolled fetch of `refs/pull/${{ github.event.number }}/head` is detected.**
100
+
101
+ ## 0.4.0
102
+
103
+ The first release, led by a GitHub Action anyone can install; the local model pipeline, studio and
104
+ trajectory export remain for teams that run that infrastructure.
105
+
106
+ ### Added
107
+ - **diff-sentry ships as a GitHub Action** (`action.yml`, with inputs `fail-on`, `report-only-paths`,
108
+ `base-sha`, `version` and outputs `failed`, `hit-count`, `max-severity`, `report`) that runs only the
109
+ deterministic scan, so it needs no credentials, network or Deno and runs safely on fork PRs under the
110
+ read-only token.
111
+ - **A `scan` subcommand and a `diff-sentry` console entry point** run the deterministic scan standalone;
112
+ `--fail-on` defaults to the SIEM signal floor, with `--json` and `--include-deletions` options.
113
+ - **Ten indicator rules for the Miasma attack families**: `pwn-request`, `privileged-fork-trigger`,
114
+ `diff-viewport-evasion`, `invisible-unicode`, `detached-process-spawn`, `inline-code-exec`,
115
+ `remote-fetch-to-disk`, `obfuscated-identifiers`, and the sub-floor corroborators `dynamic-code-eval`
116
+ and `content-addressed-host`, each with a negative case in the corpus.
117
+ - **A `self-scan` CI job** runs this repo's own Action on every PR.
118
+ - **`release.yml` publishes to PyPI through OIDC Trusted Publishing** after installing the built wheel
119
+ cleanly and checking that the tag, wheel filename and `__version__` agree.
120
+
121
+ ### Changed
122
+ - **`rlm-kit` is replaced by `rlm-harness==1.0.0` from PyPI**; imports move to `rlm_harness`.
123
+ - **The rubric uses `rlm_harness.rubric`**, and `schema` still re-exports `Criterion`, `CriterionFact`
124
+ and `RubricCriteria`.
125
+ - **The README and package description lead with the Action.**
126
+ - **A bare `uv sync` installs the `subscription-sdk` dev group**, keeping the Claude Agent SDK in the dev
127
+ environment.
128
+ - **CI pins ruff to `0.16.0`.**
129
+
130
+ ### Fixed
131
+ - **`scan` ignores lines a diff deletes by default**, so a commit that removes a payload is not flagged
132
+ for it.
133
+
134
+ ## 0.3.0
135
+
136
+ ### Added
137
+ - **An input with no assessable content yields `inconclusive`** (`status="inconclusive"`,
138
+ `reason="insufficient_evidence"`) instead of a confident verdict, enforced by a host-side backstop and
139
+ exported as a reward-free `inconclusive` label; hard indicator evidence still forces a SIEM signal.
140
+ - **The studio shows the short fields of an unrecognized tool call** in the drawer, the live feed and the
141
+ trajectory view instead of an empty step, and `deep_classify` keeps a child-rollout link when its
142
+ backend returns one.
143
+
144
+ ## 0.2.1
145
+
146
+ ### Fixed
147
+ - **The studio starts on a subscription with the same command as the sibling harnesses**, through a new
148
+ `subscription` extra on `diff-sentry-studio`.
149
+ - **`/v1/config` no longer shows a subscription analyst as the classifier**, since no run can use one.
150
+
151
+ ## 0.2.0
152
+
153
+ ### Added
154
+ - **The planner and analyst can run on a Claude Pro/Max subscription** by setting `DS_ROOT_LM` /
155
+ `DS_SUB_LM` to `claude-agent-sdk/<model>`; this needs `uv sync --extra subscription`, a logged-in
156
+ Claude Code CLI and no `ANTHROPIC_API_KEY`.
157
+ - **The studio has a page-height, three-column layout with a Verdict / Indicators / Change switch**,
158
+ and the Change view reads the normalized content from the run's own trace.
159
+
160
+ ### Changed
161
+ - **The classifier must stay on an OpenAI-compatible endpoint**: `config.from_env` rejects a
162
+ `claude-agent-sdk/…` classifier, set directly or inherited from `DS_SUB_LM`, so a subscription-only
163
+ run is not supported.
164
+
165
+ ## 0.1.0
166
+
167
+ The initial release: classify a change into a judgement-only verdict, union the deterministic indicator
168
+ evidence on read (MF3), build the response, emit the SIEM signal host-side, and export reward-free
169
+ trajectories, all testable offline. Includes the metadata sandwich (MF1), the GitHub-allowlisted opt-in
170
+ enrichment fetch (MF2), the pure-Python indicator suite, the `deep_classify` second-stage seam,
171
+ progressive-disclosure attack skills, an offline hackerbot-claw reproduction, and the studio console.