diff --git a/AGENTS.md b/AGENTS.md index 0207005..5046426 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Guidance for AI agents (including Claude Code) working in this repository. rennf93/guard-agent-php (https://github.com/Guard-Core/guard-agent-php) is the PHP telemetry agent for the guard-core ecosystem. It buffers security events, performance metrics, and agent status in memory (optionally persisted to Redis for crash recovery) and ships them to the Guard Core App ingestion API with at-least-once delivery semantics: nothing acknowledged is lost, nothing unacknowledged is forgotten. -- Composer package `rennf93/guard-agent-php`, type `library`, license MIT. No `version` field in composer.json (the laravel-guard/symfony-guard convention); versions come from git tags and are reported to the server as `agent_version` from `RenzoFranceschini\GuardAgent\Version::VERSION` (`0.1.0` at the time of writing). Shipped tags: none yet. +- Composer package `rennf93/guard-agent-php`, type `library`, license MIT. No `version` field in composer.json (the laravel-guard/symfony-guard convention); versions come from git tags and are reported to the server as `agent_version` from `RenzoFranceschini\GuardAgent\Version::VERSION` (`3.2.4` at the time of writing, in lockstep with the Python agent; 3.2.4 is not tagged yet). - This package contains NO security logic. It reports what a host adapter already decided; detection, rate limiting, bans, and verdicts all live in guard-core (and guard-core-php). - PHP `^8.2`. Requires `ext-json` and `ext-curl` only. Redis persistence works with zero extra dependencies through a built-in RESP2 stream client; `ext-redis` and `predis/predis` adapters are provided and listed under `suggest`. - Autoload is PSR-4: `RenzoFranceschini\GuardAgent\` maps to `src/`. Every file is `declare(strict_types=1)`. There is no PSR-7/PSR-15 anything: this is a client library, not middleware. @@ -16,9 +16,9 @@ rennf93/guard-agent-php (https://github.com/Guard-Core/guard-agent-php) is the P - `guard-core` is the engine library; the framework adapters (fastapi-guard, flaskapi-guard, djapi-guard, tornadoapi-guard) wire it into frameworks; `guard-agent` (Python), guardagent (TS), guard-agent-rs (Rust), and guard-agent-go (Go) are the sibling telemetry agents; `guard-core-app` is the SaaS this agent reports to. - The ingestion contract, verified against guard-core-api source: - `POST {endpoint}/api/v1/events`, `/api/v1/metrics`, `/api/v1/status`. - - Headers: `X-API-Key` (required), `X-Project-Id` (optional), `X-Agent-Install-Id` (install tracking, telemetry_router.py:194), `User-Agent` (`guard-agent/`), optional `X-Payload-Signature`. + - Headers: `X-API-Key` (required), `X-Project-Id` (optional), `X-Agent-Install-Id` (install tracking, telemetry_router.py:194), `User-Agent` (`guard-agent-php/`, `Version::USER_AGENT`), optional `X-Payload-Signature`. - Bodies above `compressionThreshold` bytes are gzipped with `Content-Encoding: gzip`. The server's `GzipRequestMiddleware` inflates the body BEFORE the router runs. - - `X-Payload-Signature: v1=` where `` is HMAC-SHA256 over the UNCOMPRESSED body bytes: `_verify_signature_or_warn` (telemetry_router.py:110-126) hashes `request.body()` after the middleware already decompressed it, so the signature must always cover the plain JSON bytes. This differs from the Python/TS/Go agents, which sign the post-gzip bytes and fail verification whenever compression applies. + - `X-Payload-Signature: v1=` where `` is HMAC-SHA256 over the UNCOMPRESSED body bytes: `_verify_signature_or_warn` (telemetry_router.py:110-126) hashes `request.body()` after the middleware already decompressed it, so the signature must always cover the plain JSON bytes. The Python agent signs the same uncompressed bytes (`_transport_dispatch.py:108-126`), as do the TS and Rust agents. - `413` when the decompressed body exceeds 262144 bytes (`services/payload_size_guard.py`); `429` carries `Retry-After`; `200` with `success: false` or non-empty `errors[]` is a partial failure. - This package does NOT import guard-core-php, any framework, or any composer dependency. Guard adapters that need telemetry embed this agent directly. @@ -34,7 +34,7 @@ All of `src/`, namespace `RenzoFranceschini\GuardAgent`: - `Model/SecurityEvent.php`, `Model/SecurityMetric.php`, `Model/AgentStatus.php`: readonly wire models with `normalize()` (camelCase or snake_case input, `InvalidEventException` on bad input) and `toWire()` (snake_case, ISO-8601 UTC with milliseconds). `Model/KnownTypes.php`: the advisory event-type list and the enforced metric-type list. `Model/DynamicRules.php`: the SaaS rules surface (mirrors DynamicRules, guard_agent/models.py:250-324, snake_case wire keys with camelCase properties, `normalize()` raising `InvalidRulesException`; `ttl` drives the client cache, `blocked_cloud_providers` deduped like the Python set, `ge=1` enforced only on the `auto_ban_*` fields). `Model/WireFormat.php`: timestamp parsing (DateTimeInterface, epoch SECONDS, or parseable string) and `isoUtc()`. - `Install/InstallId.php`: file-backed install ID at `~/.guard-agent/install-id` (override parameter, uuid v4, all filesystem failures warn and fall back to an in-memory UUID). - `Log/AgentLogger.php` interface + `Log/DefaultAgentLogger.php` (streams in CLI SAPI, `error_log` in web SAPI; `GUARD_AGENT_DEBUG` enables debug). -- `Utils/`: `Json` (Python-json.dumps-compatible bytes: unescaped slashes, escaped non-ASCII; `SerializationException`), `Backoff` (`base * 2^attempt` capped), `RetryAfter`, `BatchId`, `Uuid` (v4 without ext-uuid), `HeadersRedactor` (recursive `[REDACTED]` with a JSON-string scan, depth cap 10), `ResponseSummary`, `ErrorHook` (stages `transport_send` / `flush_events` / `flush_metrics`; a throwing hook is caught and logged). +- `Utils/`: `Json` (Python-json.dumps-compatible bytes: unescaped slashes, escaped non-ASCII; `SerializationException`), `Backoff` (`base * 2^attempt` capped), `RetryAfter`, `BatchId`, `Uuid` (v4 without ext-uuid), `HeadersRedactor` (recursive `[REDACTED]` with a JSON-string scan, depth cap 10), `ResponseSummary`, `ErrorHook` (stages `transport_send` / `encryption` / `flush_events` / `flush_metrics`; a throwing hook is caught and logged). - `Exception/`: `GuardAgentException` base with `ConfigException`, `BufferFullException`, `InvalidEventException`, `SerializationException`, `InvalidRulesException`, `RateLimitedException` (carries `retryAfterSeconds`), `PermanentClientException` (400/404/422; `statusCode` + `detail`), `PayloadTooLargeException` (413, extends PermanentClientException, so both are circuit-breaker exempt), `RedisException`. - `examples/basic_usage/`: minimal wiring demonstration (engine `onBlock` payload to `SecurityEvent`, agent from env, host-driven `tick()` loop, final flush on shutdown; `basic_usage.php` + `README.md`). - `mkdocs.yml`: mkdocs-material site definition (docs/ sources). @@ -104,7 +104,9 @@ Every field except `apiKey` is optional; `AgentConfigResolver::resolve()` fills - **At-least-once handshake**: drain (keys aligned with items) -> send -> on success confirm (delete Redis records); on failure requeue at the FRONT in original order, retain Redis records, arm the per-kind gate for `min(flushInterval * 2^(streak - 1), 300)` seconds. Under pressure the requeue evicts the TAIL (newest items) and the caller confirms those evicted keys so nothing orphans. - **Response classification** (mirroring `_handle_response`): 200 with a JSON dict is evaluated (`success: false` or non-empty `errors[]` means partial failure -> whole batch requeued); 200 with an unparseable body is retried; 200 non-dict and 201 are accepted; 429 raises `RateLimitedException` with Retry-After (default 60s, honored up to 300s); 401/403 are generic retried errors; 400/404/422 are `PermanentClientException` (batch dropped, counted, never retried); 413 is `PayloadTooLargeException` (split in half recursively; a singleton that still exceeds the cap is dropped); 5xx retry with backoff; any other 4xx is logged and retried. - **Circuit breaker**: 5 consecutive non-permanent failures open it for 60s, then HALF_OPEN probes. 400/404/413/422 are exempt (batch-level rejections, not health signals); 429 and curl errors count. -- **Persistence**: persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, or the shutdown function, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. A shutdown function the agent registers once when Redis persistence is attached (`initializeRedis()`, including the one `start()` builds from `config.redis`) runs `persistAtShutdown()`, which calls `persistPending()`, so items buffered by a request are written to Redis when the process ends by normal end, `exit()`, an uncaught exception, a fatal error, out of memory or `max_execution_time` (proved by `bin/test_shutdown.php`). Events are lost only when PHP runs no code at all: SIGKILL (for example the OOM killer or `kill -9`), and SIGTERM unless the host installs its own handler (plain PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable). The agent installs no signal handler because it would override the host's. It does not call `fastcgi_finish_request()` either: that would cut off output and shutdown functions the host registers later, so a host that wants the response out first calls it itself. +- **Persistence** (a documented, maintainer-approved deviation from the Python agent, which persists from a background task spawned on add: PHP persists at flush, tick or shutdown, never on add, so a request never waits on Redis): persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, or the shutdown function, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (the stamp is wall-clock epoch nanoseconds like Python's `time.time_ns()`, microsecond resolution) (or `agent_metrics`), TTL 3600s. Startup reload walks the keys in the order Redis `KEYS` returns them, exactly like the Python agent (no sort; the stamp is wall-clock so a later sort would be chronological); corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. A shutdown function the agent registers once when Redis persistence is attached (`initializeRedis()`, including the one `start()` builds from `config.redis`) runs `persistAtShutdown()`, which calls `persistPending()`, so items buffered by a request are written to Redis when the process ends by normal end, `exit()`, an uncaught exception, a fatal error, out of memory or `max_execution_time` (proved by `bin/test_shutdown.php`). Events are lost only when PHP runs no code at all: SIGKILL (for example the OOM killer or `kill -9`), and SIGTERM unless the host installs its own handler (plain PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable). The agent installs no signal handler because it would override the host's. It does not call `fastcgi_finish_request()` either: that would cut off output and shutdown functions the host registers later, so a host that wants the response out first calls it itself. +- **Rules refresh**: `tick()` refreshes dynamic rules only when `projectId` is set, mirroring the reference's `if self.config.project_id` rules loop; `getDynamicRules()` still works without one. +- **Stats**: `getStats()` carries exactly the reference `get_stats()` key set (`bufferStats`, `transportStats` and `loopFailures` nested), camelCased (`events_sent` is `eventsSent`, `buffer_stats` is `bufferStats`, `auto_flush_running` is `autoFlushRunning`). The array is host-facing only (the PHP adapters merge it into `agentStats`); it is not serialized into any status push, health endpoint or SaaS payload, so the camelCase idiom is kept. `autoFlushRunning` is always `false` because PHP has no background auto-flush. - **Degraded status**: circuit OPEN, or buffer >= 90 percent occupancy, or lifetime failure rate > 10 percent. `healthCheck()` additionally fails at >= 95 percent occupancy or > 50 percent failure rate, and when not running. ## Development Commands diff --git a/CLAUDE.md b/CLAUDE.md index 0207005..5046426 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Guidance for AI agents (including Claude Code) working in this repository. rennf93/guard-agent-php (https://github.com/Guard-Core/guard-agent-php) is the PHP telemetry agent for the guard-core ecosystem. It buffers security events, performance metrics, and agent status in memory (optionally persisted to Redis for crash recovery) and ships them to the Guard Core App ingestion API with at-least-once delivery semantics: nothing acknowledged is lost, nothing unacknowledged is forgotten. -- Composer package `rennf93/guard-agent-php`, type `library`, license MIT. No `version` field in composer.json (the laravel-guard/symfony-guard convention); versions come from git tags and are reported to the server as `agent_version` from `RenzoFranceschini\GuardAgent\Version::VERSION` (`0.1.0` at the time of writing). Shipped tags: none yet. +- Composer package `rennf93/guard-agent-php`, type `library`, license MIT. No `version` field in composer.json (the laravel-guard/symfony-guard convention); versions come from git tags and are reported to the server as `agent_version` from `RenzoFranceschini\GuardAgent\Version::VERSION` (`3.2.4` at the time of writing, in lockstep with the Python agent; 3.2.4 is not tagged yet). - This package contains NO security logic. It reports what a host adapter already decided; detection, rate limiting, bans, and verdicts all live in guard-core (and guard-core-php). - PHP `^8.2`. Requires `ext-json` and `ext-curl` only. Redis persistence works with zero extra dependencies through a built-in RESP2 stream client; `ext-redis` and `predis/predis` adapters are provided and listed under `suggest`. - Autoload is PSR-4: `RenzoFranceschini\GuardAgent\` maps to `src/`. Every file is `declare(strict_types=1)`. There is no PSR-7/PSR-15 anything: this is a client library, not middleware. @@ -16,9 +16,9 @@ rennf93/guard-agent-php (https://github.com/Guard-Core/guard-agent-php) is the P - `guard-core` is the engine library; the framework adapters (fastapi-guard, flaskapi-guard, djapi-guard, tornadoapi-guard) wire it into frameworks; `guard-agent` (Python), guardagent (TS), guard-agent-rs (Rust), and guard-agent-go (Go) are the sibling telemetry agents; `guard-core-app` is the SaaS this agent reports to. - The ingestion contract, verified against guard-core-api source: - `POST {endpoint}/api/v1/events`, `/api/v1/metrics`, `/api/v1/status`. - - Headers: `X-API-Key` (required), `X-Project-Id` (optional), `X-Agent-Install-Id` (install tracking, telemetry_router.py:194), `User-Agent` (`guard-agent/`), optional `X-Payload-Signature`. + - Headers: `X-API-Key` (required), `X-Project-Id` (optional), `X-Agent-Install-Id` (install tracking, telemetry_router.py:194), `User-Agent` (`guard-agent-php/`, `Version::USER_AGENT`), optional `X-Payload-Signature`. - Bodies above `compressionThreshold` bytes are gzipped with `Content-Encoding: gzip`. The server's `GzipRequestMiddleware` inflates the body BEFORE the router runs. - - `X-Payload-Signature: v1=` where `` is HMAC-SHA256 over the UNCOMPRESSED body bytes: `_verify_signature_or_warn` (telemetry_router.py:110-126) hashes `request.body()` after the middleware already decompressed it, so the signature must always cover the plain JSON bytes. This differs from the Python/TS/Go agents, which sign the post-gzip bytes and fail verification whenever compression applies. + - `X-Payload-Signature: v1=` where `` is HMAC-SHA256 over the UNCOMPRESSED body bytes: `_verify_signature_or_warn` (telemetry_router.py:110-126) hashes `request.body()` after the middleware already decompressed it, so the signature must always cover the plain JSON bytes. The Python agent signs the same uncompressed bytes (`_transport_dispatch.py:108-126`), as do the TS and Rust agents. - `413` when the decompressed body exceeds 262144 bytes (`services/payload_size_guard.py`); `429` carries `Retry-After`; `200` with `success: false` or non-empty `errors[]` is a partial failure. - This package does NOT import guard-core-php, any framework, or any composer dependency. Guard adapters that need telemetry embed this agent directly. @@ -34,7 +34,7 @@ All of `src/`, namespace `RenzoFranceschini\GuardAgent`: - `Model/SecurityEvent.php`, `Model/SecurityMetric.php`, `Model/AgentStatus.php`: readonly wire models with `normalize()` (camelCase or snake_case input, `InvalidEventException` on bad input) and `toWire()` (snake_case, ISO-8601 UTC with milliseconds). `Model/KnownTypes.php`: the advisory event-type list and the enforced metric-type list. `Model/DynamicRules.php`: the SaaS rules surface (mirrors DynamicRules, guard_agent/models.py:250-324, snake_case wire keys with camelCase properties, `normalize()` raising `InvalidRulesException`; `ttl` drives the client cache, `blocked_cloud_providers` deduped like the Python set, `ge=1` enforced only on the `auto_ban_*` fields). `Model/WireFormat.php`: timestamp parsing (DateTimeInterface, epoch SECONDS, or parseable string) and `isoUtc()`. - `Install/InstallId.php`: file-backed install ID at `~/.guard-agent/install-id` (override parameter, uuid v4, all filesystem failures warn and fall back to an in-memory UUID). - `Log/AgentLogger.php` interface + `Log/DefaultAgentLogger.php` (streams in CLI SAPI, `error_log` in web SAPI; `GUARD_AGENT_DEBUG` enables debug). -- `Utils/`: `Json` (Python-json.dumps-compatible bytes: unescaped slashes, escaped non-ASCII; `SerializationException`), `Backoff` (`base * 2^attempt` capped), `RetryAfter`, `BatchId`, `Uuid` (v4 without ext-uuid), `HeadersRedactor` (recursive `[REDACTED]` with a JSON-string scan, depth cap 10), `ResponseSummary`, `ErrorHook` (stages `transport_send` / `flush_events` / `flush_metrics`; a throwing hook is caught and logged). +- `Utils/`: `Json` (Python-json.dumps-compatible bytes: unescaped slashes, escaped non-ASCII; `SerializationException`), `Backoff` (`base * 2^attempt` capped), `RetryAfter`, `BatchId`, `Uuid` (v4 without ext-uuid), `HeadersRedactor` (recursive `[REDACTED]` with a JSON-string scan, depth cap 10), `ResponseSummary`, `ErrorHook` (stages `transport_send` / `encryption` / `flush_events` / `flush_metrics`; a throwing hook is caught and logged). - `Exception/`: `GuardAgentException` base with `ConfigException`, `BufferFullException`, `InvalidEventException`, `SerializationException`, `InvalidRulesException`, `RateLimitedException` (carries `retryAfterSeconds`), `PermanentClientException` (400/404/422; `statusCode` + `detail`), `PayloadTooLargeException` (413, extends PermanentClientException, so both are circuit-breaker exempt), `RedisException`. - `examples/basic_usage/`: minimal wiring demonstration (engine `onBlock` payload to `SecurityEvent`, agent from env, host-driven `tick()` loop, final flush on shutdown; `basic_usage.php` + `README.md`). - `mkdocs.yml`: mkdocs-material site definition (docs/ sources). @@ -104,7 +104,9 @@ Every field except `apiKey` is optional; `AgentConfigResolver::resolve()` fills - **At-least-once handshake**: drain (keys aligned with items) -> send -> on success confirm (delete Redis records); on failure requeue at the FRONT in original order, retain Redis records, arm the per-kind gate for `min(flushInterval * 2^(streak - 1), 300)` seconds. Under pressure the requeue evicts the TAIL (newest items) and the caller confirms those evicted keys so nothing orphans. - **Response classification** (mirroring `_handle_response`): 200 with a JSON dict is evaluated (`success: false` or non-empty `errors[]` means partial failure -> whole batch requeued); 200 with an unparseable body is retried; 200 non-dict and 201 are accepted; 429 raises `RateLimitedException` with Retry-After (default 60s, honored up to 300s); 401/403 are generic retried errors; 400/404/422 are `PermanentClientException` (batch dropped, counted, never retried); 413 is `PayloadTooLargeException` (split in half recursively; a singleton that still exceeds the cap is dropped); 5xx retry with backoff; any other 4xx is logged and retried. - **Circuit breaker**: 5 consecutive non-permanent failures open it for 60s, then HALF_OPEN probes. 400/404/413/422 are exempt (batch-level rejections, not health signals); 429 and curl errors count. -- **Persistence**: persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, or the shutdown function, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. A shutdown function the agent registers once when Redis persistence is attached (`initializeRedis()`, including the one `start()` builds from `config.redis`) runs `persistAtShutdown()`, which calls `persistPending()`, so items buffered by a request are written to Redis when the process ends by normal end, `exit()`, an uncaught exception, a fatal error, out of memory or `max_execution_time` (proved by `bin/test_shutdown.php`). Events are lost only when PHP runs no code at all: SIGKILL (for example the OOM killer or `kill -9`), and SIGTERM unless the host installs its own handler (plain PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable). The agent installs no signal handler because it would override the host's. It does not call `fastcgi_finish_request()` either: that would cut off output and shutdown functions the host registers later, so a host that wants the response out first calls it itself. +- **Persistence** (a documented, maintainer-approved deviation from the Python agent, which persists from a background task spawned on add: PHP persists at flush, tick or shutdown, never on add, so a request never waits on Redis): persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, or the shutdown function, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (the stamp is wall-clock epoch nanoseconds like Python's `time.time_ns()`, microsecond resolution) (or `agent_metrics`), TTL 3600s. Startup reload walks the keys in the order Redis `KEYS` returns them, exactly like the Python agent (no sort; the stamp is wall-clock so a later sort would be chronological); corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. A shutdown function the agent registers once when Redis persistence is attached (`initializeRedis()`, including the one `start()` builds from `config.redis`) runs `persistAtShutdown()`, which calls `persistPending()`, so items buffered by a request are written to Redis when the process ends by normal end, `exit()`, an uncaught exception, a fatal error, out of memory or `max_execution_time` (proved by `bin/test_shutdown.php`). Events are lost only when PHP runs no code at all: SIGKILL (for example the OOM killer or `kill -9`), and SIGTERM unless the host installs its own handler (plain PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable). The agent installs no signal handler because it would override the host's. It does not call `fastcgi_finish_request()` either: that would cut off output and shutdown functions the host registers later, so a host that wants the response out first calls it itself. +- **Rules refresh**: `tick()` refreshes dynamic rules only when `projectId` is set, mirroring the reference's `if self.config.project_id` rules loop; `getDynamicRules()` still works without one. +- **Stats**: `getStats()` carries exactly the reference `get_stats()` key set (`bufferStats`, `transportStats` and `loopFailures` nested), camelCased (`events_sent` is `eventsSent`, `buffer_stats` is `bufferStats`, `auto_flush_running` is `autoFlushRunning`). The array is host-facing only (the PHP adapters merge it into `agentStats`); it is not serialized into any status push, health endpoint or SaaS payload, so the camelCase idiom is kept. `autoFlushRunning` is always `false` because PHP has no background auto-flush. - **Degraded status**: circuit OPEN, or buffer >= 90 percent occupancy, or lifetime failure rate > 10 percent. `healthCheck()` additionally fails at >= 95 percent occupancy or > 50 percent failure rate, and when not running. ## Development Commands diff --git a/README.md b/README.md index 5ec9de8..662a808 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ $agent->stop(); // final flush, releases Redis ### The uncompressed-signature note -When `payloadSigningSecret` is set, the transport sends `X-Payload-Signature: v1=` where `` is `hash_hmac('sha256', , )`. The Guard Core App ingestion API verifies the signature **after** decompressing the body (its `GzipRequestMiddleware` inflates `Content-Encoding: gzip` request bodies before the telemetry router runs), so the HMAC must always cover the uncompressed JSON bytes. This differs from the Python/TypeScript/Go agents, which sign the post-gzip bytes and silently fail verification whenever compression kicks in; the PHP agent signs what the server actually verifies. +When `payloadSigningSecret` is set, the transport sends `X-Payload-Signature: v1=` where `` is `hash_hmac('sha256', , )`. The Guard Core App ingestion API verifies the signature **after** decompressing the body (its `GzipRequestMiddleware` inflates `Content-Encoding: gzip` request bodies before the telemetry router runs), so the HMAC must always cover the uncompressed JSON bytes. The Python, TypeScript and Rust agents sign the same uncompressed bytes. ## Dynamic rules @@ -218,7 +218,7 @@ When `projectEncryptionKey` is set (a urlsafe-base64-encoded 256-bit key issued ## Persistence -With `redis` configured, every accepted item is written to Redis under a globally-unique key (`{prefix}:agent_events:event__<8hex>`, TTL 3600s) by `persistPending()`, which `flushBuffer()` runs after its send attempt (so delivered items are never written) and `flushIfNeeded()`/`tick()` run between flushes; adding an item never waits on Redis. If the process ends before the next flush or tick, a shutdown function the agent registers once when persistence is attached writes what is still pending, so a normal end, `exit()`, an uncaught exception, a fatal error, out of memory and `max_execution_time` do not lose events (`bin/test_shutdown.php` proves each against a real Redis). Events are lost only when PHP can run no code: SIGKILL (the OOM killer, `kill -9`), and SIGTERM unless the host installs its own handler (PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable, and the agent installs none because it would override the host's). The shutdown write runs after the response is built, so a host that wants the client released first calls `fastcgi_finish_request()` itself; the agent does not, because that would cut off output and shutdown functions registered after it. On `start()` the buffer reloads whatever a previous process left behind. Every Redis failure is fail-open: logged, counted, and after 3 consecutive write failures paused for a 30s cooldown, so an unhealthy Redis cannot tax the request path. The TTL is the backstop: at worst a lost confirmation duplicates a reload, it never loses data. +With `redis` configured, every accepted item is written to Redis under a globally-unique key (`{prefix}:agent_events:event__<8hex>`, TTL 3600s) by `persistPending()`, which `flushBuffer()` runs after its send attempt (so delivered items are never written) and `flushIfNeeded()`/`tick()` run between flushes; adding an item never waits on Redis. If the process ends before the next flush or tick, a shutdown function the agent registers once when persistence is attached writes what is still pending, so a normal end, `exit()`, an uncaught exception, a fatal error, out of memory and `max_execution_time` do not lose events (`bin/test_shutdown.php` proves each against a real Redis). Events are lost only when PHP can run no code: SIGKILL (the OOM killer, `kill -9`), and SIGTERM unless the host installs its own handler (PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable, and the agent installs none because it would override the host's). The shutdown write runs after the response is built, so a host that wants the client released first calls `fastcgi_finish_request()` itself; the agent does not, because that would cut off output and shutdown functions registered after it. On `start()` the buffer reloads whatever a previous process left behind. Every Redis failure is fail-open: logged, counted, and after 3 consecutive write failures paused for a 30s cooldown, so an unhealthy Redis cannot tax the request path. The TTL is the backstop: at worst a lost confirmation duplicates a reload, it never loses data. ## Documentation diff --git a/bin/test_agent.php b/bin/test_agent.php index 81db6e6..4dea48a 100644 --- a/bin/test_agent.php +++ b/bin/test_agent.php @@ -3409,4 +3409,56 @@ static function (string $stage, Throwable $error, array $context) use (&$hookCal $t->ok($caught !== null && str_contains($caught->getMessage(), 'refusing plaintext fallback (cause: '), 'startup error message carries the cause'); $t->ok($caught !== null && $caught->getPrevious() !== null && str_contains($caught->getMessage(), $caught->getPrevious()->getMessage()), 'cause text equals the chained exception message'); +// ===================================================================== +// Parity with the Python reference agent (guard-agent 3.2.4) +// ===================================================================== +$t->section('reference parity'); + +// The rules refresh only runs with a project id (client.py:128-129). +foreach ([[null, 0], ['', 0], ['proj-1', 1]] as [$projectId, $expectedRules]) { + $parityTransport = new RecordingTransport(); + $parityTransport->rulesOutcomes = [DynamicRules::normalize(['rule_id' => 'p', 'ttl' => 0])]; + $parityAgent = makeAgent(['dynamicRuleInterval' => 60, 'projectId' => $projectId], $parityTransport); + $parityAgent->start(); + if (refl($parityAgent, 'nextRulesRefreshAt')->getValue($parityAgent) !== null) { + refl($parityAgent, 'nextRulesRefreshAt')->setValue($parityAgent, microtime(true) - 1); + } + $parityAgent->tick(); + $t->same($expectedRules, $parityTransport->rulesRequests, 'rules refresh requests with projectId ' . var_export($projectId, true)); + $parityAgent->stop(); +} + +// Persistence keys carry wall-clock epoch nanoseconds like time.time_ns() +// (_buffer_redis.py:54), not a monotonic clock. +$fake = new FakeRedisClient(); +$parityAgent = makeAgent([], new RecordingTransport()); +$parityAgent->initializeRedis(new RedisHandler($fake, 'guard:agent:test:stamp', new SilentLogger())); +$beforeNs = (int) (microtime(true) * 1_000_000) * 1000; +$parityAgent->sendEvent(makeEvent()); +$parityAgent->buffer->persistPending(); +$afterNs = ((int) (microtime(true) * 1_000_000) + 1) * 1000; +$stampKeys = $fake->keysContaining(':agent_events:event_'); +$t->same(1, count($stampKeys), 'stamp: one event key persisted'); +$stampOk = preg_match('/:event_(\d{19})_[0-9a-f]{8}$/', $stampKeys[0] ?? '', $stampMatch) === 1; +$t->ok($stampOk, 'stamp: key is event_<19 digit ns>_<8 hex>'); +$t->ok($stampOk && (int) $stampMatch[1] >= $beforeNs && (int) $stampMatch[1] <= $afterNs, 'stamp: nanoseconds are wall-clock epoch'); + +// getStats carries exactly the reference get_stats() key set, camelCased +// (_client_status.py:70-89, buffer.py:57-74, _transport_lifecycle.py:128-137). +$camel = static fn (string $key): string => lcfirst(str_replace('_', '', ucwords($key, '_'))); +$expectTop = ['running', 'uptime', 'events_sent', 'metrics_sent', 'events_failed', 'metrics_failed', 'rules_fetched', 'buffer_stats', 'transport_stats', 'cached_rules', 'rules_last_update', 'loop_failures', 'last_status_push_ok']; +$expectBuffer = ['events_buffered', 'metrics_buffered', 'events_flushed', 'metrics_flushed', 'events_dropped', 'metrics_dropped', 'current_event_buffer_size', 'current_metric_buffer_size', 'redis_persist_failures', 'durability_degraded', 'last_flush_time', 'auto_flush_running']; +$expectTransport = ['requests_sent', 'requests_failed', 'bytes_sent', 'circuit_breaker_state', 'failure_count', 'session_closed']; +$sortedKeys = static function (array $keys): array { + sort($keys); + + return $keys; +}; +$statsAgent = makeAgent(); +$agentStats = $statsAgent->getStats(); +$t->same($sortedKeys(array_map($camel, $expectTop)), $sortedKeys(array_keys($agentStats)), 'stats: agent key set equals the reference'); +$t->same($sortedKeys(array_map($camel, $expectBuffer)), $sortedKeys(array_keys($agentStats['bufferStats'])), 'stats: buffer key set equals the reference'); +$t->same($sortedKeys(array_map($camel, $expectTransport)), $sortedKeys(array_keys((new HttpTransport(makeConfig()))->getStats())), 'stats: transport key set equals the reference'); +$t->same(['flush', 'rules', 'status'], $sortedKeys(array_keys($agentStats['loopFailures'])), 'stats: loop failure keys equal the reference'); + $t->finish(); diff --git a/docs/usage.md b/docs/usage.md index 5231d6d..3dfa6bc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -84,7 +84,8 @@ UNCOMPRESSED JSON body; the server verifies after decompression. Gzip ## Redis persistence With `redis` configured, every buffered item is persisted under -`{keyPrefix}:{namespace}:{key}` (TTL 3600s) before the send attempt and +`{keyPrefix}:{namespace}:{key}` (TTL 3600s) by `persistPending()` at flush, `tick()` or shutdown (never on +add, a documented PHP deviation so a request never waits on Redis) and confirmed (deleted) on success, so a crash between buffer and network loses nothing; `start()` reloads what a previous process left behind. Every Redis failure is fail-open: logged, counted, and after 3 consecutive write diff --git a/src/EventBuffer/EventBuffer.php b/src/EventBuffer/EventBuffer.php index 549da03..a905357 100644 --- a/src/EventBuffer/EventBuffer.php +++ b/src/EventBuffer/EventBuffer.php @@ -680,6 +680,7 @@ public function getStats(): array 'redisPersistFailures' => $this->redisPersistFailures, 'durabilityDegraded' => $this->redisHandler !== null && $this->redisPersistFailures > 0, 'lastFlushTime' => $this->lastFlushTime, + 'autoFlushRunning' => false, // PHP has no background auto-flush (host-driven tick()) ]; } @@ -688,12 +689,12 @@ public function getStats(): array // ------------------------------------------------------------------ /** - * Globally-unique short persistence key: {kind}_{unix nanos}_{8 hex}, + * Globally-unique short persistence key: {kind}_{wall-clock epoch nanos}_{8 hex}, * mirroring guard_agent/_buffer_redis.py:47. */ public static function uniqueKey(string $kind): string { - $nanos = (string) hrtime(true); + $nanos = ((int) (microtime(true) * 1_000_000)) . '000'; // wall-clock epoch ns like time.time_ns() $rand = substr(Uuid::hex(), 0, 8); return $kind . '_' . $nanos . '_' . $rand; diff --git a/src/GuardAgent.php b/src/GuardAgent.php index bf56b85..9e65e25 100644 --- a/src/GuardAgent.php +++ b/src/GuardAgent.php @@ -236,7 +236,7 @@ public function start(): void $this->running = true; $this->nextStatusPushAt = microtime(true) + $this->config->statusInterval; - $this->nextRulesRefreshAt = microtime(true) + $this->config->dynamicRuleInterval; + $this->nextRulesRefreshAt = $this->config->projectId === null || $this->config->projectId === '' ? null : microtime(true) + $this->config->dynamicRuleInterval; // rules refresh only with a project id (client.py:128-129) $this->logger->info('Guard Agent started successfully'); } catch (\Throwable $error) {