diff-sentry 0.7.0__tar.gz → 0.7.1__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 (152) hide show
  1. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/detect-setup.md +3 -2
  2. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/indicators.md +1 -1
  3. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/rubric-and-eval.md +1 -0
  4. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.github/workflows/ci.yml +3 -3
  5. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.github/workflows/release.yml +6 -6
  6. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/AGENTS.md +1 -1
  7. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/CHANGELOG.md +13 -0
  8. diff_sentry-0.7.1/PKG-INFO +116 -0
  9. diff_sentry-0.7.1/README.md +95 -0
  10. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/action.yml +1 -1
  11. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/__init__.py +3 -3
  12. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/actors.py +1 -1
  13. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/assemble.py +5 -5
  14. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/cli.py +13 -13
  15. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/config.py +8 -8
  16. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/deep_classify.py +23 -6
  17. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/detect.py +9 -9
  18. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/emit.py +6 -6
  19. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/fetch_tool.py +4 -4
  20. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/indicators.py +82 -56
  21. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/ingest.py +22 -22
  22. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/normalize.py +9 -9
  23. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/response.py +5 -5
  24. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/rl_export.py +11 -11
  25. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/rubric.py +16 -16
  26. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/schema.py +11 -11
  27. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/snapshot.py +1 -1
  28. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/watch.py +11 -11
  29. diff_sentry-0.7.1/docs/development.md +14 -0
  30. diff_sentry-0.7.1/docs/how-it-works.md +56 -0
  31. diff_sentry-0.7.1/docs/limits.md +17 -0
  32. diff_sentry-0.7.1/docs/watch.md +43 -0
  33. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/__init__.py +3 -3
  34. diff_sentry-0.7.1/eval/diff_sentry_eval/__main__.py +6 -0
  35. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/cli.py +3 -3
  36. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/judge.py +35 -16
  37. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/schema.py +7 -7
  38. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/score.py +7 -7
  39. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/diff_sentry_eval/taskset.py +6 -6
  40. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/conftest.py +1 -1
  41. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_boundary.py +1 -1
  42. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_cli.py +1 -1
  43. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_judge.py +27 -1
  44. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_schema.py +1 -1
  45. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_score.py +1 -1
  46. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/tests/test_taskset.py +2 -2
  47. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/pyproject.toml +10 -10
  48. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/README.md +1 -1
  49. diff_sentry-0.7.1/studio/diff_sentry_studio/__init__.py +1 -0
  50. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/app.py +17 -17
  51. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/iterations.py +14 -14
  52. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/live.py +16 -16
  53. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/diff_sentry_studio/mapper.py +11 -11
  54. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/pyproject.toml +3 -3
  55. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/app.js +16 -16
  56. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/index.html +1 -1
  57. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/replay-core.js +3 -3
  58. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/run-core.js +9 -9
  59. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/style.css +7 -7
  60. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/trajectory.js +2 -2
  61. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/static-contract.test.js +2 -2
  62. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_app.py +3 -3
  63. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_iterations.py +2 -2
  64. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_live.py +5 -5
  65. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/test_mapper.py +5 -5
  66. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/conftest.py +29 -4
  67. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_assemble.py +2 -2
  68. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_cli.py +9 -9
  69. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_cli_watch.py +1 -1
  70. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_config.py +2 -2
  71. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_deep_classify.py +37 -0
  72. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_detect.py +2 -2
  73. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_detection_quality.py +8 -8
  74. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_export.py +5 -5
  75. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_fetch_tool.py +1 -1
  76. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_forward_offline.py +5 -5
  77. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_grounding.py +34 -10
  78. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_incident_hackerbot_claw.py +6 -6
  79. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_indicators.py +18 -18
  80. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_ingest.py +1 -1
  81. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_injection_offline.py +5 -5
  82. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_inline_exec_and_bidi.py +0 -9
  83. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_metadata_rules.py +1 -1
  84. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_provenance.py +5 -5
  85. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_response.py +4 -4
  86. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_rubric.py +2 -2
  87. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_schema.py +1 -1
  88. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_subscription.py +2 -2
  89. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/uv.lock +5 -5
  90. diff_sentry-0.7.0/PKG-INFO +0 -214
  91. diff_sentry-0.7.0/README.md +0 -193
  92. diff_sentry-0.7.0/eval/diff_sentry_eval/__main__.py +0 -6
  93. diff_sentry-0.7.0/studio/diff_sentry_studio/__init__.py +0 -1
  94. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/handoff.md +0 -0
  95. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/rules/monitoring.md +0 -0
  96. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.claude/skills/release/SKILL.md +0 -0
  97. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.env.example +0 -0
  98. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.gitignore +0 -0
  99. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/.python-version +0 -0
  100. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/LICENSE +0 -0
  101. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/VENDOR.md +0 -0
  102. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/__main__.py +0 -0
  103. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/dbutil.py +0 -0
  104. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/hosts.py +0 -0
  105. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/linewide.py +0 -0
  106. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/shelllex.py +0 -0
  107. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/ci-and-workflows.md +0 -0
  108. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/concealment-and-provenance.md +0 -0
  109. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/developer-autorun.md +0 -0
  110. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/llm-directed-text.md +0 -0
  111. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/package-and-build-hooks.md +0 -0
  112. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
  113. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/diff_sentry/skills/triage-a-change.md +0 -0
  114. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/README.md +0 -0
  115. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/pyproject.toml +0 -0
  116. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/eval/taskset.example.json +0 -0
  117. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/DESIGN.md +0 -0
  118. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  119. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  120. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/replay-core.test.js +0 -0
  121. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/run-core.test.js +0 -0
  122. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/studio/tests/trajectory.test.js +0 -0
  123. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/__init__.py +0 -0
  124. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/corpus/change_corpus.json +0 -0
  125. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/corpus/hackerbot_claw_campaign.json +0 -0
  126. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/corpus/hackerbot_claw_incident.json +0 -0
  127. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/fixtures/known-malicious.diff +0 -0
  128. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/__init__.py +0 -0
  129. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/grammar.py +0 -0
  130. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/harness.py +0 -0
  131. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/lexrunner.py +0 -0
  132. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/programs.py +0 -0
  133. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/oracle/runner.py +0 -0
  134. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_actors.py +0 -0
  135. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_adversarial_input.py +0 -0
  136. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_campaign_replay.py +0 -0
  137. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_dogfood_gate.py +0 -0
  138. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_emit.py +0 -0
  139. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_enclosure.py +0 -0
  140. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_file_scope.py +0 -0
  141. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_file_structure.py +0 -0
  142. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_gate_edges.py +0 -0
  143. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_hosts.py +0 -0
  144. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_ingest_metadata.py +0 -0
  145. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_linewide.py +0 -0
  146. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_normalize.py +0 -0
  147. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_oracle.py +0 -0
  148. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_oracle_harness.py +0 -0
  149. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_shelllex.py +0 -0
  150. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_snapshot.py +0 -0
  151. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_watch.py +0 -0
  152. {diff_sentry-0.7.0 → diff_sentry-0.7.1}/tests/test_watch_pool.py +0 -0
@@ -11,9 +11,10 @@ paths:
11
11
  # Model roles, auth, and run failures
12
12
 
13
13
  - **Failures name themselves.** With `max_retries=1` (set in `detect.setup`), a non-retryable LM error (auth / billing / configuration / unsupported model) escapes after one attempt as the raw `dspy.LMError` subclass. An `RLMTaskError` means the single attempt ended in an adapter parse failure, an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run` turns both into a `status=failed` response. Check the endpoint before suspecting the schema.
14
- - **An empty answer at the cap is a planner that thought too long, not one that cannot follow the format.** A reasoning planner whose call ends `finish_reason=length` with empty content fails the run with `AdapterParseError` (the "LM Response" quoted is its promoted reasoning, or `{}`), which reads like a format failure; the raw call's `finish_reason` tells them apart. The fix is a thinking budget below `DS_PLANNER_MAX_TOKENS` through `DS_ROOT_LM_KWARGS`, never a whole-run retry or a different adapter.
14
+ - **An empty answer at the cap is a planner that thought too long, not one that cannot follow the format.** A reasoning planner whose call ends `finish_reason=length` with empty content fails the run with `AdapterParseError`. Its message starts "This reply carried no content" when the reply quoted is the promoted reasoning channel, and quotes any other reply as the model wrote it; the raw call's `finish_reason` tells a cut at the cap from a reply that ignored the format. The fix is a thinking budget below `DS_PLANNER_MAX_TOKENS` through `DS_ROOT_LM_KWARGS`, never a whole-run retry or a different adapter.
15
15
  - **`DS_ROOT_LM_KWARGS` / `DS_SUB_LM_KWARGS` are aliases of rlm-harness's `RLM_MAIN_LM_KWARGS` / `RLM_SUB_LM_KWARGS`.** `setup` builds the kit's config itself and never calls the kit's `from_env`, the only reader of those two; `DetectConfig.from_env` reads both names and refuses them set to different values. It also refuses the keys the kit owns (model, endpoint, timeout), so a bad value stops the process rather than failing every run. The analyst's parameters never reach the classifier, which builds its own request.
16
- - **`DS_CLASSIFIER_LM_KWARGS` is the classifier's own**, over its `temperature=0.1`. `from_env` refuses the keys its settings own (model, messages, stream, n, timeout, max_tokens, max_completion_tokens) and, when the `classify` extra is installed, any key the OpenAI client does not take: that call would fail every time as an endpoint error, which the circuit breaker never counts.
16
+ - **`DS_CLASSIFIER_LM_KWARGS` is the classifier's own**, over its `temperature=0.1`. `from_env` refuses the keys its settings own (model, messages, stream, n, timeout, max_tokens, max_completion_tokens) and, when the `classify` extra is installed, any key the OpenAI client does not take, so a bad value stops the process rather than failing every call.
17
17
  - **A classifier reply cut at `max_tokens` is an unusable reply that names the cut** (`deep_classify._CutReply`, read by the validator), not an endpoint error. The same request is cut the same way, so it is not re-sent, and a classifier that keeps thinking through its budget trips the circuit breaker. A reply cut after a whole verdict still counts. The tool_call records `cut: true`. The fix is a thinking budget in `DS_CLASSIFIER_LM_KWARGS` (raising `DS_CLASSIFIER_MAX_TOKENS` alone can turn a slow model's cut into a timeout).
18
+ - **An error status that repeats is not re-sent** (`deep_classify.is_fast_fail_http`, passed to `make_model_tool` as `is_fast_fail`): a 400, 401, 403, 404 or 422 from the classifier's endpoint is an endpoint error after one call, and it counts toward the circuit breaker, so a wrong model name, key or request stops being called. A timeout, a transport error, a 408, 409, 429 or 5xx keeps the transient retry and never counts. The eval judge holds its own copy without 400 and 422: its breaker spans a whole batch, and a status one oversized change earns there would leave the later runs unscored.
18
19
  - **Subscription auth is a lazy, opt-in branch.** A role whose model is `claude-agent-sdk/<id>` runs on rlm-harness's `ClaudeAgentLM`, imported only inside `detect._maybe_subscription_lm` and never from `__init__.py`, so `import diff_sentry` stays dspy-free and a proxy-only install never touches the SDK.
19
20
  - **The classifier never runs on the subscription.** It is a separate OpenAI-compatible client (`deep_classify._selfclassify_chat`), so `config.from_env` rejects a `claude-agent-sdk/` classifier model, whether set explicitly or inherited from `DS_SUB_LM`. Mixed auth across roles is by design.
@@ -44,7 +44,7 @@ paths:
44
44
 
45
45
  ## `curl-pipe-shell`
46
46
 
47
- A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file: `.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`, `.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label. The planner's `scan_indicators` applies the same softening to the region it is given.
47
+ A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file: `.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`, `.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label. The planner's `scan_indicators` applies the same softening to the region it is given. A region with added lines and a `- ` line but no hunk or file header could be a patch slice or a list, so it is not softened, unless every line of it is a whole line, diff marker and all, of a documentation file's patch (`_Change.patch_of`): it is then read as the baseline reads that file.
48
48
 
49
49
  ### What it matches
50
50
 
@@ -14,6 +14,7 @@ paths:
14
14
 
15
15
  - `rl_export.rubric_signal` attaches the ATLAS 4-category (TF/TA/TG/PA) decomposition as LABELS: a fixed, no-LLM skeleton in `run_start` meta (`rubric_to_meta(default_rubric())`, one criterion per category, since the task is constant; there is no `generate_rubric` path) plus deterministic per-criterion `criteria_facts`.
16
16
  - The facts are a re-lens over `run_labels` / `run_metrics`, not a second derivation: `rubric.trace_facts` reuses them, so a criterion's `observed` cannot drift from what a trainer reads. `_CATEGORY_LENS` maps TF↔(verdict / signal / hit_iteration_cap), TA↔(scan / deep_classify / analyst / fetch / skill counts + circuit-breaks + analyst failures), TG↔(indicator_count / max_indicator_severity / signal / cited_unknown), PA↔(verdict / cited_unknown).
17
+ - `deep_classify_circuit_breaks` counts the tool_calls the breaker short-circuited. From rlm-harness 1.22.0 a repeating error status (`is_fast_fail_http`) counts toward the breaker as an unusable reply does, so a break in a newer trace can mean a misconfigured classifier endpoint as well as a thrashing planner; the status in the run's endpoint-error tool_calls (`Error code: 401`) tells them apart, since a 429, a 5xx or a timeout records one too and does not count.
17
18
  - `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any rubric type.
