diff-sentry 0.7.0__tar.gz → 0.8.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 (153) hide show
  1. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/detect-setup.md +3 -2
  2. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/indicators.md +12 -2
  3. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/rubric-and-eval.md +1 -0
  4. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/skills/release/SKILL.md +3 -1
  5. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.github/workflows/ci.yml +3 -3
  6. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.github/workflows/release.yml +6 -6
  7. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/AGENTS.md +1 -1
  8. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/CHANGELOG.md +29 -0
  9. diff_sentry-0.8.0/PKG-INFO +116 -0
  10. diff_sentry-0.8.0/README.md +95 -0
  11. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/action.yml +1 -1
  12. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/__init__.py +3 -3
  13. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/actors.py +1 -1
  14. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/assemble.py +5 -5
  15. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/cli.py +14 -14
  16. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/config.py +8 -8
  17. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/deep_classify.py +23 -6
  18. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/detect.py +9 -9
  19. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/emit.py +6 -6
  20. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/fetch_tool.py +4 -4
  21. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/indicators.py +245 -68
  22. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/ingest.py +22 -22
  23. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/normalize.py +9 -9
  24. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/response.py +5 -5
  25. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/rl_export.py +11 -11
  26. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/rubric.py +16 -16
  27. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/schema.py +11 -11
  28. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/snapshot.py +1 -1
  29. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/watch.py +11 -11
  30. diff_sentry-0.8.0/docs/development.md +14 -0
  31. diff_sentry-0.8.0/docs/how-it-works.md +58 -0
  32. diff_sentry-0.8.0/docs/limits.md +17 -0
  33. diff_sentry-0.8.0/docs/watch.md +43 -0
  34. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/__init__.py +3 -3
  35. diff_sentry-0.8.0/eval/diff_sentry_eval/__main__.py +6 -0
  36. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/cli.py +3 -3
  37. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/judge.py +35 -16
  38. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/schema.py +7 -7
  39. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/score.py +7 -7
  40. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/diff_sentry_eval/taskset.py +6 -6
  41. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/conftest.py +1 -1
  42. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_boundary.py +1 -1
  43. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_cli.py +1 -1
  44. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_judge.py +27 -1
  45. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_schema.py +1 -1
  46. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_score.py +1 -1
  47. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/tests/test_taskset.py +2 -2
  48. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/pyproject.toml +10 -10
  49. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/README.md +1 -1
  50. diff_sentry-0.8.0/studio/diff_sentry_studio/__init__.py +1 -0
  51. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/app.py +17 -17
  52. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/iterations.py +14 -14
  53. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/live.py +16 -16
  54. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/diff_sentry_studio/mapper.py +11 -11
  55. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/pyproject.toml +3 -3
  56. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/app.js +16 -16
  57. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/index.html +1 -1
  58. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/replay-core.js +3 -3
  59. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/run-core.js +9 -9
  60. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/style.css +7 -7
  61. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/trajectory.js +2 -2
  62. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/static-contract.test.js +2 -2
  63. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_app.py +3 -3
  64. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_iterations.py +2 -2
  65. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_live.py +5 -5
  66. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/test_mapper.py +5 -5
  67. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/conftest.py +29 -4
  68. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_assemble.py +2 -2
  69. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_cli.py +9 -9
  70. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_cli_watch.py +1 -1
  71. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_config.py +2 -2
  72. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_deep_classify.py +37 -0
  73. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_detect.py +2 -2
  74. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_detection_quality.py +8 -8
  75. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_export.py +5 -5
  76. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_fetch_tool.py +1 -1
  77. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_forward_offline.py +5 -5
  78. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_grounding.py +34 -10
  79. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_incident_hackerbot_claw.py +6 -6
  80. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_indicators.py +18 -18
  81. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_ingest.py +1 -1
  82. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_injection_offline.py +5 -5
  83. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_inline_exec_and_bidi.py +0 -9
  84. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_metadata_rules.py +1 -1
  85. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_provenance.py +5 -5
  86. diff_sentry-0.8.0/tests/test_python_loader.py +220 -0
  87. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_response.py +4 -4
  88. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_rubric.py +2 -2
  89. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_schema.py +1 -1
  90. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_subscription.py +2 -2
  91. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/uv.lock +5 -5
  92. diff_sentry-0.7.0/PKG-INFO +0 -214
  93. diff_sentry-0.7.0/README.md +0 -193
  94. diff_sentry-0.7.0/eval/diff_sentry_eval/__main__.py +0 -6
  95. diff_sentry-0.7.0/studio/diff_sentry_studio/__init__.py +0 -1
  96. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/handoff.md +0 -0
  97. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.claude/rules/monitoring.md +0 -0
  98. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.env.example +0 -0
  99. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.gitignore +0 -0
  100. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/.python-version +0 -0
  101. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/LICENSE +0 -0
  102. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/VENDOR.md +0 -0
  103. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/__main__.py +0 -0
  104. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/dbutil.py +0 -0
  105. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/hosts.py +0 -0
  106. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/linewide.py +0 -0
  107. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/shelllex.py +0 -0
  108. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
  109. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
  110. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/developer-autorun.md +0 -0
  111. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/llm-directed-text.md +0 -0
  112. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
  113. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
  114. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/diff_sentry/skills/triage-a-change.md +0 -0
  115. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/README.md +0 -0
  116. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/pyproject.toml +0 -0
  117. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/eval/taskset.example.json +0 -0
  118. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/DESIGN.md +0 -0
  119. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  120. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  121. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/replay-core.test.js +0 -0
  122. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/run-core.test.js +0 -0
  123. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/studio/tests/trajectory.test.js +0 -0
  124. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/__init__.py +0 -0
  125. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/corpus/change_corpus.json +0 -0
  126. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
  127. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  128. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/fixtures/known-malicious.diff +0 -0
  129. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/__init__.py +0 -0
  130. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/grammar.py +0 -0
  131. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/harness.py +0 -0
  132. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/lexrunner.py +0 -0
  133. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/programs.py +0 -0
  134. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/oracle/runner.py +0 -0
  135. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_actors.py +0 -0
  136. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_adversarial_input.py +0 -0
  137. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_campaign_replay.py +0 -0
  138. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_dogfood_gate.py +0 -0
  139. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_emit.py +0 -0
  140. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_enclosure.py +0 -0
  141. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_file_scope.py +0 -0
  142. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_file_structure.py +0 -0
  143. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_gate_edges.py +0 -0
  144. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_hosts.py +0 -0
  145. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_ingest_metadata.py +0 -0
  146. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_linewide.py +0 -0
  147. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_normalize.py +0 -0
  148. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_oracle.py +0 -0
  149. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_oracle_harness.py +0 -0
  150. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_shelllex.py +0 -0
  151. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_snapshot.py +0 -0
  152. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/tests/test_watch.py +0 -0
  153. {diff_sentry-0.7.0 → diff_sentry-0.8.0}/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
 
