relay-shell 0.5.1__tar.gz → 0.5.2__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 (217) hide show
  1. {relay_shell-0.5.1 → relay_shell-0.5.2}/CHANGELOG.md +16 -1
  2. {relay_shell-0.5.1 → relay_shell-0.5.2}/PKG-INFO +2 -2
  3. {relay_shell-0.5.1 → relay_shell-0.5.2}/README.md +1 -1
  4. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0007-audit-hash-chain.md +36 -0
  5. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/architecture.md +13 -0
  6. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/index.md +1 -1
  7. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/runbook.md +13 -0
  8. {relay_shell-0.5.1 → relay_shell-0.5.2}/pyproject.toml +1 -1
  9. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/__init__.py +1 -1
  10. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/audit.py +63 -28
  11. relay_shell-0.5.2/tests/test_audit_sink.py +273 -0
  12. {relay_shell-0.5.1 → relay_shell-0.5.2}/.claude/settings.json +0 -0
  13. {relay_shell-0.5.1 → relay_shell-0.5.2}/.editorconfig +0 -0
  14. {relay_shell-0.5.1 → relay_shell-0.5.2}/.env.example +0 -0
  15. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/CODEOWNERS +0 -0
  16. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/ISSUE_TEMPLATE/bug.md +0 -0
  17. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/ISSUE_TEMPLATE/feature.md +0 -0
  18. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/ISSUE_TEMPLATE/security.md +0 -0
  19. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/copilot-instructions.md +0 -0
  21. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/ci-gate.yml +0 -0
  22. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/ci.yml +0 -0
  23. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/codeql.yml +0 -0
  24. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/copilot-setup-steps.yml +0 -0
  25. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/dependency-review.yml +0 -0
  26. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/docs.yml +0 -0
  27. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/gitleaks.yml +0 -0
  28. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/nightly-fuzz.yml +0 -0
  29. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/pip-audit.yml +0 -0
  30. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/release-rehearsal.yml +0 -0
  31. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/release.yml +0 -0
  32. {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/sbom.yml +0 -0
  33. {relay_shell-0.5.1 → relay_shell-0.5.2}/.gitignore +0 -0
  34. {relay_shell-0.5.1 → relay_shell-0.5.2}/.gitleaks.toml +0 -0
  35. {relay_shell-0.5.1 → relay_shell-0.5.2}/.pre-commit-config.yaml +0 -0
  36. {relay_shell-0.5.1 → relay_shell-0.5.2}/AGENTS.md +0 -0
  37. {relay_shell-0.5.1 → relay_shell-0.5.2}/BACKLOG.md +0 -0
  38. {relay_shell-0.5.1 → relay_shell-0.5.2}/CLAUDE.md +0 -0
  39. {relay_shell-0.5.1 → relay_shell-0.5.2}/CODE_OF_CONDUCT.md +0 -0
  40. {relay_shell-0.5.1 → relay_shell-0.5.2}/CONTRIBUTING.md +0 -0
  41. {relay_shell-0.5.1 → relay_shell-0.5.2}/LICENSE +0 -0
  42. {relay_shell-0.5.1 → relay_shell-0.5.2}/NOTICE +0 -0
  43. {relay_shell-0.5.1 → relay_shell-0.5.2}/SECURITY.md +0 -0
  44. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/00-inventory.md +0 -0
  45. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/01-baseline.md +0 -0
  46. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/02-security-findings.md +0 -0
  47. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/03-final-report.md +0 -0
  48. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-05-27-engagement.md +0 -0
  49. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-06-01-engagement.md +0 -0
  50. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-06-21-adversarial-engagement.md +0 -0
  51. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-06-21-engagement.md +0 -0
  52. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-07-15-adversarial-engagement.md +0 -0
  53. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-07-15-engagement.md +0 -0
  54. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-07-15-release-v0.3.0.md +0 -0
  55. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-08-04-backlog-followup.md +0 -0
  56. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-08-04-engagement.md +0 -0
  57. {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-10-14-engagement.md +0 -0
  58. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/Caddyfile +0 -0
  59. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/README.md +0 -0
  60. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/approved-resets.json.example +0 -0
  61. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/audit-evidence.env.example +0 -0
  62. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence-sync +0 -0
  63. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence.py +0 -0
  64. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence.service +0 -0
  65. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence.timer +0 -0
  66. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/install-edge.sh +0 -0
  67. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/install.sh +0 -0
  68. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/logrotate/relay-shell +0 -0
  69. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/PACKAGE.lock +0 -0
  70. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/README.md +0 -0
  71. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/nftables.conf.snippet +0 -0
  72. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/prometheus-node-exporter.service.d/override.conf +0 -0
  73. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-health-metrics.py +0 -0
  74. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-health-metrics.service +0 -0
  75. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-health-metrics.timer +0 -0
  76. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-monitoring.env.example +0 -0
  77. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/release-signing.pub +0 -0
  78. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/systemd/relay-shell.service +0 -0
  79. {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/systemd/relay-shell.service.d/hardening.conf +0 -0
  80. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0001-runtime-and-sdk.md +0 -0
  81. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0002-no-sandbox-full-access.md +0 -0
  82. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0003-tiered-authority.md +0 -0
  83. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0004-edge-tls-automation.md +0 -0
  84. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0005-codebase-validation.md +0 -0
  85. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0006-seccomp-notify-audit-channel.md +0 -0
  86. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0008-operating-guidance-prompt.md +0 -0
  87. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0009-tier3-confirmation-broker.md +0 -0
  88. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0010-rollback-verify-broker.md +0 -0
  89. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0011-windows-openssh-powershell.md +0 -0
  90. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0012-cancel-safe-session-teardown.md +0 -0
  91. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0013-open-by-default-with-unsafe-notices.md +0 -0
  92. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0014-seccomp-launch-and-install-recovery.md +0 -0
  93. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/README.md +0 -0
  94. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/audit-shipper.md +0 -0
  95. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/auth.md +0 -0
  96. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/deployment.md +0 -0
  97. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/release-trust.md +0 -0
  98. {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/tools.md +0 -0
  99. {relay_shell-0.5.1 → relay_shell-0.5.2}/mkdocs.yml +0 -0
  100. {relay_shell-0.5.1 → relay_shell-0.5.2}/renovate.json5 +0 -0
  101. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/README.md +0 -0
  102. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/development-py3.12.txt +0 -0
  103. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/development-py3.14.txt +0 -0
  104. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/runtime-py3.12.txt +0 -0
  105. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/runtime-py3.14.txt +0 -0
  106. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-audit.in +0 -0
  107. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-audit.txt +0 -0
  108. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-bootstrap.in +0 -0
  109. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-bootstrap.txt +0 -0
  110. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-build.in +0 -0
  111. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-build.txt +0 -0
  112. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-docs.txt +0 -0
  113. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-sbom.in +0 -0
  114. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-sbom.txt +0 -0
  115. {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements.txt +0 -0
  116. {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/check_ci_checks.py +0 -0
  117. {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/healthcheck.sh +0 -0
  118. {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/installer_transaction.py +0 -0
  119. {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/reconcile_runtime.py +0 -0
  120. {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/rehearse_install.py +0 -0
  121. {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/rehearse_release.py +0 -0
  122. {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/rehearse_upgrade.py +0 -0
  123. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/__main__.py +0 -0
  124. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/__init__.py +0 -0
  125. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/admin.py +0 -0
  126. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/oauth.py +0 -0
  127. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/resource.py +0 -0
  128. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/broker.py +0 -0
  129. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/config.py +0 -0
  130. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/errors.py +0 -0
  131. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/inventory.py +0 -0
  132. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/metrics.py +0 -0
  133. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/notices.py +0 -0
  134. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/patterns.py +0 -0
  135. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/policy.py +0 -0
  136. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/py.typed +0 -0
  137. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/redaction.py +0 -0
  138. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/seccomp.py +0 -0
  139. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/server.py +0 -0
  140. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/sessions.py +0 -0
  141. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/shelltools.py +0 -0
  142. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/sshpool.py +0 -0
  143. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/util.py +0 -0
  144. {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/verifier.py +0 -0
  145. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/conftest.py +0 -0
  146. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/README.md +0 -0
  147. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/advisory-description-hashes.json +0 -0
  148. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/advisory-description.txt +0 -0
  149. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/baseline.json +0 -0
  150. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/runtime.in +0 -0
  151. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/runtime.lock +0 -0
  152. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/helpers.py +0 -0
  153. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_a14_rs.py +0 -0
  154. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_a14_t1_gaps.py +0 -0
  155. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_admission_guards.py +0 -0
  156. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_audit.py +0 -0
  157. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_audit_lows.py +0 -0
  158. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_audit_tail_tool.py +0 -0
  159. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_authenticated_audit.py +0 -0
  160. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_broker.py +0 -0
  161. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_broker_client_binding.py +0 -0
  162. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_cancellation_audit.py +0 -0
  163. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ci_checks.py +0 -0
  164. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ci_gate.py +0 -0
  165. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_classifier_scan_gates.py +0 -0
  166. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_classifier_v12.py +0 -0
  167. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_classifier_v16.py +0 -0
  168. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_config.py +0 -0
  169. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_config_hygiene.py +0 -0
  170. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_config_template.py +0 -0
  171. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_dependency_floors.py +0 -0
  172. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_deploy_packaging.py +0 -0
  173. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_edge_routing.py +0 -0
  174. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_errors.py +0 -0
  175. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_evidence_deploy.py +0 -0
  176. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_fuzz.py +0 -0
  177. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_healthcheck.py +0 -0
  178. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_install_edge_validation.py +0 -0
  179. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_install_rehearsal.py +0 -0
  180. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_installer_transaction.py +0 -0
  181. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_inventory.py +0 -0
  182. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_logrotate_config.py +0 -0
  183. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_main.py +0 -0
  184. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_metrics.py +0 -0
  185. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_notices.py +0 -0
  186. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth.py +0 -0
  187. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_approval.py +0 -0
  188. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_resource.py +0 -0
  189. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_reuse.py +0 -0
  190. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_store.py +0 -0
  191. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_patterns.py +0 -0
  192. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_policy.py +0 -0
  193. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_prompts.py +0 -0
  194. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_redaction.py +0 -0
  195. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_redaction_operators.py +0 -0
  196. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_redaction_v16.py +0 -0
  197. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_release_admission.py +0 -0
  198. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_release_workflows.py +0 -0
  199. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_resources.py +0 -0
  200. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_root_execution_contract.py +0 -0
  201. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_runtime_inventory.py +0 -0
  202. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_seccomp.py +0 -0
  203. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_server.py +0 -0
  204. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_session_secret_audit.py +0 -0
  205. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_sessions.py +0 -0
  206. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_sessions_cancel.py +0 -0
  207. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_shell.py +0 -0
  208. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ssh_fanout_tool.py +0 -0
  209. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ssh_integration.py +0 -0
  210. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ssh_keyscan_tool.py +0 -0
  211. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_sshpool_unit.py +0 -0
  212. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_stdio_e2e.py +0 -0
  213. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_tool_wrappers.py +0 -0
  214. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_upgrade_rehearsal.py +0 -0
  215. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_util.py +0 -0
  216. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_verifier.py +0 -0
  217. {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_workflow_hygiene.py +0 -0
@@ -6,6 +6,20 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.2] - 2026-11-02
10
+
11
+ ### Fixed
12
+
13
+ - Validate the actual audit descriptor before initial permission hardening and on
14
+ rotation reopens. Unsupported devices/FIFOs now degrade to stderr without
15
+ metadata changes or blocking; regular append sinks retain their behavior.
16
+ - Validate regular descriptors for audit-tail and verification reads;
17
+ handle invalid paths and unavailable home directories through audit fallback.
18
+ - Report rotation-open failures through audit health and preserve the first
19
+ degraded reason. Skip chain-tail reads after sink initialization fails.
20
+ - Preserve operator-created rotation-file modes; newly created files remain
21
+ private. See [ADR 0007](docs/adr/0007-audit-hash-chain.md#audit-sink-descriptor-validation-2026-11-02).
22
+
9
23
  ## [0.5.1] - 2026-11-02
10
24
 
11
25
  ### Performance
@@ -1815,7 +1829,8 @@ All notable changes to this project are documented here. The format follows
1815
1829
  bundled logrotate config drops and restores the append-only attribute
1816
1830
  across rotation. See `docs/deployment.md` §6.
1817
1831
 
1818
- [Unreleased]: https://github.com/rmednitzer/relay-shell/compare/v0.5.1...HEAD
1832
+ [Unreleased]: https://github.com/rmednitzer/relay-shell/compare/v0.5.2...HEAD
1833
+ [0.5.2]: https://github.com/rmednitzer/relay-shell/compare/v0.5.1...v0.5.2
1819
1834
  [0.5.1]: https://github.com/rmednitzer/relay-shell/compare/v0.5.0...v0.5.1
1820
1835
  [0.5.0]: https://github.com/rmednitzer/relay-shell/compare/v0.4.0...v0.5.0
1821
1836
  [0.4.0]: https://github.com/rmednitzer/relay-shell/compare/v0.3.0...v0.4.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: relay-shell
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: A highly reliable, maximally capable MCP server for complete shell and SSH mastery: a governed relay in the shell/SSH path.
5
5
  Project-URL: Homepage, https://github.com/rmednitzer/relay-shell
6
6
  Project-URL: Documentation, https://rmednitzer.github.io/relay-shell/
@@ -42,7 +42,7 @@ Description-Content-Type: text/markdown
42
42
 
43
43
  # Relay Shell
44
44
 
45
- v0.5.1 · Python 3.12 / 3.14 · transports `stdio` and `streamable-http` · MCP SDK
45
+ v0.5.2 · Python 3.12 / 3.14 · transports `stdio` and `streamable-http` · MCP SDK
46
46
  `mcp>=2,<3` · Apache-2.0
47
47
 
48
48
  `relay-shell` is a [Model Context Protocol](https://modelcontextprotocol.io) server that gives
@@ -1,6 +1,6 @@
1
1
  # Relay Shell
2
2
 
3
- v0.5.1 · Python 3.12 / 3.14 · transports `stdio` and `streamable-http` · MCP SDK
3
+ v0.5.2 · Python 3.12 / 3.14 · transports `stdio` and `streamable-http` · MCP SDK
4
4
  `mcp>=2,<3` · Apache-2.0
5
5
 
6
6
  `relay-shell` is a [Model Context Protocol](https://modelcontextprotocol.io) server that gives
@@ -245,3 +245,39 @@ unchained, head-truncated (with and without `--segment`), and genesis logs.
245
245
  No change to policy admission, tier semantics, the no-sandbox posture, or
246
246
  any tool's response shape. This pass hardened a compensating control
247
247
  (ADR 0002's first one); it did not move the trust boundary.
248
+
249
+
250
+ ## Audit sink descriptor validation (2026-11-02)
251
+
252
+ A privileged validation run exposed path-based permission hardening of a device
253
+ before file-type validation. FIFO opens could also block, including chain resume
254
+ after initialization failure, while a rotation-open error could escape the
255
+ handler's degraded-state update.
256
+
257
+ Validate the descriptor used for writes with fstat, both initially and after
258
+ rotation. Open nonblocking so unsupported FIFOs cannot stall initialization or
259
+ reopen. Only the initial regular descriptor is subject to best-effort fchmod;
260
+ preserve existing rotation-file permissions and create missing files at 0600.
261
+ Initialization failure uses the existing stderr fallback and skips chain-tail
262
+ reads. Reopen failures mark degraded through the existing first-reason-wins API.
263
+
264
+ This changes unsupported sink handling, with no record-schema, hash-chain,
265
+ execution-authority or policy-mode change. Control-authority STPA changes are
266
+ not applicable; the operational constraints are nonblocking FIFO failure, no device
267
+ metadata mutation, visible degradation and unchanged valid-file append/rotation.
268
+ Device/symlink, FIFO with/without readers, rotation, path replacement, descriptor
269
+ cleanup and chmod-refusal regressions test these constraints. Qualified Python
270
+ profiles and privileged file-mode evidence must be retained at release.
271
+
272
+ Operator-controlled paths and other filesystem actors remain trusted. This does
273
+ not establish durable storage, fsync guarantees, a hostile-directory boundary or
274
+ independent off-host evidence. Roll back the complete runtime if necessary while
275
+ preserving accepted audit records; no data migration is required.
276
+
277
+ Audit-tail reads (plain and gzip) and chain-verification opens use the same
278
+ regular-descriptor validation. This also prevents a regular-to-FIFO replacement
279
+ from blocking between a path check and the actual read. Invalid paths and an
280
+ unavailable home directory degrade initialization through the existing fallback.
281
+
282
+ O_NONBLOCK is not a general storage or network-filesystem timeout; regular-file
283
+ I/O latency remains governed by the operating system and storage.
@@ -76,6 +76,14 @@ Every tool body is identical in shape (`Relay.run`):
76
76
  for tamper-evidence ([ADR 0007](adr/0007-audit-hash-chain.md)); default off
77
77
  keeps the record byte-identical.
78
78
 
79
+ The audit file handler validates its opened descriptor at initialization and every
80
+ rotation reopen. It uses nonblocking open to avoid FIFO stalls, rejects nonregular
81
+ sinks and hardens only the initial regular descriptor. Invalid initialization
82
+ falls back to stderr and skips chain-tail reads. Reopen failures mark audit health
83
+ degraded while tool calls continue; existing rotation-file modes are preserved.
84
+ Regular-file type checks alone do not prove storage durability or constrain an
85
+ operator-controlled path.
86
+
79
87
  A tool may fail, time out, or be denied, but it always returns a single bounded, audited
80
88
  string; the one exception is a cancelled call, which is audited and then re-raised.
81
89
 
@@ -192,3 +200,8 @@ ASCII-only inputs also use equivalent ASCII regex variants for common Tier-3
192
200
  and Tier-2 scans. Inputs containing any non-ASCII character retain the original
193
201
  Unicode regexes; replacing a published table disables its optimized variant.
194
202
  Differential tests cover both paths.
203
+
204
+ Audit-tail reads (plain and gzip) and chain-verification opens use the same
205
+ regular-descriptor validation. This also prevents a regular-to-FIFO replacement
206
+ from blocking between a path check and the actual read. Invalid paths and an
207
+ unavailable home directory degrade initialization through the existing fallback.
@@ -6,7 +6,7 @@ title: Overview
6
6
 
7
7
  A [Model Context Protocol](https://modelcontextprotocol.io) server for **complete shell and
8
8
  SSH operation**: local commands and scripts, interactive PTY sessions, fleet operation over
9
- SSH, SFTP transfers and port forwards. Current release: **[v0.5.1](https://github.com/rmednitzer/relay-shell/releases/tag/v0.5.1)** (2026-11-02).
9
+ SSH, SFTP transfers and port forwards. Current release: **[v0.5.2](https://github.com/rmednitzer/relay-shell/releases/tag/v0.5.2)** (2026-11-02).
10
10
 
11
11
  It is operator infrastructure for hosts you own and administer. It runs with the privileges
12
12
  of its service account, with no sandbox, and it is **open by default**: with the default
@@ -271,6 +271,14 @@ alertable; any HTTP response (including 401/403/404) means the listener is up.
271
271
 
272
272
  ### 2.9 Audit-log hygiene
273
273
 
274
+ Configure a regular audit file. Device/FIFO sinks are unsupported: initialization
275
+ degrades to stderr without changing their permissions or waiting for a reader.
276
+ Each rotation reopen also validates the actual descriptor; failure marks runtime
277
+ audit health degraded. Initial regular-file hardening is best effort (append-only
278
+ files may refuse chmod), and existing operator-created rotation-file modes remain
279
+ unchanged. New files use mode 0600. Do not interpret a regular-file check as a
280
+ proof of durable persistence.
281
+
274
282
  On a real deployment:
275
283
 
276
284
  ```bash
@@ -1204,3 +1212,8 @@ ASCII-only inputs also use equivalent ASCII regex variants for common Tier-3
1204
1212
  and Tier-2 scans. Inputs containing any non-ASCII character retain the original
1205
1213
  Unicode regexes; replacing a published table disables its optimized variant.
1206
1214
  Differential tests cover both paths.
1215
+
1216
+ Audit-tail reads (plain and gzip) and chain-verification opens use the same
1217
+ regular-descriptor validation. This also prevents a regular-to-FIFO replacement
1218
+ from blocking between a path check and the actual read. Invalid paths and an
1219
+ unavailable home directory degrade initialization through the existing fallback.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "relay-shell"
7
- version = "0.5.1"
7
+ version = "0.5.2"
8
8
  description = "A highly reliable, maximally capable MCP server for complete shell and SSH mastery: a governed relay in the shell/SSH path."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ["__version__"]
6
6
 
7
- __version__ = "0.5.1"
7
+ __version__ = "0.5.2"
@@ -15,14 +15,17 @@ logger degrades to stderr (or silence) and records ``degraded=True``.
15
15
  from __future__ import annotations
16
16
 
17
17
  import contextlib
18
+ import errno
18
19
  import gzip
19
20
  import json
20
21
  import logging
21
22
  import os
23
+ import stat
22
24
  import sys
23
25
  import threading
24
26
  from collections import deque
25
27
  from dataclasses import dataclass
28
+ from io import TextIOWrapper
26
29
  from logging.handlers import WatchedFileHandler
27
30
  from pathlib import Path
28
31
  from typing import Any
@@ -112,20 +115,37 @@ _FORMATTERS = {"jsonl": _format_jsonl, "cef": _format_cef, "leef": _format_leef}
112
115
  _CHAIN_GENESIS = "0" * 64
113
116
 
114
117
 
118
+ def _open_regular_fd(path: str | os.PathLike[str], flags: int) -> int:
119
+ """Open and validate a regular descriptor without blocking on a FIFO."""
120
+ fd = os.open(path, flags | os.O_NONBLOCK, 0o600)
121
+ try:
122
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
123
+ raise OSError(
124
+ errno.EINVAL,
125
+ "audit path is not a regular file (records not durably stored)",
126
+ str(path),
127
+ )
128
+ return fd
129
+ except BaseException:
130
+ with contextlib.suppress(OSError):
131
+ os.close(fd)
132
+ raise
133
+
134
+
115
135
  def _tail_path(path: Path, lines: int) -> str:
116
136
  """Return the last non-empty lines from a plain or gzip-compressed file."""
117
137
  if lines <= 0:
118
138
  return ""
119
139
 
120
- if path.suffix == ".gz":
121
- compressed_records: deque[str] = deque(maxlen=lines)
122
- with gzip.open(path, "rt", encoding="utf-8", errors="replace") as fh:
123
- for raw in fh:
124
- if raw.strip():
125
- compressed_records.append(raw.rstrip("\r\n"))
126
- return "\n".join(compressed_records)
140
+ with os.fdopen(_open_regular_fd(path, os.O_RDONLY), "rb") as fh:
141
+ if path.suffix == ".gz":
142
+ compressed_records: deque[str] = deque(maxlen=lines)
143
+ with gzip.open(fh, "rt", encoding="utf-8", errors="replace") as compressed:
144
+ for raw in compressed:
145
+ if raw.strip():
146
+ compressed_records.append(raw.rstrip("\r\n"))
147
+ return "\n".join(compressed_records)
127
148
 
128
- with path.open("rb") as fh:
129
149
  fh.seek(0, os.SEEK_END)
130
150
  size = fh.tell()
131
151
  if size == 0:
@@ -254,7 +274,9 @@ def verify_chain(path: str) -> ChainResult:
254
274
  expected_seq = 0
255
275
  expected_prev = _CHAIN_GENESIS
256
276
  try:
257
- with p.open("r", encoding="utf-8", errors="replace") as fh:
277
+ with os.fdopen(
278
+ _open_regular_fd(p, os.O_RDONLY), "r", encoding="utf-8", errors="replace"
279
+ ) as fh:
258
280
  for lineno, raw in enumerate(fh, start=1):
259
281
  line = raw.strip()
260
282
  if not line:
@@ -351,11 +373,39 @@ def verify_chain(path: str) -> ChainResult:
351
373
 
352
374
  class _AuditFileHandler(WatchedFileHandler):
353
375
  owner: Any = None
376
+ _initial_open = True
377
+
378
+ def _open(self) -> TextIOWrapper[Any]:
379
+ # Validate the descriptor we will write, rather than chmodding a path
380
+ # that may refer to a device or change between inspection and use.
381
+ # O_NONBLOCK prevents an unsupported FIFO from hanging initialization
382
+ # or a rotation reopen. It has no effect on regular files.
383
+ fd = _open_regular_fd(self.baseFilename, os.O_APPEND | os.O_CREAT | os.O_WRONLY)
384
+ try:
385
+ # Existing append-only files can refuse chmod while still accepting
386
+ # append writes. Preserve the existing best-effort hardening policy.
387
+ if self._initial_open:
388
+ with contextlib.suppress(OSError):
389
+ os.fchmod(fd, 0o600)
390
+ stream = os.fdopen(fd, "a", encoding=self.encoding, errors=self.errors)
391
+ self._initial_open = False
392
+ return stream
393
+ except BaseException:
394
+ with contextlib.suppress(OSError):
395
+ os.close(fd)
396
+ raise
397
+
398
+ def emit(self, record: logging.LogRecord) -> None:
399
+ try:
400
+ super().emit(record)
401
+ except OSError:
402
+ # WatchedFileHandler reopens before FileHandler's error handler.
403
+ # Surface a rejected rotation target without breaking the tool call.
404
+ self.handleError(record)
354
405
 
355
406
  def handleError(self, record: logging.LogRecord) -> None:
356
407
  if self.owner is not None:
357
- self.owner.degraded = True
358
- self.owner.degraded_reason = "audit write failed at runtime"
408
+ self.owner.mark_degraded("audit write failed at runtime")
359
409
  super().handleError(record)
360
410
 
361
411
 
@@ -397,24 +447,9 @@ class AuditLogger:
397
447
  try:
398
448
  target = Path(path).expanduser()
399
449
  target.parent.mkdir(parents=True, exist_ok=True)
400
- # O_APPEND keeps this compatible with `chattr +a` files.
401
- fd = os.open(str(target), os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
402
- os.close(fd)
403
- # Defensive for existing files that might already be too permissive.
404
- with contextlib.suppress(OSError):
405
- target.chmod(0o600)
406
- # OBS-1: a non-regular sink (`/dev/null`, device, FIFO) accepts
407
- # writes but stores nothing durable. Flag it degraded so the
408
- # `relay_shell_audit_degraded` gauge and `server_info.audit` show
409
- # it; the sink itself is left as configured.
410
- if not target.is_file():
411
- self.degraded = True
412
- self.degraded_reason = (
413
- f"audit path is not a regular file (records not durably stored): {path}"
414
- )
415
450
  sink = _AuditFileHandler(str(target), encoding="utf-8", errors="backslashreplace")
416
451
  sink.owner = self
417
- except OSError as exc: # unwritable path -> degrade, never crash
452
+ except (OSError, ValueError, RuntimeError) as exc: # invalid/unwritable path -> degrade
418
453
  self.degraded = True
419
454
  self.degraded_reason = str(exc)
420
455
  sink = logging.StreamHandler(sys.stderr)
@@ -426,7 +461,7 @@ class AuditLogger:
426
461
  echo.setFormatter(logging.Formatter("AUDIT %(message)s"))
427
462
  self._log.addHandler(echo)
428
463
 
429
- if self.chain:
464
+ if self.chain and not self.degraded:
430
465
  self._resume_chain()
431
466
 
432
467
  def _resume_chain(self) -> None:
@@ -0,0 +1,273 @@
1
+ """Audit sinks must not mutate devices, hang on FIFOs, or hide reopen failure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from relay_shell.audit import AuditLogger
15
+
16
+
17
+ @pytest.mark.parametrize("symlink", [False, True])
18
+ def test_device_sink_never_attempts_permission_changes(
19
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, symlink: bool
20
+ ) -> None:
21
+ device = Path("/dev/null")
22
+ path = tmp_path / "device" if symlink else device
23
+ if symlink:
24
+ path.symlink_to(device)
25
+ before = device.stat()
26
+ real_chmod = Path.chmod
27
+ real_fchmod = os.fchmod
28
+
29
+ def guard_path_chmod(self: Path, mode: int, *args, **kwargs) -> None:
30
+ assert self.resolve() != device, "permission change attempted on a device"
31
+ real_chmod(self, mode, *args, **kwargs)
32
+
33
+ def guard_fchmod(fd: int, mode: int) -> None:
34
+ assert stat.S_ISREG(os.fstat(fd).st_mode), "fchmod attempted on a nonregular sink"
35
+ real_fchmod(fd, mode)
36
+
37
+ monkeypatch.setattr(Path, "chmod", guard_path_chmod)
38
+ monkeypatch.setattr(os, "fchmod", guard_fchmod)
39
+ log = AuditLogger(str(path), chain=True)
40
+ assert log.degraded
41
+ assert "regular file" in log.degraded_reason
42
+ log.record(tool="test", args={}, output="synthetic", exit_code=0, tier=0)
43
+ after = device.stat()
44
+ assert (after.st_mode, after.st_rdev, after.st_uid, after.st_gid) == (
45
+ before.st_mode,
46
+ before.st_rdev,
47
+ before.st_uid,
48
+ before.st_gid,
49
+ )
50
+
51
+
52
+ def test_existing_regular_sink_is_hardened_and_appended(tmp_path: Path) -> None:
53
+ path = tmp_path / "audit.jsonl"
54
+ path.write_text("existing\n")
55
+ path.chmod(0o644)
56
+ log = AuditLogger(str(path))
57
+ assert not log.degraded
58
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
59
+ log.record(tool="test", args={}, output="synthetic", exit_code=0, tier=0)
60
+ assert path.read_text().startswith("existing\n")
61
+
62
+
63
+ @pytest.mark.parametrize("reader", [False, True])
64
+ def test_fifo_initialization_is_bounded_and_preserves_mode(tmp_path: Path, reader: bool) -> None:
65
+ path = tmp_path / "audit.fifo"
66
+ os.mkfifo(path, 0o640)
67
+ path.chmod(0o640)
68
+ code = """
69
+ import json,os,sys
70
+ from relay_shell.audit import AuditLogger
71
+ reader = os.open(sys.argv[1], os.O_RDONLY | os.O_NONBLOCK) if sys.argv[2] == '1' else None
72
+ log = AuditLogger(sys.argv[1], chain=True)
73
+ log.record(tool='test',args={},output='synthetic',exit_code=0,tier=0)
74
+ print(json.dumps({'degraded':log.degraded}))
75
+ if reader is not None:os.close(reader)
76
+ """
77
+ result = subprocess.run(
78
+ [sys.executable, "-c", code, str(path), str(int(reader))],
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=3,
82
+ check=True,
83
+ )
84
+ assert json.loads(result.stdout)["degraded"] is True
85
+ assert stat.S_ISFIFO(path.stat().st_mode)
86
+ assert stat.S_IMODE(path.stat().st_mode) == 0o640
87
+
88
+
89
+ def test_rotation_to_fifo_is_bounded_and_reported(tmp_path: Path) -> None:
90
+ path = tmp_path / "audit.jsonl"
91
+ code = """
92
+ import json,os,sys
93
+ from pathlib import Path
94
+ from relay_shell.audit import AuditLogger
95
+ path=Path(sys.argv[1]);log=AuditLogger(str(path),chain=True)
96
+ log.record(tool='before',args={},output='synthetic',exit_code=0,tier=0)
97
+ path.rename(str(path)+'.old')
98
+ os.mkfifo(path,0o640);path.chmod(0o640)
99
+ log.record(tool='after',args={},output='synthetic',exit_code=0,tier=0)
100
+ print(json.dumps({'degraded':log.degraded,'reason':log.degraded_reason}))
101
+ """
102
+ result = subprocess.run(
103
+ [sys.executable, "-c", code, str(path)],
104
+ capture_output=True,
105
+ text=True,
106
+ timeout=3,
107
+ check=True,
108
+ )
109
+ assert json.loads(result.stdout)["degraded"] is True
110
+ assert "runtime" in json.loads(result.stdout)["reason"]
111
+ assert stat.S_IMODE(path.stat().st_mode) == 0o640
112
+
113
+
114
+ @pytest.mark.parametrize("earlier_gap", [False, True])
115
+ def test_rotation_to_device_is_reported(tmp_path: Path, earlier_gap: bool) -> None:
116
+ path = tmp_path / "audit.jsonl"
117
+ log = AuditLogger(str(path), chain=True)
118
+ log.record(tool="before", args={}, output="synthetic", exit_code=0, tier=0)
119
+ path.rename(tmp_path / "audit.old")
120
+ path.symlink_to("/dev/null")
121
+ if earlier_gap:
122
+ log.mark_degraded("earlier gap")
123
+ log.record(tool="after", args={}, output="synthetic", exit_code=0, tier=0)
124
+ assert log.degraded
125
+ if earlier_gap:
126
+ assert log.degraded_reason == "earlier gap"
127
+ else:
128
+ assert "runtime" in log.degraded_reason
129
+
130
+
131
+ def test_rotation_preserves_operator_created_file_mode(tmp_path: Path) -> None:
132
+ path = tmp_path / "audit.jsonl"
133
+ log = AuditLogger(str(path))
134
+ log.record(tool="before", args={}, output="synthetic", exit_code=0, tier=0)
135
+ path.rename(tmp_path / "audit.old")
136
+ path.write_text("")
137
+ path.chmod(0o640)
138
+ log.record(tool="after", args={}, output="synthetic", exit_code=0, tier=0)
139
+ assert not log.degraded
140
+ assert json.loads(path.read_text())["tool"] == "after"
141
+ assert stat.S_IMODE(path.stat().st_mode) == 0o640
142
+
143
+
144
+ def test_hardening_uses_open_descriptor_during_path_replacement(
145
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
146
+ ) -> None:
147
+ path = tmp_path / "audit.jsonl"
148
+ moved = tmp_path / "original"
149
+ path.write_text("")
150
+ path.chmod(0o644)
151
+ real_open = os.open
152
+ replaced = False
153
+
154
+ def replace_after_open(file: str, flags: int, mode: int = 0o777) -> int:
155
+ nonlocal replaced
156
+ fd = real_open(file, flags, mode)
157
+ if file == str(path) and not replaced:
158
+ replaced = True
159
+ path.rename(moved)
160
+ path.write_text("")
161
+ path.chmod(0o644)
162
+ return fd
163
+
164
+ monkeypatch.setattr(os, "open", replace_after_open)
165
+ log = AuditLogger(str(path))
166
+ assert not log.degraded
167
+ assert stat.S_IMODE(moved.stat().st_mode) == 0o600
168
+ assert stat.S_IMODE(path.stat().st_mode) == 0o644
169
+
170
+
171
+ def test_rejected_sink_closes_open_descriptor(monkeypatch: pytest.MonkeyPatch) -> None:
172
+ device = "/dev/null"
173
+ real_open = os.open
174
+ captured: list[int] = []
175
+
176
+ def capture_open(file: str, flags: int, mode: int = 0o777) -> int:
177
+ fd = real_open(file, flags, mode)
178
+ if file == device:
179
+ captured.append(fd)
180
+ return fd
181
+
182
+ monkeypatch.setattr(os, "open", capture_open)
183
+ log = AuditLogger(device)
184
+ assert log.degraded
185
+ assert captured
186
+ for fd in captured:
187
+ with pytest.raises(OSError):
188
+ os.fstat(fd)
189
+
190
+
191
+ def test_append_continues_when_descriptor_chmod_is_refused(
192
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
193
+ ) -> None:
194
+ path = tmp_path / "audit.jsonl"
195
+ path.write_text("")
196
+ path.chmod(0o640)
197
+
198
+ def refuse_chmod(fd: int, mode: int) -> None:
199
+ raise PermissionError("synthetic append-only chmod refusal")
200
+
201
+ monkeypatch.setattr(os, "fchmod", refuse_chmod)
202
+ log = AuditLogger(str(path))
203
+ log.record(tool="test", args={}, output="synthetic", exit_code=0, tier=0)
204
+ assert not log.degraded
205
+ assert json.loads(path.read_text())["tool"] == "test"
206
+ assert stat.S_IMODE(path.stat().st_mode) == 0o640
207
+
208
+
209
+ @pytest.mark.parametrize("suffix", [".jsonl", ".gz"])
210
+ def test_tail_of_fifo_is_bounded(tmp_path: Path, suffix: str) -> None:
211
+ path = tmp_path / ("audit" + suffix)
212
+ os.mkfifo(path, 0o640)
213
+ path.chmod(0o640)
214
+ code = """
215
+ import json,sys
216
+ from relay_shell.audit import AuditLogger
217
+ log=AuditLogger.__new__(AuditLogger);log.path=sys.argv[1]
218
+ print(json.dumps({'tail':log.tail(10)}))
219
+ """
220
+ result = subprocess.run(
221
+ [sys.executable, "-c", code, str(path)],
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=3,
225
+ check=True,
226
+ )
227
+ assert json.loads(result.stdout)["tail"] == ""
228
+ assert stat.S_IMODE(path.stat().st_mode) == 0o640
229
+
230
+
231
+ def test_verifier_rejects_path_replacement_with_fifo(tmp_path: Path) -> None:
232
+ path = tmp_path / "audit.jsonl"
233
+ path.write_text("")
234
+ code = """
235
+ import json,os,sys
236
+ from pathlib import Path
237
+ from relay_shell.audit import verify_chain
238
+ target=Path(sys.argv[1]);real_is_file=Path.is_file
239
+ def replace_after_stat(self):
240
+ result=real_is_file(self)
241
+ if self==target and result:
242
+ self.unlink();os.mkfifo(self,0o640)
243
+ return result
244
+ Path.is_file=replace_after_stat
245
+ r=verify_chain(str(target))
246
+ print(json.dumps({'ok':r.ok,'reason':r.reason}))
247
+ """
248
+ result = subprocess.run(
249
+ [sys.executable, "-c", code, str(path)],
250
+ capture_output=True,
251
+ text=True,
252
+ timeout=3,
253
+ check=True,
254
+ )
255
+ assert json.loads(result.stdout)["ok"] is False
256
+ assert "regular file" in json.loads(result.stdout)["reason"]
257
+
258
+
259
+ def test_invalid_path_degrades_without_raising() -> None:
260
+ log = AuditLogger("bad\x00audit")
261
+ assert log.degraded
262
+ log.record(tool="test", args={}, output="synthetic", exit_code=0, tier=0)
263
+
264
+
265
+ def test_unavailable_home_degrades_without_raising(
266
+ monkeypatch: pytest.MonkeyPatch,
267
+ ) -> None:
268
+ def unavailable_home(self: Path) -> Path:
269
+ raise RuntimeError("synthetic unavailable home")
270
+
271
+ monkeypatch.setattr(Path, "expanduser", unavailable_home)
272
+ log = AuditLogger("~/audit.jsonl")
273
+ assert log.degraded
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes