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.
- {relay_shell-0.5.1 → relay_shell-0.5.2}/CHANGELOG.md +16 -1
- {relay_shell-0.5.1 → relay_shell-0.5.2}/PKG-INFO +2 -2
- {relay_shell-0.5.1 → relay_shell-0.5.2}/README.md +1 -1
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0007-audit-hash-chain.md +36 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/architecture.md +13 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/index.md +1 -1
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/runbook.md +13 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/pyproject.toml +1 -1
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/__init__.py +1 -1
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/audit.py +63 -28
- relay_shell-0.5.2/tests/test_audit_sink.py +273 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.claude/settings.json +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.editorconfig +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.env.example +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/CODEOWNERS +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/ISSUE_TEMPLATE/bug.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/ISSUE_TEMPLATE/feature.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/ISSUE_TEMPLATE/security.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/copilot-instructions.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/ci-gate.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/ci.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/codeql.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/copilot-setup-steps.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/dependency-review.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/docs.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/gitleaks.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/nightly-fuzz.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/pip-audit.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/release-rehearsal.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/release.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.github/workflows/sbom.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.gitignore +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.gitleaks.toml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/.pre-commit-config.yaml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/AGENTS.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/BACKLOG.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/CLAUDE.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/CODE_OF_CONDUCT.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/CONTRIBUTING.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/LICENSE +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/NOTICE +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/SECURITY.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/00-inventory.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/01-baseline.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/02-security-findings.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/03-final-report.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-05-27-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-06-01-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-06-21-adversarial-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-06-21-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-07-15-adversarial-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-07-15-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-07-15-release-v0.3.0.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-08-04-backlog-followup.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-08-04-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/audit/2026-10-14-engagement.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/Caddyfile +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/README.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/approved-resets.json.example +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/audit-evidence.env.example +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence-sync +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence.service +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/evidence/relay-audit-evidence.timer +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/install-edge.sh +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/install.sh +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/logrotate/relay-shell +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/PACKAGE.lock +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/README.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/nftables.conf.snippet +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/prometheus-node-exporter.service.d/override.conf +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-health-metrics.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-health-metrics.service +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-health-metrics.timer +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/monitoring/relay-monitoring.env.example +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/release-signing.pub +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/systemd/relay-shell.service +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/deploy/systemd/relay-shell.service.d/hardening.conf +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0001-runtime-and-sdk.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0002-no-sandbox-full-access.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0003-tiered-authority.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0004-edge-tls-automation.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0005-codebase-validation.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0006-seccomp-notify-audit-channel.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0008-operating-guidance-prompt.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0009-tier3-confirmation-broker.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0010-rollback-verify-broker.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0011-windows-openssh-powershell.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0012-cancel-safe-session-teardown.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0013-open-by-default-with-unsafe-notices.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/0014-seccomp-launch-and-install-recovery.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/adr/README.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/audit-shipper.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/auth.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/deployment.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/release-trust.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/docs/tools.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/mkdocs.yml +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/renovate.json5 +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/README.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/development-py3.12.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/development-py3.14.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/runtime-py3.12.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements/runtime-py3.14.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-audit.in +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-audit.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-bootstrap.in +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-bootstrap.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-build.in +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-build.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-docs.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-sbom.in +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements-sbom.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/requirements.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/check_ci_checks.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/healthcheck.sh +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/installer_transaction.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/reconcile_runtime.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/rehearse_install.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/rehearse_release.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/scripts/rehearse_upgrade.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/__main__.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/__init__.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/admin.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/oauth.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/auth/resource.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/broker.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/config.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/errors.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/inventory.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/metrics.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/notices.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/patterns.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/policy.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/py.typed +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/redaction.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/seccomp.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/server.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/sessions.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/shelltools.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/sshpool.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/util.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/src/relay_shell/verifier.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/conftest.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/README.md +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/advisory-description-hashes.json +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/advisory-description.txt +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/baseline.json +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/runtime.in +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/fixtures/upgrade-v0.3/runtime.lock +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/helpers.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_a14_rs.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_a14_t1_gaps.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_admission_guards.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_audit.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_audit_lows.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_audit_tail_tool.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_authenticated_audit.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_broker.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_broker_client_binding.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_cancellation_audit.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ci_checks.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ci_gate.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_classifier_scan_gates.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_classifier_v12.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_classifier_v16.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_config.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_config_hygiene.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_config_template.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_dependency_floors.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_deploy_packaging.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_edge_routing.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_errors.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_evidence_deploy.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_fuzz.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_healthcheck.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_install_edge_validation.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_install_rehearsal.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_installer_transaction.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_inventory.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_logrotate_config.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_main.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_metrics.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_notices.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_approval.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_resource.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_reuse.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_oauth_store.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_patterns.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_policy.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_prompts.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_redaction.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_redaction_operators.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_redaction_v16.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_release_admission.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_release_workflows.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_resources.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_root_execution_contract.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_runtime_inventory.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_seccomp.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_server.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_session_secret_audit.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_sessions.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_sessions_cancel.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_shell.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ssh_fanout_tool.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ssh_integration.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_ssh_keyscan_tool.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_sshpool_unit.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_stdio_e2e.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_tool_wrappers.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_upgrade_rehearsal.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_util.py +0 -0
- {relay_shell-0.5.1 → relay_shell-0.5.2}/tests/test_verifier.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|