Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<version>`), 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>`, `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=<hex>` where `<hex>` 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=<hex>` where `<hex>` 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.

Expand All @@ -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).
Expand Down Expand Up @@ -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_<nanos>_<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_<epoch nanos>_<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
Expand Down
Loading
Loading