18
19
  - `rubric.py` is a dspy-free leaf: it imports only `.schema` at top, and its `rl_export` reuse is a function-level import (that path is dspy-free too), so `response.py` can import it at top.
19
20
  - `response._rubric` surfaces it as `DetectionResponse.rubric` (`RubricReport`); the studio shows it as a card tagged `labels — not a score` that adds no judgement. Keep the card legacy-guarded: a response without a `rubric` renders nothing.
@@ -1,6 +1,6 @@
1
1
  name: CI
2
2
 
3
- # Offline suite only: no live LLM, no network calls in tests, no Deno sandbox — dspy-bearing paths use
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
5
  # indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI
6
6
  # at the exact version pinned in pyproject.toml / uv.lock.
@@ -9,7 +9,7 @@ on:
9
9
  branches: [main]
10
10
  pull_request:
11
11
 
12
- # Least privilege: this workflow only checks out code and runs tests/lint — it never writes. Specifying
12
+ # Least privilege: this workflow only checks out code and runs tests/lint; it never writes. Specifying
13
13
  # `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted
14
14
  # fork PR can't use the token to push, tag, or open issues.
15
15
  permissions:
@@ -62,7 +62,7 @@ jobs:
62
62
  for f in studio/tests/*.test.js; do echo "▸ $f"; node "$f"; done
63
63
 
64
64
  # Dogfood: this repo's own CI runs the ACTION this repo publishes (`uses: ./`), not a copy of it.
65
- # So every PR here is also an integration test of the thing users install — a drifted action.yml
65
+ # So every PR here is also an integration test of the thing users install: a drifted action.yml
66
66
  # fails our own build before it reaches anyone's Marketplace install.
67
67
  #
68
68
  # DELIBERATELY the deterministic scan and not the full `classify` pipeline. `classify` needs DS_*
@@ -1,6 +1,6 @@
1
1
  name: Release
2
2
 
3
- # Publish to PyPI via Trusted Publishing (OIDC) — NO API token. INERT until a GitHub Release is
3
+ # Publish to PyPI via Trusted Publishing (OIDC), with NO API token. INERT until a GitHub Release is
4
4
  # published, and needs one-time setup before the first release:
5
5
  # 1. On PyPI, add a Trusted Publisher for project `diff-sentry`:
6
6
  # owner = qazbnm456, repo = diff-sentry, workflow = release.yml, environment = pypi
@@ -11,7 +11,7 @@ name: Release
11
11
  # consumers pin in `uses: qazbnm456/diff-sentry@vX`. So cutting a release ships the package AND the
12
12
  # Action; keep the README's quickstart ref in step with the tag you publish.
13
13
  #
14
- # Supply-chain: the two third-party actions are SHA-pinned — setup-uv, and (highest blast radius, it
14
+ # Supply-chain: the two third-party actions are SHA-pinned: setup-uv, and (highest blast radius, it
15
15
  # uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned
16
16
  # checkout/*-artifact stay on major tags.
17
17
 
@@ -34,7 +34,7 @@ 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. Without
37
+ # Verify the ARTIFACT, not the checkout: a wheel can build cleanly and still be unusable. Without
38
38
  # a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub
39
39
  # Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into
40
40
  # a clean environment and running it is what catches that.
@@ -75,7 +75,7 @@ jobs:
75
75
  exit 1
76
76
  fi
77
77
 
78
- - uses: actions/upload-artifact@v4
78
+ - uses: actions/upload-artifact@v7
79
79
  with:
80
80
  name: dist
81
81
  path: dist/
@@ -87,14 +87,14 @@ jobs:
87
87
  # would fire this job and ask PyPI to publish `diff-sentry` over OIDC. The Trusted Publisher binding
88
88
  # (owner+repo+workflow+environment) would reject it, but that is a rejected publish attempt against
89
89
  # OUR project, plus a probe surface and a confusing red run in someone else's repo. `build` above is
90
- # deliberately NOT guarded — it only builds and tests an artifact, and a fork verifying its own
90
+ # deliberately NOT guarded: it only builds and tests an artifact, and a fork verifying its own
91
91
  # build is fine.
92
92
  if: github.repository == 'qazbnm456/diff-sentry'
93
93
  environment: pypi
94
94
  permissions:
95
95
  id-token: write # OIDC token for PyPI Trusted Publishing
96
96
  steps:
97
- - uses: actions/download-artifact@v4
97
+ - uses: actions/download-artifact@v8
98
98
  with:
99
99
  name: dist
100
100
  path: dist/
@@ -1,6 +1,6 @@
1
1
  # diff-sentry: agent guide
2
2
 
3
- A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal. `README.md` has the pipeline table and the honest caveats; rlm-harness's "Building a consumer" is the extension contract this project lives within.
3
+ A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change (PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the planner SUBMITs a judgement-only verdict, and the deterministic indicator EVIDENCE is unioned on read into a SIEM signal. `README.md` is the user-facing overview and `docs/` holds the pipeline and the honest limits; rlm-harness's "Building a consumer" is the extension contract this project lives within.
4
4
 
5
5
  rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, locked in `uv.lock`). Overlay `uv pip install -e ../rlm-harness` only while co-developing the kit, and bump the pin once the fix ships.
6
6
 
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to diff-sentry, a BewAIre-style detector that classifies one GitHub change (PR, issue or push) for malicious intent on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
4
4
 
5
+ ## 0.7.1
6
+
7
+ Upgrade from 0.7.0: `rlm-harness==1.22.0`. The GitHub Action's scan is unchanged; the model pipeline (`pr`, `issue`, `classify`, `watch`) raises fewer false signals on documentation changes and stops calling a misconfigured classifier sooner. Nothing needs configuring.
8
+
9
+ ### Changed
10
+ - **A classifier endpoint that answers 400, 401, 403, 404 or 422 is called once**, not retried, and counts toward the circuit breaker, so a wrong model name, key or request stops being called. Timeouts, 429 and 5xx are retried as before. The eval judge does the same for 401, 403 and 404.
11
+ - **A planner run that fails on an unparseable reply quotes the reply as the model wrote it**, and says so when the reply carried only reasoning.
12
+ - **`rlm-harness==1.22.0`.**
13
+ - **The README covers using diff-sentry**; how it works, the watcher, the known limits and development moved to `docs/`.
14
+
15
+ ### Fixed
16
+ - **A planner re-scan of a slice of a documentation patch is softened as the scan of that file is**, so a README change that adds a documented installer and removes an indented line no longer signals as `critical`.
17
+
5
18
  ## 0.7.0
6
19
 
7
20
  Upgrade from 0.6.0: `rlm-harness==1.20.0` and `dspy` 3.4.1. The signal now reads only the evidence the planner grounded in the change, and several rules read lower or higher than before, so a SIEM that alerts on severity sees fewer false signals and some new ones (see **Changed**). Nothing needs configuring; the new `DS_*` settings are optional.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: diff-sentry
3
+ Version: 0.7.1
4
+ Summary: Catch malicious pull requests before they merge: scan a diff for supply-chain attack patterns with deterministic evidence a model cannot suppress.
5
+ Author: Boik Su
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: dspy,malicious-code-detection,prompt-injection,rlm,security,siem,supply-chain-security
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: pydantic>=2.11.0
12
+ Requires-Dist: rlm-harness==1.22.0
13
+ Provides-Extra: classify
14
+ Requires-Dist: openai>=1.0; extra == 'classify'
15
+ Provides-Extra: observe
16
+ Requires-Dist: langfuse>=4.6.1; extra == 'observe'
17
+ Requires-Dist: openinference-instrumentation-dspy>=0.1.36; extra == 'observe'
18
+ Provides-Extra: subscription
19
+ Requires-Dist: claude-agent-sdk>=0.1.60; extra == 'subscription'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # diff-sentry
23
+
24
+ **Catch a malicious pull request before you merge it.**
25
+
26
+ diff-sentry reads a change the way an attacker hopes you won't: it treats the diff as untrusted data, looks for the shapes that real supply-chain attacks use, and reports evidence rather than an opinion.
27
+
28
+ The fastest way to use it is as a GitHub Action. It needs no API key, no model, and no network access, so it runs on fork pull requests under the read-only token they already get.
29
+
30
+ ```yaml
31
+ # .github/workflows/diff-sentry.yml
32
+ name: diff-sentry
33
+ on: pull_request
34
+
35
+ permissions:
36
+ contents: read
37
+
38
+ jobs:
39
+ scan:
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v7
43
+ with:
44
+ fetch-depth: 0 # the scan diffs against the PR base
45
+ - uses: qazbnm456/diff-sentry@v0.7.1
46
+ ```
47
+
48
+ That is the whole setup. The job fails when an indicator at or above `high` fires.
49
+
50
+ ## What it catches
51
+
52
+ | Family | Examples |
53
+ |---|---|
54
+ | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true`; plus `permissions: write-all` and CODEOWNERS reassignment |
55
+ | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
56
+ | Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
57
+ | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
58
+ | Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
59
+ | Provenance | forged `[bot]` identities, bot-mimicking new accounts, accounts created days before the event, profiles that describe an autonomous agent, unsigned or identity-mismatched commits, a fork deleted after the PR |
60
+ | Author-controlled channels | a PR branch name carrying shell syntax, slash-command comments that fire `issue_comment` workflows |
61
+ | Campaign memory (`watch` only) | an account or payload host already flagged in another repository, an edit that removes a flagged payload, a burst of changes from a new account |
62
+ | Prompt injection | instructions aimed at an LLM reviewer or triage workflow |
63
+
64
+ A plain workflow edit, or a `pull_request_target` on its own, is `medium`, below the failure threshold, so a benign workflow PR does not break your build. Deleted lines are not scanned, so removing a payload is not flagged. [How it works](docs/how-it-works.md#severities-are-tuned-against-noise) explains the tuning.
65
+
66
+ ## Inputs
67
+
68
+ | Input | Default | Purpose |
69
+ |---|---|---|
70
+ | `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
71
+ | `report-only-paths` | none | Newline-separated globs scanned but never gated. For files whose job is to carry attack patterns: rule bodies, security fixtures, prompt templates, docs that tabulate payloads. |
72
+ | `base-sha` | derived | Override the commit to diff against. |
73
+ | `version` | matches the action tag | The diff-sentry release to install from PyPI. `local` installs from the current checkout instead, which is how this repo scans its own PRs with the rules in that PR. |
74
+
75
+ Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
76
+
77
+ ## Do not reach for `pull_request_target`
78
+
79
+ Sooner or later you will want the scan to post its results as a PR comment, and a `pull_request` job on a fork PR has a read-only token. The tempting fix is `pull_request_target`, which is the exact misconfiguration that opened the AsyncAPI "Miasma" compromise, and which this action reports as `critical`.
80
+
81
+ Do it the safe way instead: keep the scan on `pull_request` with no secrets, upload the `report` as an artifact, and post it from a separate `workflow_run` job that runs the base branch's code and never checks out the PR head. diff-sentry's own rules make the same distinction, so the safe shape does not trip them.
82
+
83
+ ## Evidence a model cannot suppress
84
+
85
+ An LLM reviewer can be argued out of its conclusion by the diff it reads. The Action runs no model at all. In the full pipeline, the rules record their hits before any model takes a turn, and the alert is derived from those hits, so a prompt injection can skew a verdict but cannot remove the evidence ([details](docs/how-it-works.md#evidence-a-model-cannot-suppress)).
86
+
87
+ ## Beyond the Action
88
+
89
+ https://github.com/user-attachments/assets/d200aee8-263b-483e-ad55-f90ae69f3ab0
90
+
91
+ _The studio console: paste a real payload, watch the detection stream live, then read the evidence-framed verdict._
92
+
93
+ If you operate the infrastructure, or maintain a project where a wrong call is expensive, the rest of the system runs locally with a model endpoint you choose:
94
+
95
+ - **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
96
+ - **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
97
+ - **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
98
+ - **Trajectories for training**: every run is recorded, and `export` turns the traces into a reward-free dataset for fine-tuning a smaller planner. See [The full pipeline](docs/how-it-works.md#the-full-pipeline).
99
+
100
+ None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
101
+
102
+ ## Limits
103
+
104
+ A strong prompt injection can skew a model's verdict, though never the evidence. Some pipe-to-shell shapes, and a secret that reaches a network call across lines, are missed. An external watcher cannot stop an attack that fires the moment a PR opens. [Honest limits](docs/limits.md) lists the known gaps.
105
+
106
+ ## Documentation
107
+
108
+ - [How it works](docs/how-it-works.md): severity tuning, the evidence union, the full pipeline, running it locally, and the incidents the tests reconstruct.
109
+ - [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
110
+ - [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
111
+ - [Development](docs/development.md): the test suites and CI.
112
+ - [CHANGELOG](CHANGELOG.md)
113
+
114
+ ## License
115
+
116
+ MIT. Built on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
@@ -0,0 +1,95 @@
1
+ # diff-sentry
2
+
3
+ **Catch a malicious pull request before you merge it.**
4
+
5
+ diff-sentry reads a change the way an attacker hopes you won't: it treats the diff as untrusted data, looks for the shapes that real supply-chain attacks use, and reports evidence rather than an opinion.
6
+
7
+ The fastest way to use it is as a GitHub Action. It needs no API key, no model, and no network access, so it runs on fork pull requests under the read-only token they already get.
8
+
9
+ ```yaml
10
+ # .github/workflows/diff-sentry.yml
11
+ name: diff-sentry
12
+ on: pull_request
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ scan:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+ with:
23
+ fetch-depth: 0 # the scan diffs against the PR base
24
+ - uses: qazbnm456/diff-sentry@v0.7.1
25
+ ```
26
+
27
+ That is the whole setup. The job fails when an indicator at or above `high` fires.
28
+
29
+ ## What it catches
30
+
31
+ | Family | Examples |
32
+ |---|---|
33
+ | Workflow configuration | `pull_request_target` that checks out the PR head (the pwn-request that broke AsyncAPI) by `ref:`, by a hand-rolled `git fetch`, or by opting back in with `allow-unsafe-pr-checkout: true`; plus `permissions: write-all` and CODEOWNERS reassignment |
34
+ | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
35
+ | Execution | pipe-to-shell installs, `${IFS}` space-evasion, detached child processes, inline `node -e`, fetch-to-disk droppers |
36
+ | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
37
+ | Diff-presentation evasion | payloads shoved past the viewport by a long whitespace run, bidi-override and zero-width characters |
38
+ | Provenance | forged `[bot]` identities, bot-mimicking new accounts, accounts created days before the event, profiles that describe an autonomous agent, unsigned or identity-mismatched commits, a fork deleted after the PR |
39
+ | Author-controlled channels | a PR branch name carrying shell syntax, slash-command comments that fire `issue_comment` workflows |
40
+ | Campaign memory (`watch` only) | an account or payload host already flagged in another repository, an edit that removes a flagged payload, a burst of changes from a new account |
41
+ | Prompt injection | instructions aimed at an LLM reviewer or triage workflow |
42
+
43
+ A plain workflow edit, or a `pull_request_target` on its own, is `medium`, below the failure threshold, so a benign workflow PR does not break your build. Deleted lines are not scanned, so removing a payload is not flagged. [How it works](docs/how-it-works.md#severities-are-tuned-against-noise) explains the tuning.
44
+
45
+ ## Inputs
46
+
47
+ | Input | Default | Purpose |
48
+ |---|---|---|
49
+ | `fail-on` | `high` | Severity that fails the build (`info`/`low`/`medium`/`high`/`critical`). |
50
+ | `report-only-paths` | none | Newline-separated globs scanned but never gated. For files whose job is to carry attack patterns: rule bodies, security fixtures, prompt templates, docs that tabulate payloads. |
51
+ | `base-sha` | derived | Override the commit to diff against. |
52
+ | `version` | matches the action tag | The diff-sentry release to install from PyPI. `local` installs from the current checkout instead, which is how this repo scans its own PRs with the rules in that PR. |
53
+
54
+ Outputs: `failed`, `hit-count`, `max-severity`, and `report` (a JSON path).
55
+
56
+ ## Do not reach for `pull_request_target`
57
+
58
+ Sooner or later you will want the scan to post its results as a PR comment, and a `pull_request` job on a fork PR has a read-only token. The tempting fix is `pull_request_target`, which is the exact misconfiguration that opened the AsyncAPI "Miasma" compromise, and which this action reports as `critical`.
59
+
60
+ Do it the safe way instead: keep the scan on `pull_request` with no secrets, upload the `report` as an artifact, and post it from a separate `workflow_run` job that runs the base branch's code and never checks out the PR head. diff-sentry's own rules make the same distinction, so the safe shape does not trip them.
61
+
62
+ ## Evidence a model cannot suppress
63
+
64
+ An LLM reviewer can be argued out of its conclusion by the diff it reads. The Action runs no model at all. In the full pipeline, the rules record their hits before any model takes a turn, and the alert is derived from those hits, so a prompt injection can skew a verdict but cannot remove the evidence ([details](docs/how-it-works.md#evidence-a-model-cannot-suppress)).
65
+
66
+ ## Beyond the Action
67
+
68
+ https://github.com/user-attachments/assets/d200aee8-263b-483e-ad55-f90ae69f3ab0
69
+
70
+ _The studio console: paste a real payload, watch the detection stream live, then read the evidence-framed verdict._
71
+
72
+ If you operate the infrastructure, or maintain a project where a wrong call is expensive, the rest of the system runs locally with a model endpoint you choose:
73
+
74
+ - **Deep classification**: a reasoning model reads a PR, issue or push in a sandbox and returns a verdict next to the evidence. See [How it works](docs/how-it-works.md).
75
+ - **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
76
+ - **Watch**: monitors a list of repositories, alerts the moment the rules fire, and remembers the accounts and payload hosts it has seen. See [Watching a watchlist](docs/watch.md).
77
+ - **Trajectories for training**: every run is recorded, and `export` turns the traces into a reward-free dataset for fine-tuning a smaller planner. See [The full pipeline](docs/how-it-works.md#the-full-pipeline).
78
+
79
+ None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
80
+
81
+ ## Limits
82
+
83
+ A strong prompt injection can skew a model's verdict, though never the evidence. Some pipe-to-shell shapes, and a secret that reaches a network call across lines, are missed. An external watcher cannot stop an attack that fires the moment a PR opens. [Honest limits](docs/limits.md) lists the known gaps.
84
+
85
+ ## Documentation
86
+
87
+ - [How it works](docs/how-it-works.md): severity tuning, the evidence union, the full pipeline, running it locally, and the incidents the tests reconstruct.
88
+ - [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
89
+ - [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
90
+ - [Development](docs/development.md): the test suites and CI.
91
+ - [CHANGELOG](CHANGELOG.md)
92
+
93
+ ## License
94
+
95
+ MIT. Built on [`rlm-harness`](https://github.com/qazbnm456/rlm-harness).
@@ -44,7 +44,7 @@ inputs:
44
44
  # KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts
45
45
  # from the tag being released, because a stale default here would silently run an
46
46
  # old scanner under a new action tag.
47
- default: '0.7.0'
47
+ default: '0.7.1'
48
48
 
49
49
  outputs:
50
50
  failed:
@@ -1,4 +1,4 @@
1
- """diff-sentry — classify a GitHub change for malicious intent, as a traced RLM harness.
1
+ """diff-sentry: classify a GitHub change for malicious intent, as a traced RLM harness.
2
2
 
3
3
  A downstream *consumer* of rlm-harness (an exact PyPI pin) reproducing Datadog's BewAIre defense shape:
4
4
  ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a
@@ -80,10 +80,10 @@ __all__ = [
80
80
  "screen",
81
81
  ]
82
82
 
83
- __version__ = "0.7.0"
83
+ __version__ = "0.7.1"
84
84
 
85
85
 
86
- def __getattr__(name: str): # PEP 562 — defer the dspy import to first use
86
+ def __getattr__(name: str): # PEP 562: defer the dspy import to first use
87
87
  if name in ("ClassifyChange", "setup"):
88
88
  from . import detect
89
89
 
@@ -1,4 +1,4 @@
1
- """Host-side actor store — who is behind a change, and what else they did.
1
+ """Host-side actor store: who is behind a change, and what else they did.
2
2
 
3
3
  A per-change classifier sees one PR. The campaigns worth catching span many: hackerbot-claw hit seven
4
4
  repositories in ten days from one account, with one payload host. The defender of each repository saw one
@@ -1,17 +1,17 @@
1
- """Assemble the canonical verdict from the trace — the read-time step that makes the EVIDENCE a fact.
1
+ """Assemble the canonical verdict from the trace: the read-time step that makes the EVIDENCE a fact.
2
2
 
3
3
  `assemble_verdict(verdict, events)` attaches the deterministic fields the SYSTEM owns to the planner's
4
4
  JUDGEMENT (`ChangeVerdict`, no hits): the UNION of ALL indicator hits from the trace (baseline ∪ every
5
- `scan_indicators` tool call — MF3), the derived max severity, whether to SIGNAL, and any indicator ids
5
+ `scan_indicators` tool call; MF3), the derived max severity, whether to SIGNAL, and any indicator ids
6
6
  the planner cited that match no recorded hit (a fabrication tell).
7
7
 
8
8
  The `signal` decision is DETERMINISTIC and partly independent of the planner: it fires if the planner's
9
9
  verdict is in `emit_on` OR a high/critical deterministic indicator fired. So a false-benign self-report
10
- cannot suppress hard evidence from reaching the SIEM — the planner can skew the verdict, never hide the
10
+ cannot suppress hard evidence from reaching the SIEM: the planner can skew the verdict, never hide the
11
11
  indicators. (This is deterministic reduction over facts, NOT a model judgement smuggled into the read
12
- path — the verdict tier stays the planner's; only the evidence-driven floor is ours.)
12
+ path: the verdict tier stays the planner's; only the evidence-driven floor is ours.)
13
13
 
14
- This runs everywhere the result is consumed — the live path (cli), re-render, and rl_export — so labels
14
+ This runs everywhere the result is consumed: the live path (cli), re-render, and rl_export, so labels
15
15
  read facts too. A trace missing a newer field still reads: pydantic ignores unknown keys, and hits
16
16
  re-source from the trace.
17
17
 
@@ -11,11 +11,11 @@
11
11
  python -m diff_sentry archive verify # re-hash the snapshot archive
12
12
 
13
13
  `run()` is the programmatic entry. It records `traces/{run_id}.jsonl`, writes `responses/{run_id}.json`,
14
- and — host-side, AFTER the run — emits a SIEM signal when the assembled verdict warrants it. A host-side
14
+ and (host-side, AFTER the run) emits a SIEM signal when the assembled verdict warrants it. A host-side
15
15
  BASELINE indicator scan runs at ingest and rides in the run_start meta, so the deterministic evidence is
16
16
  in the trace before the planner takes a turn (MF3). `scan`/`render`/`export` work offline;
17
17
  `pr`/`issue`/`classify` need model creds (DS_* env) + a Deno sandbox (`classify` only *ingests* offline).
18
- `scan` exposes that deterministic baseline on its own — the half we can run in our own CI on an untrusted
18
+ `scan` exposes that deterministic baseline on its own: the half we can run in our own CI on an untrusted
19
19
  fork PR, where no creds exist and none may be handed over (see `_cmd_scan`).
20
20
  """
21
21
 
@@ -56,7 +56,7 @@ async def detect_from_event(
56
56
  setup(config)
57
57
  task = ClassifyChange(config=config, extra_tools=extra_tools, event=event)
58
58
  event_str = normalize_event(event)
59
- # Host-side BASELINE scan — deterministic evidence in the trace before the planner runs (MF3).
59
+ # Host-side BASELINE scan: deterministic evidence in the trace before the planner runs (MF3).
60
60
  baseline = _baseline_indicators(event)
61
61
 
62
62
  async def _run():
@@ -67,23 +67,23 @@ async def detect_from_event(
67
67
  # The run's INITIAL STATE (the untrusted event is a REPL variable, not a chat turn) + prompt.
68
68
  "event": event_str,
69
69
  "instructions": task.instructions,
70
- # The change under review — echoed into the response envelope + the SIEM signal.
70
+ # The change under review: echoed into the response envelope + the SIEM signal.
71
71
  "source": _source_echo(event),
72
72
  # The host-derived facts about the author and the event (account age as of the event, profile,
73
73
  # timestamps, fork, commit offsets), so an offline reader sees what the ingest saw.
74
74
  **({"provenance": event["provenance"]} if event.get("provenance") else {}),
75
- # The deterministic baseline — assemble unions this with every scan_indicators tool call.
75
+ # The deterministic baseline: assemble unions this with every scan_indicators tool call.
76
76
  "baseline_indicators": baseline,
77
77
  # The emit threshold THIS run used, so an offline re-render/export re-derives the SAME signal.
78
78
  "emit_on": list(config.emit_on),
79
- # The models actually used this run (roles → concrete names) — self-describing trace.
79
+ # The models actually used this run (roles → concrete names): self-describing trace.
80
80
  "planner": config.main_model,
81
81
  "analyst": config.sub_model,
82
82
  "classifier": config.classifier_model,
83
83
  # The budget THIS run ran under, so an offline reader computes hit_iteration_cap correctly.
84
84
  "max_iterations": config.max_iterations,
85
85
  "max_llm_calls": config.max_llm_calls,
86
- # The ATLAS rubric decomposition carried as LABELS (structure only — the trainer scores dᵢ). A
86
+ # The ATLAS rubric decomposition carried as LABELS (structure only; the trainer scores dᵢ). A
87
87
  # FIXED skeleton (default_rubric): diff-sentry's task is constant, so only the per-criterion
88
88
  # FACTS vary per run (re-sourced from the trace on read; see rubric.criteria_facts).
89
89
  "rubric": rubric_to_meta(default_rubric()),
@@ -166,7 +166,7 @@ def run(
166
166
  emit_failed: bool = True,
167
167
  ) -> RunArtifacts:
168
168
  """THE programmatic entry: classify one change, write the response, and (host-side, after the run)
169
- emit a SIEM signal when warranted. Never raises on a run failure — a failed run still writes an
169
+ emit a SIEM signal when warranted. Never raises on a run failure: a failed run still writes an
170
170
  informative response and returns result-less RunArtifacts."""
171
171
  from rlm_harness.trace import load_events
172
172
 
@@ -181,7 +181,7 @@ def run(
181
181
  try:
182
182
  verdict = asyncio.run(detect_from_event(event, config, trace_path=trace_path, run_id=run_id,
183
183
  on_event=on_event, extra_tools=extra_tools))
184
- except Exception as exc: # noqa: BLE001 — a failed run is still self-contained + navigable
184
+ except Exception as exc: # noqa: BLE001 (a failed run is still self-contained + navigable)
185
185
  events = load_events(trace_path, run_id) if os.path.exists(trace_path) else []
186
186
  resp = build_failed_response(run_id, events, f"{type(exc).__name__}: {exc}")
187
187
  response_path = _write((os.path.join(outdir, "responses"), f"{run_id}.json"),
@@ -299,11 +299,11 @@ def drop_deleted_lines(text: str) -> str:
299
299
  """Strip a unified diff's `-` deletion lines, keeping additions and context.
300
300
 
301
301
  A diff carries the code being REMOVED as well as the code being added, and the detectors read plain
302
- text — so scanning a raw diff flags a change for a payload it is DELETING. That turns every
302
+ text, so scanning a raw diff flags a change for a payload it is DELETING. That turns every
303
303
  remediation commit red, which is the worst possible failure mode for a security gate: it penalises
304
304
  exactly the change you wanted. `---` (the file header) is kept; only true `-` deletion lines go.
305
305
 
306
- Non-diff input is returned untouched — the check is per line, so a plain source file has no `-`-
306
+ Non-diff input is returned untouched: the check is per line, so a plain source file has no `-`-
307
307
  prefixed lines to lose. `scan --include-deletions` opts back in when you want to audit what a change
308
308
  removed (a payload can also be hidden by deleting a check)."""
309
309
  if not text:
@@ -314,10 +314,10 @@ def drop_deleted_lines(text: str) -> str:
314
314
 
315
315
  def _cmd_scan(args) -> int:
316
316
  """The deterministic HALF of the pipeline, standalone: run the indicator suite over text and exit
317
- non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno — just `indicators`.
317
+ non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno, just `indicators`.
318
318
 
319
319
  This is what makes the detector safe to dogfood in our OWN CI. The full `pr`/`classify` path needs
320
- `DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target` — the exact
320
+ `DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target`, the exact
321
321
  misconfiguration that opened the AsyncAPI "Miasma" supply-chain compromise. `scan` needs nothing, so
322
322
  it runs under the same read-only `contents: read` token an untrusted fork PR already gets."""
323
323
  from .indicators import scan_diff
@@ -1,4 +1,4 @@
1
- """Configuration for diff-sentry — model ROLES, never hardcoded model names.
1
+ """Configuration for diff-sentry: model ROLES, never hardcoded model names.
2
2
 
3
3
  Three ROLES (the rlm-harness-consumer convention): the RLM PLANNER (root LM) drives the triage loop and
4
4
  holds the diff in the REPL; the ANALYST (sub LM, reached via `llm_query`) is an expensive brain for a
@@ -103,7 +103,7 @@ def _classifier_lm_kwargs() -> dict | None:
103
103
 
104
104
  @dataclass(frozen=True)
105
105
  class DetectConfig:
106
- # Planner (main) + analyst (sub), sharing one OpenAI-compatible proxy. No model name is assumed —
106
+ # Planner (main) + analyst (sub), sharing one OpenAI-compatible proxy. No model name is assumed:
107
107
  # `from_env` requires them. Empty defaults are only for direct construction in tests.
108
108
  main_model: str = "" # planner: a cheap, injection-resistant orchestrator driving the REPL loop
109
109
  sub_model: str = "" # analyst: an expensive brain for a subtle case (reached via llm_query)
@@ -121,11 +121,11 @@ class DetectConfig:
121
121
  classifier_transient_retries: int = 1
122
122
  # Extra request parameters of the classifier's call, over its `temperature=0.1` (`_classifier_lm_kwargs`).
123
123
  classifier_lm_kwargs: dict | None = None
124
- # After this many CONSECUTIVE invalid classifications the tool short-circuits (rlm-harness make_model_tool).
124
+ # After this many CONSECUTIVE unusable replies or repeating error statuses the tool short-circuits (make_model_tool).
125
125
  classifier_circuit_break: int = 4
126
126
 
127
127
  # ── RLM runtime knobs ────────────────────────────────────────────────────────────────────────
128
- interpreter: str = "pyodide" # read-not-execute: the classifier NEVER runs the change (see README)
128
+ interpreter: str = "pyodide" # read-not-execute: the classifier NEVER runs the change (see docs/how-it-works.md)
129
129
  observe: bool = False
130
130
  adapter: str = "json"
131
131
  # Generous, NOT None: a reasoning planner truncated before its answer returns empty content.
@@ -135,14 +135,14 @@ class DetectConfig:
135
135
  # reasoning planner from thinking through the whole `planner_max_tokens` and answering nothing.
136
136
  planner_lm_kwargs: dict | None = None
137
137
  analyst_lm_kwargs: dict | None = None
138
- # HARD ceiling on the single RLM episode. No outer multi-run loop (max_retries=1) — one change =
138
+ # HARD ceiling on the single RLM episode. No outer multi-run loop (max_retries=1): one change =
139
139
  # one trajectory, so the trace stays valid training data. A failed run is INFRA, not a schema bug:
140
140
  # a non-retryable LM error escapes as the raw dspy.LMError, the rest as RLMTaskError.
141
141
  max_iterations: int = 25
142
142
  max_llm_calls: int = 8 # caps ONLY analyst (llm_query) escalations
143
143
  max_output_chars: int = 10_000 # head+tail char cap dspy.RLM applies to each REPL output
144
144
 
145
- # ── Enrichment fetch (opt-in, GitHub-allowlisted — MF2) ──────────────────────────────────────
145
+ # ── Enrichment fetch (opt-in, GitHub-allowlisted, MF2) ──────────────────────────────────────
146
146
  enable_fetch: bool = False # OFF by default: fetch on attacker input is an exfil surface
147
147
  fetch_timeout: float = 20.0
148
148
  fetch_max_bytes: int = 400_000
@@ -157,7 +157,7 @@ class DetectConfig:
157
157
  siem_webhook_url: str = ""
158
158
  siem_token: str = ""
159
159
  # Verdicts (or a deterministic high/critical indicator) that emit a signal. A benign self-report
160
- # cannot suppress a hard indicator — see assemble.assemble_verdict.
160
+ # cannot suppress a hard indicator; see assemble.assemble_verdict.
161
161
  emit_on: tuple[str, ...] = ("suspicious", "malicious")
162
162
 
163
163
  @property
@@ -170,7 +170,7 @@ class DetectConfig:
170
170
 
171
171
  @classmethod
172
172
  def screen_from_env(cls) -> DetectConfig:
173
- """A config with NO model roles — only the SIEM emitter and `emit_on`. For the deterministic screen
173
+ """A config with NO model roles, only the SIEM emitter and `emit_on`. For the deterministic screen
174
174
  (`watch --screen-only`), which never runs the model and so must not require `DS_ROOT_LM`."""
175
175
  return cls(
176
176
  siem_webhook_url=os.getenv("DS_SIEM_WEBHOOK_URL", ""),