diff-sentry 0.8.0__tar.gz → 0.9.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 (152) hide show
  1. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.claude/rules/monitoring.md +10 -0
  2. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.env.example +3 -0
  3. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/AGENTS.md +4 -4
  4. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/CHANGELOG.md +11 -0
  5. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/PKG-INFO +5 -2
  6. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/README.md +3 -1
  7. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/action.yml +1 -1
  8. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/__init__.py +1 -1
  9. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/cli.py +63 -1
  10. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/indicators.py +3 -2
  11. diff_sentry-0.9.0/diff_sentry/releases.py +895 -0
  12. diff_sentry-0.9.0/docs/release-watch.md +26 -0
  13. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/pyproject.toml +2 -1
  14. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_adversarial_input.py +1 -1
  15. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_indicators.py +7 -0
  16. diff_sentry-0.9.0/tests/test_releases.py +614 -0
  17. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/uv.lock +3 -1
  18. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.claude/rules/detect-setup.md +0 -0
  19. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.claude/rules/handoff.md +0 -0
  20. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.claude/rules/indicators.md +0 -0
  21. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.claude/rules/rubric-and-eval.md +0 -0
  22. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.claude/skills/release/SKILL.md +0 -0
  23. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.github/workflows/ci.yml +0 -0
  24. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.github/workflows/release.yml +0 -0
  25. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.gitignore +0 -0
  26. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/.python-version +0 -0
  27. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/LICENSE +0 -0
  28. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/VENDOR.md +0 -0
  29. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/__main__.py +0 -0
  30. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/actors.py +0 -0
  31. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/assemble.py +0 -0
  32. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/config.py +0 -0
  33. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/dbutil.py +0 -0
  34. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/deep_classify.py +0 -0
  35. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/detect.py +0 -0
  36. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/emit.py +0 -0
  37. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/fetch_tool.py +0 -0
  38. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/hosts.py +0 -0
  39. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/ingest.py +0 -0
  40. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/linewide.py +0 -0
  41. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/normalize.py +0 -0
  42. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/response.py +0 -0
  43. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/rl_export.py +0 -0
  44. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/rubric.py +0 -0
  45. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/schema.py +0 -0
  46. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/shelllex.py +0 -0
  47. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/skills/ci-and-workflows.md +0 -0
  48. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/skills/concealment-and-provenance.md +0 -0
  49. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/skills/developer-autorun.md +0 -0
  50. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/skills/llm-directed-text.md +0 -0
  51. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/skills/package-and-build-hooks.md +0 -0
  52. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/skills/runtime-droppers-and-exfil.md +0 -0
  53. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/skills/triage-a-change.md +0 -0
  54. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/snapshot.py +0 -0
  55. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/diff_sentry/watch.py +0 -0
  56. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/docs/development.md +0 -0
  57. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/docs/how-it-works.md +0 -0
  58. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/docs/limits.md +0 -0
  59. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/docs/watch.md +0 -0
  60. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/README.md +0 -0
  61. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/diff_sentry_eval/__init__.py +0 -0
  62. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/diff_sentry_eval/__main__.py +0 -0
  63. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/diff_sentry_eval/cli.py +0 -0
  64. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/diff_sentry_eval/judge.py +0 -0
  65. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/diff_sentry_eval/schema.py +0 -0
  66. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/diff_sentry_eval/score.py +0 -0
  67. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/diff_sentry_eval/taskset.py +0 -0
  68. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/pyproject.toml +0 -0
  69. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/taskset.example.json +0 -0
  70. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/tests/conftest.py +0 -0
  71. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/tests/test_boundary.py +0 -0
  72. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/tests/test_cli.py +0 -0
  73. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/tests/test_judge.py +0 -0
  74. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/tests/test_schema.py +0 -0
  75. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/tests/test_score.py +0 -0
  76. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/eval/tests/test_taskset.py +0 -0
  77. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/DESIGN.md +0 -0
  78. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/README.md +0 -0
  79. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/diff_sentry_studio/__init__.py +0 -0
  80. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/diff_sentry_studio/app.py +0 -0
  81. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/diff_sentry_studio/iterations.py +0 -0
  82. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/diff_sentry_studio/live.py +0 -0
  83. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/diff_sentry_studio/mapper.py +0 -0
  84. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/pyproject.toml +0 -0
  85. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/app.js +0 -0
  86. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/index.html +0 -0
  87. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/replay-core.js +0 -0
  88. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/run-core.js +0 -0
  89. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/style.css +0 -0
  90. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/trajectory.js +0 -0
  91. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
  92. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
  93. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/replay-core.test.js +0 -0
  94. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/run-core.test.js +0 -0
  95. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/static-contract.test.js +0 -0
  96. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/test_app.py +0 -0
  97. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/test_iterations.py +0 -0
  98. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/test_live.py +0 -0
  99. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/test_mapper.py +0 -0
  100. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/studio/tests/trajectory.test.js +0 -0
  101. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/__init__.py +0 -0
  102. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/conftest.py +0 -0
  103. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/corpus/change_corpus.json +0 -0
  104. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/corpus/hackerbot_claw_campaign.json +0 -0
  105. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
  106. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/fixtures/known-malicious.diff +0 -0
  107. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/oracle/__init__.py +0 -0
  108. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/oracle/grammar.py +0 -0
  109. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/oracle/harness.py +0 -0
  110. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/oracle/lexrunner.py +0 -0
  111. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/oracle/programs.py +0 -0
  112. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/oracle/runner.py +0 -0
  113. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_actors.py +0 -0
  114. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_assemble.py +0 -0
  115. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_campaign_replay.py +0 -0
  116. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_cli.py +0 -0
  117. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_cli_watch.py +0 -0
  118. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_config.py +0 -0
  119. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_deep_classify.py +0 -0
  120. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_detect.py +0 -0
  121. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_detection_quality.py +0 -0
  122. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_dogfood_gate.py +0 -0
  123. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_emit.py +0 -0
  124. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_enclosure.py +0 -0
  125. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_export.py +0 -0
  126. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_fetch_tool.py +0 -0
  127. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_file_scope.py +0 -0
  128. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_file_structure.py +0 -0
  129. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_forward_offline.py +0 -0
  130. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_gate_edges.py +0 -0
  131. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_grounding.py +0 -0
  132. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_hosts.py +0 -0
  133. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_incident_hackerbot_claw.py +0 -0
  134. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_ingest.py +0 -0
  135. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_ingest_metadata.py +0 -0
  136. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_injection_offline.py +0 -0
  137. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_inline_exec_and_bidi.py +0 -0
  138. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_linewide.py +0 -0
  139. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_metadata_rules.py +0 -0
  140. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_normalize.py +0 -0
  141. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_oracle.py +0 -0
  142. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_oracle_harness.py +0 -0
  143. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_provenance.py +0 -0
  144. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_python_loader.py +0 -0
  145. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_response.py +0 -0
  146. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_rubric.py +0 -0
  147. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_schema.py +0 -0
  148. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_shelllex.py +0 -0
  149. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_snapshot.py +0 -0
  150. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_subscription.py +0 -0
  151. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_watch.py +0 -0
  152. {diff_sentry-0.8.0 → diff_sentry-0.9.0}/tests/test_watch_pool.py +0 -0