@@ -121,7 +121,7 @@ All in `indicators._soften_prose`, beside `invisible-unicode` in right-to-left t
121
121
 
122
122
  ## The file-structure rules (`scan_files`)
123
123
 
124
- A dev machine infected by a loader of the Contagious Interview / PolinRider family pushes its payload with ordinary work: obfuscated JavaScript appended to a config that runs on lint or build, a "font" that is a script, and a way to run it nobody types (a VS Code task that runs on folder open, an npm script now starting with `node <new file> &&`). `scan_files` reads what only the file list knows; `scan_change` runs it beside `scan_content` and `scan_metadata`.
124
+ A dev machine infected by a loader of the Contagious Interview / PolinRider family pushes its payload with ordinary work: obfuscated JavaScript appended to a config that runs on lint or build, a "font" that is a script, and a way to run it nobody types (a VS Code task that runs on folder open, an npm script now starting with `node <new file> &&`). `scan_files` reads what only the file list knows; `scan_change` runs it beside `scan_content` and `scan_metadata`, and the Action's `scan` (`scan_diff(files=True)`) runs the same rules on each file of a diff, reading whether it is added or renamed from its header (`new file mode`, `--- /dev/null`, `rename to`) and its patch from its hunks. A path git quotes (`"b/p\303\251/x.pth"`, a byte outside printable ASCII) is unquoted. The planner's scan does not run them: a diff quoted in a comment is no file of the change, and the baseline already ran them on the change's own files.
125
125
 
126
126
  - **`package.json` and the VS Code files are read as JSONC tokens, never line by line** (`_jsonc_tokens` over the new side of the patch, each token marked written or context). A comment between a key and its value, a value on the next line, a `\u` escape, a one-line `"scripts": {...}`, a task's `args` array and `allowAutomaticTasks` nested under `"task"` are ordinary spellings, and a line regex misses each of them. A hit needs a token the change wrote.
127
127
  - **It pairs a run entry with the change's file LIST, not with another file's text.** `run-entry-new-file` asks whether a `package.json` script, or a VS Code task `command` with its `args`, runs a file whose status is `added`. That is a structural fact, so it does not break "a paired rule only pairs within one file". A target resolves against the `package.json` directory, and against the parent of the `.vscode` folder for a VS Code file.
@@ -131,11 +131,21 @@ A dev machine infected by a loader of the Contagious Interview / PolinRider fami
131
131
  - **`vscode-autorun` is `high`**: a written `"runOn": "folderOpen"` in a `.vscode/` file or a `.code-workspace`, and a written `task.allowAutomaticTasks` set to `true` or `"on"`, which turns automatic tasks on for the workspace (VS Code planned to make `off` the default). A maintainer adding a folder-open watcher task is an accepted over-alert: the setting runs code on open, the campaign's own trigger.
132
132
  - **`binary-name-holds-text` is `high`**: a text patch under a font, image, archive or native-binary name, or added lines with no patch (a text diff too large to show). A real file of those formats has a NUL in its first bytes, so GitHub sends no patch and counts no lines. A Git LFS pointer is exempt only when its whole new side is pointer keys (`version` first, then `oid sha256:<64 hex>`, `size <n>`), so an update that rewrites only `oid` and `size` stays exempt and a script with a pointer line inside does not. `.svg` is text by nature and `.pdf` is often stored as text, so neither is on the list.
133
133
  - **`obfuscated-dev-config` is `critical`**: `obfuscated-string-array` in the ADDED lines of a config a developer's tools execute (`eslint`, `vite`, `vitest`, `webpack`, `rollup`, `next`, `nuxt`, `tailwind`, `postcss`, `babel`, `jest`, `playwright`, `prettier`, `hardhat`, `commitlint`, `stylelint` configs and their `rc` files, `truffle-config`, `gatsby-*`, `.mocharc`, gulpfile, Gruntfile, `karma.conf`). Removing it is cleanup.
134
+ - **`python-startup-hook` is `high`**: a written `.pth` line that starts with `import` and a blank (the only lines `site` executes; the rest of a `.pth` is a list of paths), after an optional BOM, with each line of the patch split as `site` splits it (`str.splitlines`, so a lone CR or a form feed starts a line of its own); and any written line, or a rename, of a `sitecustomize` or `usercustomize` module or package. A `.pth` renamed without a change to its content shows no line and is a known gap. Python runs both at every start of an interpreter whose site-packages holds them, the litellm 1.107.0 wheels' trigger. The shims of packaging and instrumentation tools (virtualenv, setuptools' `distutils-precedence.pth`, coverage, OpenTelemetry's `sitecustomize.py`) are an accepted over-alert: they run code at every start too.
134
135
  - **The token walks are bounded**: a comment never spans two hunks (the lines between them are unseen), and a `command`'s object is searched at most `_OBJECT_REACH` tokens each way, for at most `_MAX_RUN_ENTRIES` entries a file.
135
136
  - **A structural hit's id carries its path** (`form=path`): the same setting in `tasks.json` and `settings.json` is two findings, not one deduped into the other.
136
137
  - **`obfuscated-string-array` (`high`) runs everywhere**: javascript-obfuscator's rotation loop `while(!![])` with the push/shift rotation or 20 hex-indexed decoder calls. It needs no `_0x` names, which a mangled-name setting removes (`obfuscated-identifiers` still reads those). Hand-rewritten output (`while(!0)`) is a known gap.
137
138
  - **Measured:** over 857 live changes (6,858 files from four large repositories) the rules fired only on the one change they were written for, and the string-array signature appeared in none of 3,553 JavaScript files over 30 KB (bundles and minified libraries among them). Terser and esbuild write `!0` and `for(;;)`, never `!![]`.
138
139
 
140
+ ## Python loaders (`_scan_py_exec`)
141
+
142
+ Text rules, so they read every channel and a value the planner decodes. Over 39,589 distinct Python files of PyPI packages (725 MB), the three fired on two files: an `eval` beside `urlopen` in a test helper and mypy's daemon.
143
+
144
+ - **`packed-code-exec` is `high`**: `exec` / `eval` (the builtins, also as `builtins.exec` or `getattr(__builtins__, 'exec')`; not a method `.exec(`, not `literal_eval`) whose argument is a decoding call (the base64 family, `decodebytes`, hex, `decompress`, `marshal.loads`; not `json.loads`), through `compile(`, a module alias, a bare imported name or `__import__('zlib')`; a name assigned a decoding call and run after a `;` on the same line; and JavaScript's `eval` / `new Function` over `atob(` or `Buffer.from(`. A decoded value run on a later line is the model stage's to read.
145
+ - **`remote-code-exec` is `high`**: a Python HTTP client (`requests`, `urlopen`, `urlretrieve`, `httpx`, `aiohttp`, `urllib3`) and `exec` / `eval` in the same file.
146
+ - **`detached-process-spawn` also covers Python**: `os.fork` and `os.setsid` in the same file. `subprocess.Popen(start_new_session=True)` is a known gap.
147
+ - **`obfuscated-payload` inflates first**: a base64 blob that decodes to a zlib stream (any window size) or a gzip member is decompressed before it is read for a payload, each blob to at most 16 times its size and the blobs of one text to 256 KiB in all, because every inflated byte is read by the rules again. A raw deflate stream, and a blob past the budget, are read as they decode: decoy blobs in front can spend the budget, and the `exec` that runs the payload still fires `packed-code-exec` on its own.
148
+
139
149
  ## What the kind of a segment changes
