Everything an outbound call does can now be aborted, and everything it refuses is now written down.
A cancellable secure outbound send
VaultHttpClient gains sendCancellable(), behind a separate CancellableHttpClientInterface so nothing existing breaks and consumers can feature-detect it. PSR-18 hands back a response, never a handle, and Guzzle's synchronous send settles its promise before anyone could cancel it — so the send goes async, Proxy::wrapSync picks CurlMultiHandler, whose promise carries a real cancel function, and a ticker drives the loop while a CancellationSignalInterface is polled between passes. create() is untouched; createCancellable() re-composes the stack so the sync and streaming branches survive.
No Guzzle type crosses the interface. The transport detail stays owned here, so the next transport change does not reach into consumers.
The OAuth token round trip is cancellable on the same terms, and audited.
This is what netresearch/t3x-nr-llm#774 was waiting for: a cancelled agent run there ended while the HTTP call it started ran on to its timeout, and going around this package to fix it would have dropped credential injection, the request-time SSRF DNS pin and the audit write. That work can start now.
Refusals leave a trace
Three refusals used to escape without an audit row — a scheme that is not allowed, a host outside allowed_hosts, and a credential that could not be obtained. They are exactly the calls an operator goes looking for. Each now writes one http_call row with success = false, status 0, the attempted method, host and path, and a fixed literal naming the gate that refused it. No new audit action: that tuple already means "a refused outbound call" here.
A blocking send that threw something other than a PSR-18 ClientExceptionInterface also left no row — Client::applyOptions() raises outside Client::transfer()'s try/catch, so a bad option set escaped with the credential already injected. The row is now written from a finally, covering the plain and the degraded cancellable path alike.
Two new audit actions describe abandoned calls, each meaning exactly one thing.
The exceptions are unchanged byte for byte — same class, same code, same message, pinned by characterization tests written before the rows existed. A consumer catching VaultException sees no difference.
Fewer lookups, and a surface that cannot move quietly
One DNS lookup per outbound request instead of two: the resolve that pins the address for the SSRF check is the one the request uses.
Tests/Unit/Api/api-surface.txt records the public surface, so a signature cannot change without the diff saying so. The security mutation ratchet now also covers Classes/Http, and the agent documentation is synced and verified in CI rather than by hand.
guzzlehttp/guzzle is now a declared direct dependency (^7.10) — it always was one in practice.
Upgrading
No breaking change. create(), sendRequest() and every existing signature behave as before; the cancellable path is additive and opt-in through its own interface.
All of the above by @CybotTM.
Installation
composer require netresearch/nr-vaultPublication status
- TER: extensions.typo3.org/extension/nr_vault — 0.16.0 verified
- Packagist: packagist.org/packages/netresearch/nr-vault — 0.16.0 verified
- Documentation: docs.typo3.org/p/netresearch/nr-vault/0.16/en-us/ — render verified
Security
All release artifacts are signed with Sigstore keyless signing.
Verify signatures
cosign verify-blob \
--bundle nr-vault-0.16.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.16.0.zipVerify checksums
sha256sum -c checksums.txtSoftware Bill of Materials (SBOM)
SBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.