@@ -42,3 +42,13 @@ paths:
42
42
  - **Adding a fact:** capture it in `ingest.py` (best-effort: a failed call omits the key, unless the value feeds the digest, in which case mark the event incomplete instead of reporting "none"), put it in `provenance` (or a bounded event field), read it in `scan_provenance` / `scan_metadata` with a negative case, and keep it sub-floor unless it is standalone evidence. Replay the incident corpus.
43
43
  - **Read-only means read-only.** `--stats` and `actor` open the state `mode=ro` (`open_versioned(read_only=True)`) and neither create, stamp, nor requeue anything. Only `serve` and `--once` call `recover()`, under the lock.
44
44
  - **Terminal output is sanitized.** `format_dossier` replaces control characters; `_json_out` escapes the C1 range `json.dumps` leaves raw; URL and token parsing cut at the first control character. A string an attacker wrote must not be able to drive the operator's terminal.
45
+
46
+ ## Release watch (`releases.py`, `release-watch`)
47
+
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.
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
+ - **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
+ - **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`.
53
+ - **The previous release** is the highest version below the new one with a file that is not yanked; a pre-release is compared only when the new one is a pre-release too. Members are compared by content across all artifacts of a kind, so a payload in one platform's wheel shows.
54
+ - **State is per file.** First sight of a package (even an empty one) records every file as the baseline. After that, each file not yet recorded is judged with the other new files of its release, against the previous release, so a wheel uploaded after its release was judged, or added to an old release, is judged too. A file is recorded only after its comparison is screened, and the index's ETag only after every new file of it is: a watcher stopped mid-comparison judges the same files on its next pass. A failed comparison is retried on every pass (the ETag is not sent while one is pending), superseded when the files to judge change, and kept as `error` after five, with an alert that it was not compared. A `--check` runs under `...-check` and never stands in for the watcher's judgment. An alert the SIEM did not accept is re-sent from `screened/` a few per poll.
@@ -79,3 +79,6 @@ DS_API_KEY=sk-...
79
79
  # DS_WATCH_MAX_DEEP_PER_HOUR=0 # 0 = unlimited model runs per hour
80
80
  # DS_WATCH_WORKERS=1 # model runs at once; above 1, each in a process of its own
81
81
  # DS_WATCH_RATE_RESERVE=200 # pause polling when the API's remaining budget falls below this
82
+ # DS_RELEASE_PACKAGES=litellm,requests # release-watch: PyPI projects (or --packages / --packages-file)
83
+ # DS_RELEASE_INTERVAL=300 # release-watch: seconds between polls of each project's index
84
+ # DS_RELEASE_MAX_ARTIFACT_MB=256 # release-watch: a larger wheel or sdist is not compared, which alerts
@@ -16,7 +16,7 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
16
16
 
17
17
  - Run via `cli` (`pr` / `issue` / `classify`), never an ad-hoc script. `cli.run(event, …)` is the programmatic entry: it records `<out>/traces/{run_id}.jsonl` (dropping a stale one first, since TraceRecorder appends), writes `<out>/responses/{run_id}.json`, and emits the SIEM signal host-side after the run. It never raises: a crash still writes a `status=failed` response that can signal off the evidence floor. Extend `cli.py` rather than driving `detect_from_event` / `build_response` directly.
18
18
  - Offline: `python -m diff_sentry render <trace> <run_id>` re-renders a response; `python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset; `actor <login>` prints an account's dossier and `archive verify` re-hashes the snapshot archive.