140
150
 
141
151
  `_scope_to_file` runs on every segment of an event but the branch name (`scan_content`) and on every file of a diff (`scan_diff`), after the detectors and the prose softening. A branch name keeps every hit, and so does the planner's `scan_indicators`, but for a maintainer's `inline-code-exec` it copies (`_written_by_maintainer`, above).
@@ -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.
@@ -33,5 +33,7 @@ check it by hand.
33
33
  …`, notes = the CHANGELOG section). The user publishes it in the web UI with "Publish this Action to
34
34
  the GitHub Marketplace" ticked (the CLI cannot tick it). Publishing is what builds, verifies, and
35
35
  uploads to PyPI via Trusted Publishing, and the upload is irreversible.
36
- 6. After `release.yml` succeeds, re-run the failed consumer-install job
36
+ 6. After `release.yml` succeeds and PyPI's simple index lists the version (`curl -s -H "Accept:
37
+ application/vnd.pypi.simple.v1+json" https://pypi.org/simple/diff-sentry/`; the index lags the upload by
38
+ minutes, and a rerun before it lists the version fails to resolve), re-run the failed consumer-install job
37
39
  (`gh run rerun <ci run id> --failed`); green proves the published package installs and scans.
@@ -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,35 @@
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.8.0
6
+
7
+ Upgrade from 0.7.1: the GitHub Action now runs the file-structure rules and catches Python loaders, so a pull request it passed before can fail it now (see **Added** and **Fixed**). Nothing needs configuring, and `rlm-harness==1.22.0` is unchanged.
8
+
9
+ ### Added
10
+ - **Rules for the litellm 1.107.0 loader shape** ([BerriAI/litellm#47826](https://github.com/BerriAI/litellm/issues/47826)), all `high`:
11
+ - `python-startup-hook`: a `.pth` line that Python runs at every start, and a `sitecustomize` or `usercustomize` module or package;
12
+ - `packed-code-exec`: `exec` or `eval` over a decoding call such as `zlib.decompress(base64.b64decode(...))`, and JavaScript's `eval` or `new Function` over `atob` or `Buffer.from`;
13
+ - `remote-code-exec`: a Python HTTP client and `exec` or `eval` in the same file;
14
+ - `detached-process-spawn` also covers Python's `os.fork` with `os.setsid`.
15
+ - **A packed payload is inflated before it is read**: a base64 blob holding a zlib or gzip stream is decompressed, within a size budget, and scanned for a payload.
16
+
17
+ ### Fixed
18
+ - **The Action's scan runs the file-structure rules**: a VS Code task that runs on folder open, an npm lifecycle hook or task that runs a file the change adds, a script under an image or font name, obfuscated code in a developer tool's config, and the new startup hooks. Before, only the model pipeline and `watch` ran them.
19
+ - **A path git quotes in a diff header** (a byte outside printable ASCII) is read as its file instead of folding into the file before it.
20
+
21
+ ## 0.7.1
22
+
23
+ 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.
24
+
25
+ ### Changed
26
+ - **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.
27
+ - **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.
28
+ - **`rlm-harness==1.22.0`.**
29
+ - **The README covers using diff-sentry**; how it works, the watcher, the known limits and development moved to `docs/`.
30
+
31
+ ### Fixed
32
+ - **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`.
33
+
5
34
  ## 0.7.0
6
35
 
7
36
  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.8.0
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.8.0
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, Python startup hooks (`.pth`, `sitecustomize.py`), packed or downloaded code run with `exec` |
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.8.0
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, Python startup hooks (`.pth`, `sitecustomize.py`), packed or downloaded code run with `exec` |
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.8.0'
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.8.0"
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
@@ -336,7 +336,7 @@ def _cmd_scan(args) -> int:
336
336
  label = path
337
337
  if not args.include_deletions:
338
338
  text = drop_deleted_lines(text)
339
- hits.extend(scan_diff(text, location=label))
339
+ hits.extend(scan_diff(text, location=label, files=True))
340
340
 
341
341
  worst = max((severity_rank(h.severity) for h in hits), default=-1)
342
342
  failed = worst >= threshold