19
- - Continuous collection goes through `watch`, which calls `cli.run` (model) or `cli.screen` (deterministic, no creds) and never `detect_from_event` directly. `--screen-only` needs no model roles.
19
+ - Continuous collection goes through `watch` and `release-watch`, which call `cli.run` (model) or `cli.screen` (deterministic, no creds) and never `detect_from_event` directly. `--screen-only` needs no model roles.
20
20
 
21
21
  ## Hard invariants: do not break
22
22
 
@@ -31,10 +31,10 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
31
31
  - **The budget is hard: `max_retries=1`, no whole-RLM retry inside a run.** One change = one trajectory, so the trace stays valid training data. (`watch` may run a change again, but only when the previous attempt produced no result at all, an infrastructure failure; `cli.run` resets that run id's trace first, so exactly one trajectory per run id survives.) A failed run is infra before it is a schema bug; see `.claude/rules/detect-setup.md` for how the failure names itself.
32
32
  - **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` (on rlm-harness's `make_model_tool`) is the swappable second-stage seam the planner chooses to call, so the decision is a `tool_call` in the trajectory. The analyst intercept (`intercept_sub_lm`) is tracing-only, zero transforms. Swapping the backend touches only `classify_backend` / `_selfclassify_chat`. The planner passes distilled findings, never the whole diff.
33
33
  - **Sandboxed code builds no sub-agents.** In dspy 3.4.1 and later, sandboxed code can construct dspy programs that run on the host, call the analyst, can drive the task's tools, and record as one `sub_call` with no inner turns. The kit's default allows it, so `setup` passes `sandbox_sub_agents=False` explicitly, and production never injects an interpreter (which would bypass it); `test_program_offers_no_sub_agents` holds the behaviour.
34
- - **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`, `actors` and `snapshot` poll, remember, and archive outside the trajectory. Only choices the policy makes are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as provenance facts through `scan_provenance`, never as a second derivation (see `.claude/rules/monitoring.md`).
34
+ - **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py` shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`, `actors` and `snapshot` poll, remember, and archive outside the trajectory, and `releases` reads PyPI's own hosts only, as data that is never installed. Only choices the policy makes are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as provenance facts through `scan_provenance`, never as a second derivation (see `.claude/rules/monitoring.md`).
35
35
  - **This is a ROLLOUT source: trajectories, not reward.** `rl_export` passes `reward=None`; labels read the assembled verdict and metrics are objective effort counters. `deep_classify` tool_calls train the classifier; every other action is the planner's. A metric keeps ONE meaning across rlm-harness versions (the corpus mixes them; `run_start.payload.rlm_harness` names the writer): a quantity a newer kit makes observable gets a NEW key that is None where the trace cannot say (e.g. `analyst_failures`). The ATLAS rubric and the eval member stay reward-free too; see `.claude/rules/rubric-and-eval.md`.
36
36
  - **Attack knowledge lives in `diff_sentry/skills/`, not the prompt.** The skill catalog is injected (`load_skills_as_tools(discovery="inject")`) and `read_skill(name)` pulls a body on demand. Fix a wrong convention by editing or adding a skill; `detect.INSTRUCTIONS` holds only identity, the MISSION frame, the tool cost model, the triage loop, and output-gating rules. Skills ship via `packages = ["diff_sentry"]`; a force-include duplicates them and breaks the build.
37
- - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `rl_export`, and `rubric` must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
37
+ - **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`, `response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `releases`, `rl_export`, and `rubric` must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` / `run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
38
38
  - **The trace self-describes the run.** `run_start` meta carries the normalized event, instructions, source echo, `baseline_indicators`, `emit_on`, role→model names, budgets, and the rubric skeleton, so an offline re-render/export re-derives the same `signal` (reading `emit_on` from meta, never current config). Any new per-run config that affects read-time derivation must ride in meta.
39
39
 
40
40
  ## Topic guides
@@ -54,5 +54,5 @@ Claude Code loads each rule automatically when you touch a matching file; other
54
54
 
55
55
  - rlm-harness owns the generic cores: `make_model_tool` (chat → retry → validate → circuit-break) under `deep_classify`, and the SSRF primitives (`is_safe_url`, `resolved_host_is_safe`, `parse_cidrs`) under `fetch_tool`, which adds the GitHub allowlist, the httpx provider, and tracing.
56
56
  - When this consumer needs a workaround, fix the reusable gap in rlm-harness generically; never special-case diff-sentry there. Consumer values (`DS_*` roles, the verdict schema, indicator rules, the SIEM payload shape) stay here.
57
- - The deterministic tier ships as `cli.screen` and `watch` (host-side, in front of `cli.run`). Next increments: an optional small single-shot model call inside that tier; a GH Archive backfill for account history older than the Events API's 30 days; per-repository workflow awareness (which repos run `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
57
+ - The deterministic tier ships as `cli.screen`, `watch` and `release-watch` (host-side, in front of `cli.run`). Next increments: comparing a release's sdist with its git tag, and npm releases beside PyPI's; an optional small single-shot model call inside that tier; a GH Archive backfill for account history older than the Events API's 30 days; per-repository workflow awareness (which repos run `issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
58
58
  - Two workspace members, never in the `diff_sentry` wheel: `studio/` (`diff-sentry-studio`, the detection console, behind its `live` extra) and `eval/` (`diff-sentry-eval`, the reward-free scorecard). Both read the trace / `DetectionResponse` contract through the public surface only. Keep the root wheel at `packages = ["diff_sentry"]` so `uv build` never sweeps them in.
@@ -2,6 +2,17 @@
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.9.0
6
+
7
+ 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
+
9
+ ### 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.
12
+
13
+ ### Fixed
14
+ - **`data-exfiltration` no longer reads a Rust closure named `env`** (`|env| self.getenv(env)`) as a shell dumping its environment into a pipe.
15
+
5
16
  ## 0.8.0
6
17
 
7
18
  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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diff-sentry
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -8,6 +8,7 @@ License-File: LICENSE
8
8
  Keywords: dspy,malicious-code-detection,prompt-injection,rlm,security,siem,supply-chain-security
9
9
  Requires-Python: >=3.11
10
10
  Requires-Dist: httpx>=0.27
11
+ Requires-Dist: packaging>=23
11
12
  Requires-Dist: pydantic>=2.11.0
12
13
  Requires-Dist: rlm-harness==1.22.0
13
14
  Provides-Extra: classify
@@ -42,7 +43,7 @@ jobs:
42
43
  - uses: actions/checkout@v7
43
44
  with:
44
45
  fetch-depth: 0 # the scan diffs against the PR base
45
- - uses: qazbnm456/diff-sentry@v0.8.0
46
+ - uses: qazbnm456/diff-sentry@v0.9.0
46
47
  ```
47
48
 
48
49
  That is the whole setup. The job fails when an indicator at or above `high` fires.
@@ -95,6 +96,7 @@ If you operate the infrastructure, or maintain a project where a wrong call is e
95
96
  - **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
97
  - **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
97
98
  - **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).
99
+ - **Release watch**: compares each new release of the PyPI packages you depend on with the release before it, and alerts when the new wheels carry a startup hook or a packed loader that the source never showed. See [Watching releases](docs/release-watch.md).
98
100
  - **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
101
 
100
102
  None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
@@ -107,6 +109,7 @@ A strong prompt injection can skew a model's verdict, though never the evidence.
107
109
 
108
110
  - [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
111
  - [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
112
+ - [Watching releases](docs/release-watch.md): comparing each new PyPI release with the one before it.
110
113
  - [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
111
114
  - [Development](docs/development.md): the test suites and CI.
112
115
  - [CHANGELOG](CHANGELOG.md)
@@ -21,7 +21,7 @@ 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.8.0
24
+ - uses: qazbnm456/diff-sentry@v0.9.0
25
25
  ```
26
26
 
27
27
  That is the whole setup. The job fails when an indicator at or above `high` fires.
@@ -74,6 +74,7 @@ If you operate the infrastructure, or maintain a project where a wrong call is e
74
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
75
  - **The studio**: a local console that streams a run as it happens. See [`studio/README.md`](studio/README.md).
76
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
+ - **Release watch**: compares each new release of the PyPI packages you depend on with the release before it, and alerts when the new wheels carry a startup hook or a packed loader that the source never showed. See [Watching releases](docs/release-watch.md).
77
78
  - **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
 
79
80
  None of this is needed for the Action, and none of it sends your code anywhere you did not configure yourself.
@@ -86,6 +87,7 @@ A strong prompt injection can skew a model's verdict, though never the evidence.
86
87
 
87
88
  - [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
89
  - [Watching a watchlist](docs/watch.md): the monitor, its alerts, its archive, and what the account dossier holds.
90
+ - [Watching releases](docs/release-watch.md): comparing each new PyPI release with the one before it.
89
91
  - [Honest limits](docs/limits.md): what the rules and the pipeline do not see.
90
92
  - [Development](docs/development.md): the test suites and CI.
91
93
  - [CHANGELOG](CHANGELOG.md)
@@ -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.8.0'
47
+ default: '0.9.0'
48
48
 
49
49
  outputs:
50
50
  failed:
@@ -80,7 +80,7 @@ __all__ = [
80
80
  "screen",
81
81
  ]
82
82
 
83
- __version__ = "0.8.0"
83
+ __version__ = "0.9.0"
84
84
 
85
85
 
86
86
  def __getattr__(name: str): # PEP 562: defer the dspy import to first use
@@ -97,11 +97,14 @@ def _baseline_indicators(event: dict, *, content_hits: list[IndicatorHit] | None
97
97
  detectors over the raw content ∪ the provenance detectors over the ingest facts. Both re-source into the
98
98
  evidence union on read (MF3), so a false-benign SUBMIT can skew the verdict but never suppress these."""
99
99
  from .indicators import keep_worse, scan_change, scan_provenance
100
+ from .releases import release_scope, scan_release
100
101
 
101
102
  # `content_hits` lets a caller that already scanned the change (the watcher, once per change) avoid scanning
102
103
  # the same text again: the scan is the expensive part.
103
104
  hits = (scan_change(event) if content_hits is None else list(content_hits)) \
104
- + scan_provenance(event.get("provenance") or {})
105
+ + scan_provenance(event.get("provenance") or {}) + scan_release(event.get("release") or {})
106
+ if event.get("kind") == "release":
107
+ hits = release_scope(hits)
105
108
  # Hits an EARLIER version of this same change raised ride along, so an edit that removes the payload
106
109
  # cannot also remove the evidence (the watcher supplies them; a change scanned on its own has none).
107
110
  for raw in event.get("prior_indicators") or []:
@@ -360,6 +363,50 @@ def _cmd_scan(args) -> int:
360
363
  return 1 if failed else 0
361
364
 
362
365
 
366
+ def _cmd_release_watch(args) -> int:
367
+ import logging
368
+ import signal
369
+
370
+ from .releases import ReleaseWatcher, parse_packages
371
+
372
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
373
+ if not math.isfinite(args.interval) or args.interval <= 0:
374
+ print("release-watch: --interval must be a positive number of seconds", file=sys.stderr)
375
+ return 2
376
+ texts = list(args.packages) + [os.getenv("DS_RELEASE_PACKAGES", "")]
377
+ try:
378
+ if args.packages_file:
379
+ with open(args.packages_file, encoding="utf-8") as fh:
380
+ texts.append(fh.read())
381
+ packages = parse_packages(texts)
382
+ if args.check:
383
+ packages = parse_packages([args.check[0]])
384
+ watcher = ReleaseWatcher(packages, state_dir=args.state, out_dir=args.out, emit=not args.no_emit)
385
+ except (OSError, ValueError, RuntimeError) as exc:
386
+ print(f"release-watch: {exc}", file=sys.stderr)
387
+ return 2
388
+ try:
389
+ if args.check:
390
+ print(json.dumps(watcher.check(packages[0], args.check[1]), indent=2, default=str))
391
+ return 0
392
+ if not packages:
393
+ print("release-watch: no packages (--packages, --packages-file or DS_RELEASE_PACKAGES)", file=sys.stderr)
394
+ return 2
395
+ if args.once:
396
+ for result in watcher.poll_once():
397
+ print(json.dumps(result, default=str))
398
+ return 0
399
+ stopping = []
400
+ signal.signal(signal.SIGTERM, lambda *_: stopping.append(True))
401
+ try:
402
+ watcher.run(args.interval, stop=lambda: bool(stopping))
403
+ except KeyboardInterrupt:
404
+ pass
405
+ return 0
406
+ finally:
407
+ watcher.close()
408
+
409
+
363
410
  def _cmd_render(args) -> int:
364
411
  from rlm_harness.trace import load_events
365
412
 
@@ -616,6 +663,21 @@ def build_parser() -> argparse.ArgumentParser:
616
663
  help="bring back runs that exhausted their attempts (e.g. a model that was down for hours)")
617
664
  w.set_defaults(func=_cmd_watch)
618
665
 
666
+ rw = sub.add_parser("release-watch",
667
+ help="compare each new release of watched PyPI packages with the one before it (no model)")
668
+ rw.add_argument("--packages", nargs="+", default=[], help="PyPI project names (or set DS_RELEASE_PACKAGES)")
669
+ rw.add_argument("--packages-file", help="a file of project names, one per line (# comments allowed)")
670
+ rw.add_argument("--out", default="./output")
671
+ rw.add_argument("--state", default=os.getenv("DS_WATCH_STATE_DIR", "./output/state"),
672
+ help="state directory: releases.db, snapshots/")
673
+ rw.add_argument("--interval", type=float, default=float(os.getenv("DS_RELEASE_INTERVAL", "300")),
674
+ help="seconds between polls (DS_RELEASE_INTERVAL, default 300)")
675
+ rw.add_argument("--no-emit", action="store_true", help="do not emit SIEM signals")
676
+ rw.add_argument("--once", action="store_true", help="one poll, then exit")
677
+ rw.add_argument("--check", nargs=2, metavar=("PACKAGE", "VERSION"),
678
+ help="judge one release against the one before it now, print the result, and exit")
679
+ rw.set_defaults(func=_cmd_release_watch)
680
+
619
681
  ac = sub.add_parser("actor", help="the dossier of an account the watcher has seen (offline)")
620
682
  ac.add_argument("login", help="a login, or `id:<github id>`")
621
683
  ac.add_argument("--state", default=os.getenv("DS_WATCH_STATE_DIR", "./output/state"))
@@ -214,7 +214,8 @@ _SYNTAX_PIECE_RE = re.compile(r"[|;&'\"`$()<>\\](?:[^|;&'\"`$()<>\\]{0,64}(?![^|
214
214
  _REPEAT_RE = re.compile(r"(?<!\S)(\S{1,30}(?: \S{1,30}){0,7}?)(?: \1)+(?!\S)")
215
215
  _CHMOD_EXEC_RE = re.compile(r"chmod\s+\+x[^\n]{0,120}", re.IGNORECASE)
216
216
  _B64_RE = re.compile(r"[A-Za-z0-9+/]{24,}={0,2}")
217
- # Exfiltration: environment/secret READS and a secret NAME piped to a network sink. Deliberately NOT the
217
+ # Exfiltration: environment/secret READS and a secret NAME piped to a network sink. `env |` is the shell's dump of the
218
+ # environment into a pipe; a closure parameter (`|env| self.getenv(env)` in Rust) is not one. Deliberately NOT the
218
219
  # bare token name alone (a legit workflow uses `${{ secrets.GITHUB_TOKEN }}`) and NOT `export X=$Y`
219
220
  # (`export PATH=$PATH:…` is a normal idiom): the secret must actually reach an exfil verb to fire.
220
221
  # `/proc/<pid>/mem` (and maps) reading ANOTHER process is the runner-memory-dump technique from the
@@ -222,7 +223,7 @@ _B64_RE = re.compile(r"[A-Za-z0-9+/]{24,}={0,2}")
222
223
  # attack harvests. `self/maps` is deliberately EXCLUDED: parsing your own memory map is textbook legit
223
224
  # native tooling (crash reporters, profilers, sanitizers); the cross-process (`\d+`) reads are the tell.
224
225
  _EXFIL_RE = re.compile(
225
- r"printenv|(?<![\w.])env\s*\||/proc/(?:self/(?:environ|mem)|\d+/(?:environ|mem|maps))|~/\.aws/credentials|"
226
+ r"printenv|(?<![\w.|])env\s*\||/proc/(?:self/(?:environ|mem)|\d+/(?:environ|mem|maps))|~/\.aws/credentials|"
226
227
  r"(?:AWS_SECRET_ACCESS_KEY|GITHUB_TOKEN|NPM_TOKEN|NODE_AUTH_TOKEN|PYPI_TOKEN|ACTIONS_RUNTIME_TOKEN|"
227
228
  r"ACTIONS_ID_TOKEN_REQUEST_TOKEN|CARGO_REGISTRY_TOKEN|TWINE_PASSWORD|NUGET_API_KEY)\b"
228
229
  r"[^\n]{0,80}?(?:curl|wget|nc\b|https?://|\|\s*(?:ba)?sh)",