From 8c856d6dd075c55819ed38f84c350a2e527c407b Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Mon, 12 Oct 2026 20:48:20 +0200 Subject: [PATCH] feat(agenstra): proxy-based web traffic filters start a proxy sidecar container with every workspace that consumes a consolidated ruleset provided through the frontend, enforcing certain traffic flow and giving the option to explicitly allow and drop traffic to specified targets --- .github/workflows/pull-request-checks.yml | 1 + .github/workflows/release.yml | 1 + .../.start-containers.env.example | 4 +- .../docker-compose.yaml | 4 +- ..._LayeredSecurityPoliciesAndEgressEvents.ts | 495 ++++ ...controller-queue-registrar.service.spec.ts | 30 + .../controller-queue-registrar.service.ts | 12 +- .../src/queue/controller-queue.module.ts | 2 - .../src/queue/job-registry.spec.ts | 29 +- .../src/queue/job-registry.ts | 38 +- .../processors/controller-jobs.processor.ts | 33 +- .../src/typeorm.config.ts | 10 +- .../.start-containers.env.example | 9 + .../Dockerfile.egress-guard | 51 + .../backend-agent-manager/docker-compose.yaml | 7 + .../egress-guard/agenstra-egress | 250 ++ .../egress-guard/default/dnsmasq.conf | 10 + .../egress-guard/default/squid.conf | 24 + .../egress-guard/entrypoint.sh | 192 ++ .../backend-agent-manager/project.json | 35 +- ...0000000_AddEgressGuardAndMessageFilters.ts | 147 ++ .../src/typeorm.config.ts | 4 +- .../src/i18n/messages.de.xlf | 622 +++-- .../src/i18n/messages.xlf | 431 ++-- .../src/i18n/messages.de.xlf | 4 +- .../src/i18n/messages.xlf | 2 +- docs/agenstra/api-reference/README.md | 4 +- .../applications/backend-agent-controller.md | 16 +- .../applications/backend-agent-manager.md | 15 +- .../applications/frontend-agent-console.md | 16 +- docs/agenstra/architecture/components.md | 18 +- docs/agenstra/deployment/background-jobs.md | 4 +- .../deployment/environment-configuration.md | 21 + .../deployment/system-requirements.md | 6 +- docs/agenstra/features/README.md | 14 +- docs/agenstra/features/agent-configuration.md | 38 +- docs/agenstra/features/agent-management.md | 5 +- docs/agenstra/features/atlassian-import.md | 4 +- docs/agenstra/features/chat-interface.md | 2 +- .../features/dynamic-provider-plugins.md | 2 +- docs/agenstra/features/egress-filtering.md | 197 ++ .../agenstra/features/message-filter-rules.md | 34 - docs/agenstra/features/message-filters.md | 79 + docs/agenstra/features/opentelemetry.md | 21 +- docs/agenstra/features/search-indexes.md | 2 +- docs/agenstra/features/usage-statistics.md | 21 +- docs/agenstra/features/webhooks.md | 62 +- docs/agenstra/getting-started.md | 2 +- docs/agenstra/security/container-images.md | 3 + .../security/operational-hardening.md | 7 + graph/graph.json | 2004 ++++++++++------- .../feature-agent-controller/README.md | 3 + .../docs/egress-events-ingest.mmd | 45 + .../docs/filter-rules-sync.mmd | 21 - .../spec/openapi.yaml | 1013 ++++++--- .../feature-agent-controller/src/index.ts | 14 +- .../src/lib/auth/agenstra-pat.scopes.spec.ts | 1 - .../src/lib/auth/agenstra-pat.scopes.ts | 1 - .../admin-opencode-config.controller.ts | 17 +- .../client-agent-egress.controller.spec.ts | 72 + .../client-agent-egress.controller.ts | 119 + .../client-opencode-config.controller.ts | 48 + .../client-statistics.controller.ts | 31 + .../controllers/clients.controller.spec.ts | 16 +- .../src/lib/controllers/clients.controller.ts | 12 +- .../filter-rules.controller.spec.ts | 154 -- .../controllers/filter-rules.controller.ts | 91 - .../lib/controllers/statistics.controller.ts | 31 + .../filter-rules/create-filter-rule.dto.ts | 57 - .../filter-rules/filter-rule-response.dto.ts | 36 - .../src/lib/dto/filter-rules/index.ts | 3 - .../filter-rules/update-filter-rule.dto.ts | 47 - .../src/lib/dto/opencode-config.dto.ts | 32 +- .../src/lib/dto/statistics/index.ts | 1 + .../statistics/statistics-egress-event.dto.ts | 41 + .../dto/statistics/statistics-summary.dto.ts | 20 + ...le-regex-filter-rule-client.entity.spec.ts | 25 - ...console-regex-filter-rule-client.entity.ts | 24 - ...gex-filter-rule-sync-target.entity.spec.ts | 39 - ...le-regex-filter-rule-sync-target.entity.ts | 46 - ...t-console-regex-filter-rule.entity.spec.ts | 45 - .../agent-console-regex-filter-rule.entity.ts | 49 - .../egress-event-ingest-cursor.entity.ts | 28 + .../opencode-config-sync-target.entity.ts | 6 +- .../opencode-layer-file-sync-target.entity.ts | 2 +- .../statistics-egress-event.entity.ts | 92 + .../src/lib/gateways/clients.gateway.ts | 4 +- .../src/lib/modules/clients.module.spec.ts | 32 +- .../src/lib/modules/clients.module.ts | 9 +- .../src/lib/modules/filter-rules.module.ts | 39 - .../src/lib/modules/statistics.module.ts | 8 +- .../agenstra-notification.events.spec.ts | 19 +- .../agenstra-notification.events.ts | 11 +- .../agenstra-notification.publisher.spec.ts | 110 +- .../agenstra-notification.publisher.ts | 115 +- .../statistics-security.repository.spec.ts | 190 ++ .../statistics-security.repository.ts | 404 ++++ .../search/agenstra-search-document.mapper.ts | 63 +- .../agenstra-search-index.service.spec.ts | 4 +- .../search/agenstra-search-index.service.ts | 38 +- .../lib/search/agenstra-search-list.util.ts | 15 +- .../src/lib/search/agenstra-search.module.ts | 4 +- .../src/lib/search/agenstra-search.types.ts | 14 +- ...agenstra-metrics-collector.service.spec.ts | 37 +- .../agenstra-metrics-collector.service.ts | 26 +- ...anager-filter-rules-client.service.spec.ts | 217 -- ...ent-manager-filter-rules-client.service.ts | 127 -- ...ent-agent-opencode-config-proxy.service.ts | 4 +- .../services/client-agent-proxy.service.ts | 41 +- .../egress-events-ingest.service.spec.ts | 162 ++ .../services/egress-events-ingest.service.ts | 194 ++ .../filter-rules-sync.service.spec.ts | 259 --- .../lib/services/filter-rules-sync.service.ts | 139 -- .../lib/services/filter-rules.service.spec.ts | 139 -- .../src/lib/services/filter-rules.service.ts | 450 ---- ...encode-config-sync-targets.service.spec.ts | 100 + .../opencode-config-sync-targets.service.ts | 93 +- .../services/opencode-config.service.spec.ts | 61 + .../lib/services/opencode-config.service.ts | 22 + ...code-effective-config-sync.service.spec.ts | 28 +- .../opencode-effective-config-sync.service.ts | 36 +- .../services/statistics-query.service.spec.ts | 119 +- .../lib/services/statistics-query.service.ts | 154 +- .../lib/services/statistics.service.spec.ts | 78 +- .../src/lib/services/statistics.service.ts | 104 + .../utils/agent-config-change.utils.spec.ts | 38 + .../lib/utils/agent-config-change.utils.ts | 65 + .../backend/feature-agent-manager/README.md | 2 + .../docs/egress-guard.mmd | 64 + .../docs/regex-filters.mmd | 20 - .../feature-agent-manager/spec/asyncapi.yaml | 61 + .../feature-agent-manager/spec/openapi.yaml | 457 ++-- .../feature-agent-manager/src/index.ts | 8 +- .../lib/constants/egress-guard.constants.ts | 94 + .../agents-egress.controller.spec.ts | 87 + .../controllers/agents-egress.controller.ts | 94 + .../agents-filters.controller.spec.ts | 121 - .../controllers/agents-filters.controller.ts | 90 - .../agents-opencode-config.controller.ts | 2 +- .../src/lib/dto/agent-egress.dto.ts | 72 + .../src/lib/dto/agent-opencode-config.dto.ts | 27 +- .../src/lib/dto/agent-response.dto.ts | 5 + .../src/lib/dto/create-agent.dto.ts | 11 +- .../lib/dto/create-regex-filter-rule.dto.ts | 36 - .../lib/dto/regex-filter-rule-response.dto.ts | 16 - .../lib/dto/update-regex-filter-rule.dto.ts | 37 - .../lib/entities/agent-egress-event.entity.ts | 80 + .../src/lib/entities/agent.entity.ts | 36 + .../entities/regex-filter-rule.entity.spec.ts | 27 - .../lib/entities/regex-filter-rule.entity.ts | 41 - .../src/lib/gateways/agents.gateway.ts | 28 + .../src/lib/modules/agents.module.spec.ts | 21 +- .../src/lib/modules/agents.module.ts | 44 +- ...atabase-regex-incoming-chat-filter.spec.ts | 68 - .../database-regex-incoming-chat-filter.ts | 27 - ...atabase-regex-outgoing-chat-filter.spec.ts | 68 - .../database-regex-outgoing-chat-filter.ts | 27 - .../message-filters-chat-filters.spec.ts | 34 + .../message-filters-incoming-chat-filter.ts | 28 + .../message-filters-outgoing-chat-filter.ts | 28 + ...encode-config-sync-platform-policy.spec.ts | 168 ++ .../opencode/opencode-config-sync.service.ts | 77 +- .../agent-egress-events.repository.ts | 60 + .../src/lib/repositories/agents.repository.ts | 10 + .../regex-filter-rules.repository.spec.ts | 122 - .../regex-filter-rules.repository.ts | 64 - ...-manager-metrics-collector.service.spec.ts | 39 +- ...nstra-manager-metrics-collector.service.ts | 68 +- ...nt-egress-events-collector.service.spec.ts | 174 ++ .../agent-egress-events-collector.service.ts | 290 +++ .../lib/services/agent-egress.service.spec.ts | 474 ++++ .../src/lib/services/agent-egress.service.ts | 679 ++++++ .../agent-message-filters.service.spec.ts | 111 + .../services/agent-message-filters.service.ts | 96 + .../services/agents-filters.service.spec.ts | 55 - .../lib/services/agents-filters.service.ts | 88 - .../src/lib/services/agents.service.spec.ts | 150 ++ .../src/lib/services/agents.service.ts | 157 +- .../src/lib/services/docker.service.spec.ts | 220 ++ .../src/lib/services/docker.service.ts | 220 +- .../regex-filter-rules-cache.service.spec.ts | 53 - .../regex-filter-rules-cache.service.ts | 28 - ...egex-filter-rules-evaluate.service.spec.ts | 71 - .../regex-filter-rules-evaluate.service.ts | 83 - .../agent-environment-file.utils.spec.ts | 21 + .../lib/utils/agent-environment-file.utils.ts | 67 +- .../utils/egress-guard-config.utils.spec.ts | 274 +++ .../lib/utils/egress-guard-config.utils.ts | 635 ++++++ .../lib/utils/egress-guard-log.utils.spec.ts | 280 +++ .../src/lib/utils/egress-guard-log.utils.ts | 469 ++++ .../lib/utils/regex-filter-rule.utils.spec.ts | 43 - .../src/lib/utils/regex-filter-rule.utils.ts | 73 - .../data-access-agent-console/src/index.ts | 7 - .../lib/services/egress-http.service.spec.ts | 59 + .../lib/services/filter-rules.service.spec.ts | 121 - .../src/lib/services/filter-rules.service.ts | 58 - .../lib/services/opencode-config.service.ts | 79 + .../src/lib/services/statistics.service.ts | 41 + .../filter-rules/filter-rules.actions.ts | 55 - .../filter-rules/filter-rules.effects.spec.ts | 125 - .../filter-rules/filter-rules.effects.ts | 145 -- .../state/filter-rules/filter-rules.facade.ts | 72 - .../filter-rules/filter-rules.reducer.spec.ts | 130 -- .../filter-rules/filter-rules.reducer.ts | 122 - .../filter-rules/filter-rules.selectors.ts | 21 - .../state/filter-rules/filter-rules.types.ts | 64 - .../statistics/statistics-egress.spec.ts | 189 ++ .../state/statistics/statistics.actions.ts | 29 + .../state/statistics/statistics.effects.ts | 36 + .../lib/state/statistics/statistics.facade.ts | 29 + .../state/statistics/statistics.reducer.ts | 49 + .../state/statistics/statistics.selectors.ts | 10 + .../lib/state/statistics/statistics.types.ts | 64 + .../agent-config-editor.component.html | 586 +++++ .../agent-config-editor.component.ts | 214 ++ .../security-policy-editor.util.spec.ts | 106 + .../security-policy-editor.util.ts | 220 ++ .../src/lib/agent-console.routes.ts | 25 +- .../src/lib/audit/audit-security.util.spec.ts | 94 + .../src/lib/audit/audit-security.util.ts | 168 ++ .../src/lib/audit/audit.component.html | 238 +- .../src/lib/audit/audit.component.ts | 274 +++ .../src/lib/container/container.component.ts | 13 +- .../lib/rule-manager/filter-rule-labels.ts | 69 - .../rule-manager/rule-manager.component.html | 412 ---- .../rule-manager/rule-manager.component.scss | 13 - .../rule-manager/rule-manager.component.ts | 409 ---- .../shared/matrix/comparison-matrix-data.ts | 2 +- .../agenstra-shared-util-opencode-config.ts | 4 + .../src/lib/egress-policy.ts | 1306 +++++++++++ .../util-opencode-config/src/lib/heredity.ts | 2 + .../util-opencode-config/src/lib/locks.ts | 14 + .../src/lib/materialize.ts | 3 + .../util-opencode-config/src/lib/merge.ts | 29 + .../src/lib/message-filters.ts | 517 +++++ .../src/lib/platform-policy.ts | 36 + .../src/lib/platform-security.spec.ts | 578 +++++ .../src/lib/platform-security.ts | 70 + .../util-opencode-config/src/lib/types.ts | 8 +- .../agent-controller.utils.spec.ts | 2 +- .../agent-controller.utils.ts | 4 +- .../src/lib/constants/pat.constants.ts | 1 - .../util-queue/src/lib/job-id.util.spec.ts | 4 +- ...egister-repeatable-coordinator-job.spec.ts | 12 +- .../src/lib/webhook-delivery-policy.ts | 4 +- 245 files changed, 17976 insertions(+), 7344 deletions(-) create mode 100644 apps/agenstra/backend-agent-controller/src/migrations/1792000000000_LayeredSecurityPoliciesAndEgressEvents.ts create mode 100644 apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.spec.ts create mode 100644 apps/agenstra/backend-agent-manager/Dockerfile.egress-guard create mode 100644 apps/agenstra/backend-agent-manager/egress-guard/agenstra-egress create mode 100644 apps/agenstra/backend-agent-manager/egress-guard/default/dnsmasq.conf create mode 100644 apps/agenstra/backend-agent-manager/egress-guard/default/squid.conf create mode 100644 apps/agenstra/backend-agent-manager/egress-guard/entrypoint.sh create mode 100644 apps/agenstra/backend-agent-manager/src/migrations/1792000000000_AddEgressGuardAndMessageFilters.ts create mode 100644 docs/agenstra/features/egress-filtering.md delete mode 100644 docs/agenstra/features/message-filter-rules.md create mode 100644 docs/agenstra/features/message-filters.md create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/docs/egress-events-ingest.mmd delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/docs/filter-rules-sync.mmd create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/create-filter-rule.dto.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/filter-rule-response.dto.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/index.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/update-filter-rule.dto.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-egress-event.dto.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/egress-event-ingest-cursor.entity.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-egress-event.entity.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/docs/regex-filters.mmd create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/egress-guard.constants.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-egress.dto.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-regex-filter-rule.dto.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/regex-filter-rule-response.dto.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-regex-filter-rule.dto.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-egress-event.entity.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-chat-filters.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-incoming-chat-filter.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-outgoing-chat-filter.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync-platform-policy.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/egress-http.service.spec.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.spec.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.actions.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.spec.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.facade.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.spec.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.selectors.ts delete mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.types.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics-egress.spec.ts create mode 100644 libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.spec.ts create mode 100644 libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.ts create mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.spec.ts create mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.ts delete mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/filter-rule-labels.ts delete mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.html delete mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.scss delete mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/egress-policy.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/message-filters.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-policy.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.spec.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.ts diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index d2b6019c0..8559b30de 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -401,6 +401,7 @@ jobs: - name: Build Worker container images run: | npx nx affected --target=worker-container-image --configuration=test --parallel=3 + npx nx affected --target=egress-guard-container-image --configuration=test --parallel=3 - name: Scan Worker container images with Trivy uses: ./.github/actions/trivy-scan-local-images diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 021e3eb4f..62585d696 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,6 +169,7 @@ jobs: VERSION: ${{ needs.publish.outputs.new_release_version }} run: | npx nx run-many --target=worker-container-image --configuration=release --parallel=3 + npx nx run-many --target=egress-guard-container-image --configuration=release --parallel=3 - name: Generate container image SBOMs uses: ./.github/actions/generate-container-image-sboms diff --git a/apps/agenstra/backend-agent-controller/.start-containers.env.example b/apps/agenstra/backend-agent-controller/.start-containers.env.example index 34f879570..e3af87ab5 100644 --- a/apps/agenstra/backend-agent-controller/.start-containers.env.example +++ b/apps/agenstra/backend-agent-controller/.start-containers.env.example @@ -123,8 +123,8 @@ KNOWLEDGE_EMBEDDINGS_REINDEX_INTERVAL_MS=3600000 CONTEXT_IMPORT_SCHEDULER_INTERVAL_MS=120000 CONTEXT_IMPORT_SCHEDULER_CONFIG_BATCH=3 CONTEXT_IMPORT_ITEM_BUDGET=25 -FILTER_RULES_SYNC_INTERVAL_MS=30000 -FILTER_RULES_SYNC_BATCH_SIZE=10 +EGRESS_EVENTS_INGEST_INTERVAL_MS=30000 +EGRESS_EVENTS_INGEST_BATCH_SIZE=10 AUTONOMOUS_TICKET_SCHEDULER_INTERVAL_MS=60000 AUTONOMOUS_TICKET_SCHEDULER_BATCH_SIZE=5 DYNAMIC_PROVISIONING_PROVIDERS= diff --git a/apps/agenstra/backend-agent-controller/docker-compose.yaml b/apps/agenstra/backend-agent-controller/docker-compose.yaml index 9e3c41ff3..c6b1a6c87 100644 --- a/apps/agenstra/backend-agent-controller/docker-compose.yaml +++ b/apps/agenstra/backend-agent-controller/docker-compose.yaml @@ -14,8 +14,8 @@ x-backend-agent-controller-environment: &backend-agent-controller-environment CONTEXT_IMPORT_SCHEDULER_INTERVAL_MS: ${CONTEXT_IMPORT_SCHEDULER_INTERVAL_MS:-120000} CONTEXT_IMPORT_SCHEDULER_CONFIG_BATCH: ${CONTEXT_IMPORT_SCHEDULER_CONFIG_BATCH:-3} CONTEXT_IMPORT_ITEM_BUDGET: ${CONTEXT_IMPORT_ITEM_BUDGET:-25} - FILTER_RULES_SYNC_INTERVAL_MS: ${FILTER_RULES_SYNC_INTERVAL_MS:-30000} - FILTER_RULES_SYNC_BATCH_SIZE: ${FILTER_RULES_SYNC_BATCH_SIZE:-10} + EGRESS_EVENTS_INGEST_INTERVAL_MS: ${EGRESS_EVENTS_INGEST_INTERVAL_MS:-30000} + EGRESS_EVENTS_INGEST_BATCH_SIZE: ${EGRESS_EVENTS_INGEST_BATCH_SIZE:-10} AUTONOMOUS_TICKET_SCHEDULER_INTERVAL_MS: ${AUTONOMOUS_TICKET_SCHEDULER_INTERVAL_MS:-60000} AUTONOMOUS_TICKET_SCHEDULER_BATCH_SIZE: ${AUTONOMOUS_TICKET_SCHEDULER_BATCH_SIZE:-5} DB_HOST: ${DB_HOST:-postgres} diff --git a/apps/agenstra/backend-agent-controller/src/migrations/1792000000000_LayeredSecurityPoliciesAndEgressEvents.ts b/apps/agenstra/backend-agent-controller/src/migrations/1792000000000_LayeredSecurityPoliciesAndEgressEvents.ts new file mode 100644 index 000000000..5c005f565 --- /dev/null +++ b/apps/agenstra/backend-agent-controller/src/migrations/1792000000000_LayeredSecurityPoliciesAndEgressEvents.ts @@ -0,0 +1,495 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey, TableIndex } from 'typeorm'; + +const MAX_RULES_PER_LAYER = 200; +const MAX_PATTERN_LENGTH = 2000; +const MAX_REPLACEMENT_LENGTH = 2000; + +interface LegacyRuleRow { + id: string; + pattern: string; + regex_flags: string | null; + direction: string; + filter_type: string; + replace_content: string | null; + priority: number; + enabled: boolean; + is_global: boolean; +} + +interface MessageFilterRule { + name: string; + pattern: string; + flags: string; + direction: string; + action: 'drop' | 'replace' | 'flag'; + replacement?: string; + enabled: boolean; +} + +/** Index of the first body character of a group opened at `index` (skips `?:`, `?=`, `?`, …). */ +function groupBodyStart(pattern: string, index: number): number { + if (pattern[index + 1] !== '?') { + return index + 1; + } + + const next = pattern[index + 2]; + + if (next === ':' || next === '=' || next === '!') { + return index + 3; + } + + if (next === '<') { + const after = pattern[index + 3]; + + if (after === '=' || after === '!') { + return index + 4; + } + + const close = pattern.indexOf('>', index + 3); + + return close === -1 ? index + 3 : close + 1; + } + + return index + 2; +} + +function hasAmbiguousAlternation(pattern: string, branchStarts: readonly number[]): boolean { + if (branchStarts.length < 2) { + return false; + } + + const seen = new Set(); + + for (const start of branchStarts) { + const char = pattern[start]; + + if (char === undefined || '|)\\[(.^$*+?{'.includes(char)) { + return true; + } + + const key = char.toLowerCase(); + + if (seen.has(key)) { + return true; + } + + seen.add(key); + } + + return false; +} + +/** + * Same heuristic as the shared util (`hasNestedQuantifier`, frozen copy for this migration): + * rejected rules would block later saves of the layer. + */ +function hasNestedQuantifier(pattern: string): boolean { + const stack: Array<{ quantified: boolean; branchStarts: number[] }> = []; + let escaped = false; + let inClass = false; + + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (inClass) { + if (char === ']') { + inClass = false; + } + + continue; + } + + if (char === '[') { + inClass = true; + continue; + } + + if (char === '(') { + stack.push({ quantified: false, branchStarts: [groupBodyStart(pattern, index)] }); + continue; + } + + if (char === '|') { + stack[stack.length - 1]?.branchStarts.push(index + 1); + continue; + } + + if (char === ')') { + const group = stack.pop(); + const next = pattern[index + 1]; + const groupQuantified = next === '+' || next === '*' || next === '{' || next === '?'; + + if (group?.quantified && groupQuantified && next !== '?') { + return true; + } + + if (group && groupQuantified && next !== '?' && hasAmbiguousAlternation(pattern, group.branchStarts)) { + return true; + } + + if ((group?.quantified || groupQuantified) && stack.length > 0) { + stack[stack.length - 1].quantified = true; + } + + continue; + } + + if ((char === '+' || char === '*' || char === '{') && stack.length > 0) { + stack[stack.length - 1].quantified = true; + } + } + + return false; +} + +function normalizeFlags(raw: string | null): string | null { + const allowed = new Set(['g', 'i', 'm', 's', 'u', 'y', 'd']); + let out = ''; + + for (const char of (raw ?? 'g').trim()) { + if (!allowed.has(char)) { + return null; + } + + if (!out.includes(char)) { + out += char; + } + } + + return out; +} + +function toMessageFilterRule(row: LegacyRuleRow, index: number): MessageFilterRule | null { + const flags = normalizeFlags(row.regex_flags); + + if (!flags || !row.pattern || row.pattern.length > MAX_PATTERN_LENGTH || hasNestedQuantifier(row.pattern)) { + return null; + } + + try { + new RegExp(row.pattern, flags); + } catch { + return null; + } + + const direction = ['incoming', 'outgoing', 'bidirectional'].includes(row.direction) ? row.direction : 'bidirectional'; + const action = row.filter_type === 'drop' ? 'drop' : row.filter_type === 'filter' ? 'replace' : 'flag'; + + return { + name: `Migrated rule ${index + 1} (${row.id.slice(0, 8)})`, + pattern: row.pattern, + flags, + direction, + action, + ...(action === 'replace' ? { replacement: (row.replace_content ?? '').slice(0, MAX_REPLACEMENT_LENGTH) } : {}), + enabled: row.enabled !== false, + }; +} + +function mergeRules(config: unknown, rules: MessageFilterRule[]): Record { + const base = + config && typeof config === 'object' && !Array.isArray(config) ? { ...(config as Record) } : {}; + const existing = Array.isArray(base['message_filters']) ? (base['message_filters'] as unknown[]) : []; + + base['message_filters'] = [...existing, ...rules].slice(0, MAX_RULES_PER_LAYER); + + return base; +} + +/** + * Layered platform security policies (agent configuration → Security): + * - `statistics_egress_events` / `egress_event_ingest_cursors`: egress guard decisions ingested for /audit + * - `opencode_config_sync_targets.applied_egress_revision`: egress policy change notifications + * - Legacy admin regex filter rules are moved into the layered configuration (`message_filters` on the + * global layer for global rules, on each workspace layer for workspace-scoped rules), then their tables + * (rules, workspace links, manager sync targets) are dropped. + * - Webhook subscriptions to `filter_rule.triggered` move to `message_filter.triggered`; the removed + * `filter_rule.created|updated|deleted` events are unsubscribed (configuration changes now publish + * `agent_config.updated`). + */ +export class LayeredSecurityPoliciesAndEgressEvents1792000000000 implements MigrationInterface { + name = 'LayeredSecurityPoliciesAndEgressEvents1792000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'statistics_egress_events', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'statistics_agent_id', type: 'uuid', isNullable: true }, + { name: 'statistics_client_id', type: 'uuid', isNullable: false }, + { name: 'source_seq', type: 'bigint', isNullable: false }, + { name: 'kind', type: 'varchar', length: '16', isNullable: false }, + { name: 'decision', type: 'varchar', length: '16', isNullable: false }, + { name: 'host', type: 'varchar', length: '255', isNullable: true }, + { name: 'rule', type: 'varchar', length: '32', isNullable: true }, + { name: 'matched_entry', type: 'varchar', length: '255', isNullable: true }, + { name: 'matched_source', type: 'varchar', length: '16', isNullable: true }, + { name: 'method', type: 'varchar', length: '32', isNullable: true }, + { name: 'http_status', type: 'int', isNullable: true }, + { name: 'sample_url', type: 'text', isNullable: true }, + { name: 'count', type: 'int', isNullable: false, default: 1 }, + { name: 'bytes_in', type: 'bigint', isNullable: false, default: 0 }, + { name: 'bytes_out', type: 'bigint', isNullable: false, default: 0 }, + { name: 'mode', type: 'varchar', length: '16', isNullable: true }, + { name: 'policy_revision', type: 'varchar', length: '64', isNullable: true }, + { name: 'message', type: 'text', isNullable: true }, + { name: 'first_seen_at', type: 'timestamp', isNullable: false }, + { name: 'occurred_at', type: 'timestamp', isNullable: false }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'statistics_egress_events', + new TableForeignKey({ + columnNames: ['statistics_agent_id'], + referencedTableName: 'statistics_agents', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + await queryRunner.createForeignKey( + 'statistics_egress_events', + new TableForeignKey({ + columnNames: ['statistics_client_id'], + referencedTableName: 'statistics_clients', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + for (const index of [ + new TableIndex({ + name: 'IDX_statistics_egress_events_client_occurred_at', + columnNames: ['statistics_client_id', 'occurred_at'], + }), + new TableIndex({ + name: 'IDX_statistics_egress_events_agent_occurred_at', + columnNames: ['statistics_agent_id', 'occurred_at'], + }), + new TableIndex({ + name: 'IDX_statistics_egress_events_decision_occurred_at', + columnNames: ['decision', 'occurred_at'], + }), + new TableIndex({ + name: 'UQ_statistics_egress_events_source', + columnNames: ['statistics_client_id', 'source_seq'], + isUnique: true, + }), + ]) { + await queryRunner.createIndex('statistics_egress_events', index); + } + + await queryRunner.createTable( + new Table({ + name: 'egress_event_ingest_cursors', + columns: [ + { name: 'client_id', type: 'uuid', isPrimary: true }, + { name: 'last_seq', type: 'bigint', isNullable: false, default: 0 }, + { name: 'last_error', type: 'text', isNullable: true }, + { name: 'last_ingested_at', type: 'timestamp', isNullable: true }, + { name: 'updated_at', type: 'timestamp', isNullable: false, default: 'CURRENT_TIMESTAMP' }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'egress_event_ingest_cursors', + new TableForeignKey({ + columnNames: ['client_id'], + referencedTableName: 'clients', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.addColumn( + 'opencode_config_sync_targets', + new TableColumn({ name: 'applied_egress_revision', type: 'varchar', length: '64', isNullable: true }), + ); + + await this.migrateLegacyFilterRules(queryRunner); + + await queryRunner.query(` + UPDATE "webhook_endpoints" + SET "subscribed_events" = COALESCE( + ( + SELECT jsonb_agg(DISTINCT CASE WHEN value = 'filter_rule.triggered' THEN 'message_filter.triggered' ELSE value END) + FROM jsonb_array_elements_text("subscribed_events") AS value + WHERE value NOT IN ('filter_rule.created', 'filter_rule.updated', 'filter_rule.deleted') + ), + '[]'::jsonb + ) + WHERE "subscribed_events" ?| ARRAY['filter_rule.triggered', 'filter_rule.created', 'filter_rule.updated', 'filter_rule.deleted'] + `); + + await queryRunner.query('DROP TABLE IF EXISTS "agent_console_regex_filter_rule_sync_targets"'); + await queryRunner.query('DROP TABLE IF EXISTS "agent_console_regex_filter_rule_clients"'); + await queryRunner.query('DROP TABLE IF EXISTS "agent_console_regex_filter_rules"'); + } + + private async migrateLegacyFilterRules(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('agent_console_regex_filter_rules'))) { + return; + } + + const rules = (await queryRunner.query( + `SELECT "id", "pattern", "regex_flags", "direction", "filter_type", "replace_content", "priority", "enabled", "is_global" + FROM "agent_console_regex_filter_rules" + ORDER BY "priority" ASC, "created_at" ASC`, + )) as LegacyRuleRow[]; + + if (rules.length === 0) { + return; + } + + const links = (await queryRunner.query( + 'SELECT "rule_id", "client_id" FROM "agent_console_regex_filter_rule_clients"', + )) as Array<{ rule_id: string; client_id: string }>; + const globalRules: MessageFilterRule[] = []; + const byClient = new Map(); + const skipped: string[] = []; + + rules.forEach((row, index) => { + const rule = toMessageFilterRule(row, index); + + if (!rule) { + skipped.push(`${row.id} (${row.filter_type}, pattern ${JSON.stringify(row.pattern.slice(0, 200))})`); + + return; + } + + if (row.is_global) { + globalRules.push(rule); + + return; + } + + for (const link of links.filter((entry) => entry.rule_id === row.id)) { + byClient.set(link.client_id, [...(byClient.get(link.client_id) ?? []), rule]); + } + }); + + if (globalRules.length > 0) { + const rows = (await queryRunner.query( + 'SELECT "id", "config" FROM "global_opencode_config" ORDER BY "updated_at" DESC LIMIT 1', + )) as Array<{ id: string; config: unknown }>; + + if (rows[0]) { + await queryRunner.query( + 'UPDATE "global_opencode_config" SET "config" = $1, "updated_at" = now() WHERE "id" = $2', + [JSON.stringify(mergeRules(rows[0].config, globalRules)), rows[0].id], + ); + } else { + await queryRunner.query('INSERT INTO "global_opencode_config" ("config") VALUES ($1)', [ + JSON.stringify(mergeRules({}, globalRules)), + ]); + } + } + + for (const [clientId, clientRules] of byClient) { + const rows = (await queryRunner.query( + 'SELECT "id", "config" FROM "client_opencode_config" WHERE "client_id" = $1', + [clientId], + )) as Array<{ id: string; config: unknown }>; + + if (rows[0]) { + await queryRunner.query( + 'UPDATE "client_opencode_config" SET "config" = $1, "updated_at" = now() WHERE "id" = $2', + [JSON.stringify(mergeRules(rows[0].config, clientRules)), rows[0].id], + ); + } else { + await queryRunner.query('INSERT INTO "client_opencode_config" ("client_id", "config") VALUES ($1, $2)', [ + clientId, + JSON.stringify(mergeRules({}, clientRules)), + ]); + } + } + + // eslint-disable-next-line no-console + console.warn( + '[migration] Legacy filter rules moved to the layered "Message filters" configuration. Semantics changed: ' + + 'the most restrictive outcome now wins (drop > replace > flag) instead of the first matching rule by priority.', + ); + + if (skipped.length > 0) { + // eslint-disable-next-line no-console + console.warn( + `[migration] ${skipped.length} legacy filter rule(s) were not migrated (invalid regex, unsupported flags or ` + + `catastrophic-backtracking risk); recreate them manually if still needed:\n ${skipped.join('\n ')}`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Message filters stay in the layered configuration; only the (empty) legacy tables are restored. + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "agent_console_regex_filter_rules" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + "pattern" text NOT NULL, + "regex_flags" varchar(16) NOT NULL DEFAULT 'g', + "direction" varchar(32) NOT NULL, + "filter_type" varchar(16) NOT NULL, + "replace_content" text NULL, + "priority" int NOT NULL DEFAULT 0, + "enabled" boolean NOT NULL DEFAULT true, + "is_global" boolean NOT NULL DEFAULT false, + "created_at" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "agent_console_regex_filter_rule_clients" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + "rule_id" uuid NOT NULL REFERENCES "agent_console_regex_filter_rules"("id") ON DELETE CASCADE, + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + CONSTRAINT "UQ_console_regex_rule_client" UNIQUE ("rule_id", "client_id") + ) + `); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "agent_console_regex_filter_rule_sync_targets" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + "rule_id" uuid NOT NULL REFERENCES "agent_console_regex_filter_rules"("id") ON DELETE CASCADE, + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + "manager_rule_id" uuid NULL, + "desired_on_manager" boolean NOT NULL DEFAULT true, + "sync_status" varchar(16) NOT NULL DEFAULT 'pending', + "last_error" text NULL, + "last_synced_at" timestamp NULL, + "updated_at" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "UQ_console_regex_sync_rule_client" UNIQUE ("rule_id", "client_id") + ) + `); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_console_regex_sync_status" ON "agent_console_regex_filter_rule_sync_targets" ("sync_status", "desired_on_manager")', + ); + await queryRunner.query(` + UPDATE "webhook_endpoints" + SET "subscribed_events" = ( + SELECT jsonb_agg(CASE WHEN value = 'message_filter.triggered' THEN 'filter_rule.triggered' ELSE value END) + FROM jsonb_array_elements_text("subscribed_events") AS value + ) + WHERE "subscribed_events" ? 'message_filter.triggered' + `); + await queryRunner.dropColumn('opencode_config_sync_targets', 'applied_egress_revision'); + await queryRunner.dropTable('egress_event_ingest_cursors', true, true, true); + await queryRunner.dropTable('statistics_egress_events', true, true, true); + } +} diff --git a/apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.spec.ts b/apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.spec.ts new file mode 100644 index 000000000..01f9f2315 --- /dev/null +++ b/apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.spec.ts @@ -0,0 +1,30 @@ +import { ControllerQueueRegistrarService } from './controller-queue-registrar.service'; +import { RETIRED_CONTROLLER_REPEATABLE_JOBS } from './job-registry'; + +describe('ControllerQueueRegistrarService', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + }); + + it('removes retired repeatables before registering active ones', async () => { + process.env = { ...originalEnv, QUEUE_ROLE: 'scheduler' }; + + const retired = RETIRED_CONTROLLER_REPEATABLE_JOBS[0]; + const removeRepeatableByKey = jest.fn().mockResolvedValue(true); + const queue = { + getRepeatableJobs: jest + .fn() + .mockResolvedValue([{ name: retired.name, id: retired.coordinatorJobId, key: 'repeat:old' }]), + removeRepeatableByKey, + add: jest.fn().mockResolvedValue({}), + }; + const service = new ControllerQueueRegistrarService(queue as never); + + await service.onModuleInit(); + + expect(removeRepeatableByKey).toHaveBeenCalledWith('repeat:old'); + expect(queue.add).toHaveBeenCalled(); + }); +}); diff --git a/apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.ts b/apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.ts index da8f74b62..2aee565c1 100644 --- a/apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.ts +++ b/apps/agenstra/backend-agent-controller/src/queue/controller-queue-registrar.service.ts @@ -1,9 +1,13 @@ -import { registerRepeatableCoordinatorJob, shouldRegisterRepeatableJobs } from '@forepath/shared/backend'; +import { + registerRepeatableCoordinatorJob, + removeRepeatableCoordinatorJob, + shouldRegisterRepeatableJobs, +} from '@forepath/shared/backend'; import { InjectQueue } from '@nestjs/bullmq'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Queue } from 'bullmq'; -import { CONTROLLER_QUEUE_NAME, getControllerRepeatableJobs } from './job-registry'; +import { CONTROLLER_QUEUE_NAME, RETIRED_CONTROLLER_REPEATABLE_JOBS, getControllerRepeatableJobs } from './job-registry'; @Injectable() export class ControllerQueueRegistrarService implements OnModuleInit { @@ -16,6 +20,10 @@ export class ControllerQueueRegistrarService implements OnModuleInit { return; } + for (const retired of RETIRED_CONTROLLER_REPEATABLE_JOBS) { + await removeRepeatableCoordinatorJob(this.controllerQueue, retired.name, retired.coordinatorJobId); + } + for (const definition of getControllerRepeatableJobs()) { if (definition.disabled) { continue; diff --git a/apps/agenstra/backend-agent-controller/src/queue/controller-queue.module.ts b/apps/agenstra/backend-agent-controller/src/queue/controller-queue.module.ts index ee7e17480..2df4803e9 100644 --- a/apps/agenstra/backend-agent-controller/src/queue/controller-queue.module.ts +++ b/apps/agenstra/backend-agent-controller/src/queue/controller-queue.module.ts @@ -4,7 +4,6 @@ import { AgenstraUpdatesModule, ClientsModule, ContextImportModule, - FilterRulesModule, } from '@forepath/agenstra/backend/feature-agent-controller'; import { SharedQueueModule, shouldRegisterRepeatableJobs, shouldRunQueueWorkers } from '@forepath/shared/backend'; import { Module, forwardRef } from '@nestjs/common'; @@ -22,7 +21,6 @@ import { ControllerJobsProcessor } from './processors/controller-jobs.processor' AgenstraUpdatesModule, AgenstraSearchModule, forwardRef(() => ClientsModule), - forwardRef(() => FilterRulesModule), forwardRef(() => ContextImportModule), ], providers: [ diff --git a/apps/agenstra/backend-agent-controller/src/queue/job-registry.spec.ts b/apps/agenstra/backend-agent-controller/src/queue/job-registry.spec.ts index 7c5bdbe76..b731eb1af 100644 --- a/apps/agenstra/backend-agent-controller/src/queue/job-registry.spec.ts +++ b/apps/agenstra/backend-agent-controller/src/queue/job-registry.spec.ts @@ -1,16 +1,16 @@ -import { ControllerJobName, getControllerRepeatableJobs } from './job-registry'; +import { ControllerJobName, RETIRED_CONTROLLER_REPEATABLE_JOBS, getControllerRepeatableJobs } from './job-registry'; describe('controller job-registry', () => { it('defines coordinator and unit job names', () => { expect(ControllerJobName.AUTONOMOUS_TICKET_UNIT).toBe('autonomous-ticket.unit'); - expect(ControllerJobName.FILTER_RULES_RECONCILE).toBe('filter-rules-sync.reconcile'); + expect(ControllerJobName.EGRESS_EVENTS_INGEST_UNIT).toBe('egress-events-ingest.unit'); }); it('getControllerRepeatableJobs includes core coordinators', () => { const jobs = getControllerRepeatableJobs(); const names = jobs.map((job) => job.name); - expect(names).toContain(ControllerJobName.FILTER_RULES_SYNC_COORDINATOR); + expect(names).toContain(ControllerJobName.EGRESS_EVENTS_INGEST_COORDINATOR); expect(names).toContain(ControllerJobName.WEBHOOK_DELIVERY_RETENTION_COORDINATOR); }); @@ -64,10 +64,33 @@ describe('controller job-registry', () => { expect(names).toContain(ControllerJobName.OPENCODE_LAYER_FILES_SYNC_COORDINATOR); }); + it('getControllerRepeatableJobs skips egress ingestion when disabled', () => { + process.env.EGRESS_EVENTS_INGEST_INTERVAL_MS = '0'; + const names = getControllerRepeatableJobs().map((job) => job.name); + + expect(names).not.toContain(ControllerJobName.EGRESS_EVENTS_INGEST_COORDINATOR); + delete process.env.EGRESS_EVENTS_INGEST_INTERVAL_MS; + }); + it('coordinator job ids are valid for BullMQ (no colons)', () => { for (const job of getControllerRepeatableJobs()) { expect(job.coordinatorJobId).not.toContain(':'); expect(job.coordinatorJobId.startsWith('coordinator.')).toBe(true); } }); + + it('lists retired repeatables that no active job reuses', () => { + const active = getControllerRepeatableJobs(); + + expect(RETIRED_CONTROLLER_REPEATABLE_JOBS.map((job) => job.name)).toEqual([ + 'filter-rules-sync.coordinator', + 'filter-rules-sync.reconcile', + ]); + + for (const retired of RETIRED_CONTROLLER_REPEATABLE_JOBS) { + expect(active.some((job) => job.name === retired.name || job.coordinatorJobId === retired.coordinatorJobId)).toBe( + false, + ); + } + }); }); diff --git a/apps/agenstra/backend-agent-controller/src/queue/job-registry.ts b/apps/agenstra/backend-agent-controller/src/queue/job-registry.ts index a31ba15e4..68fcc69a0 100644 --- a/apps/agenstra/backend-agent-controller/src/queue/job-registry.ts +++ b/apps/agenstra/backend-agent-controller/src/queue/job-registry.ts @@ -23,9 +23,8 @@ export const ControllerJobName = { CONTEXT_IMPORT_UNIT: 'context-import.unit', KNOWLEDGE_EMBEDDING_COORDINATOR: 'knowledge-embedding.coordinator', KNOWLEDGE_EMBEDDING_UNIT: 'knowledge-embedding.unit', - FILTER_RULES_SYNC_COORDINATOR: 'filter-rules-sync.coordinator', - FILTER_RULES_SYNC_UNIT: 'filter-rules-sync.unit', - FILTER_RULES_RECONCILE: 'filter-rules-sync.reconcile', + EGRESS_EVENTS_INGEST_COORDINATOR: 'egress-events-ingest.coordinator', + EGRESS_EVENTS_INGEST_UNIT: 'egress-events-ingest.unit', OPENCODE_CONFIG_SYNC_COORDINATOR: 'opencode-config-sync.coordinator', OPENCODE_CONFIG_SYNC_UNIT: 'opencode-config-sync.unit', OPENCODE_LAYER_FILES_SYNC_COORDINATOR: 'opencode-layer-files-sync.coordinator', @@ -87,21 +86,20 @@ export function parseSearchReindexIntervalMs(envKey: string, fallbackMs: number) return amount * (multipliers[unit] ?? 1); } +/** + * Repeatable jobs of removed features. Their Redis repeatables are deleted on startup so they stop + * firing (e.g. the global regex filter rule sync, replaced by layered message filters). + */ +export const RETIRED_CONTROLLER_REPEATABLE_JOBS: ReadonlyArray<{ name: string; coordinatorJobId: string }> = [ + { name: 'filter-rules-sync.coordinator', coordinatorJobId: buildCoordinatorJobId('filter-rules-sync') }, + { name: 'filter-rules-sync.reconcile', coordinatorJobId: buildCoordinatorJobId('filter-rules-reconcile') }, +]; + export function getControllerRepeatableJobs(): ControllerRepeatableJobDefinition[] { const knowledgeInterval = parseIntervalMs('KNOWLEDGE_EMBEDDINGS_REINDEX_INTERVAL_MS', 3_600_000); const contextImportInterval = parseIntervalMs('CONTEXT_IMPORT_SCHEDULER_INTERVAL_MS', 120_000); const searchReindexInterval = parseSearchReindexIntervalMs('SEARCH_REINDEX_INTERVAL', 900_000); const jobs: ControllerRepeatableJobDefinition[] = [ - { - name: ControllerJobName.FILTER_RULES_SYNC_COORDINATOR, - coordinatorJobId: buildCoordinatorJobId('filter-rules-sync'), - everyMs: parseIntervalMs('FILTER_RULES_SYNC_INTERVAL_MS', 30_000), - }, - { - name: ControllerJobName.FILTER_RULES_RECONCILE, - coordinatorJobId: buildCoordinatorJobId('filter-rules-reconcile'), - everyMs: parseIntervalMs('FILTER_RULES_SYNC_INTERVAL_MS', 30_000), - }, { name: ControllerJobName.OPENCODE_CONFIG_SYNC_COORDINATOR, coordinatorJobId: buildCoordinatorJobId('opencode-config-sync'), @@ -124,6 +122,16 @@ export function getControllerRepeatableJobs(): ControllerRepeatableJobDefinition }, ]; + const egressIngestInterval = parseIntervalMs('EGRESS_EVENTS_INGEST_INTERVAL_MS', 30_000); + + if (egressIngestInterval > 0) { + jobs.push({ + name: ControllerJobName.EGRESS_EVENTS_INGEST_COORDINATOR, + coordinatorJobId: buildCoordinatorJobId('egress-events-ingest'), + everyMs: egressIngestInterval, + }); + } + if (contextImportInterval > 0) { jobs.push({ name: ControllerJobName.CONTEXT_IMPORT_COORDINATOR, @@ -192,8 +200,8 @@ export function getContextImportConfigBatch(): number { return parseInt(process.env.CONTEXT_IMPORT_SCHEDULER_CONFIG_BATCH ?? '3', 10); } -export function getFilterRulesSyncBatchSize(): number { - return parseInt(process.env.FILTER_RULES_SYNC_BATCH_SIZE ?? '10', 10); +export function getEgressEventsIngestBatchSize(): number { + return parseInt(process.env.EGRESS_EVENTS_INGEST_BATCH_SIZE ?? '10', 10); } export function getOpencodeConfigSyncBatchSize(): number { diff --git a/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts b/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts index d77b71bed..cd6a90185 100644 --- a/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts +++ b/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts @@ -4,9 +4,8 @@ import { AgenstraSearchIndexService, AutonomousRunOrchestratorService, ContextImportOrchestratorService, + EgressEventsIngestService, ExternalImportConfigService, - FilterRulesService, - FilterRulesSyncService, isAgenstraSearchEntityType, KnowledgeEmbeddingIndexService, OpencodeConfigSyncTargetsService, @@ -39,7 +38,7 @@ import { getAutonomousTicketBatchSize, getContextImportConfigBatch, getContextImportItemBudget, - getFilterRulesSyncBatchSize, + getEgressEventsIngestBatchSize, getKnowledgeEmbeddingPageBatchSize, getOpencodeConfigSyncBatchSize, getOpencodeLayerFilesSyncBatchSize, @@ -58,8 +57,7 @@ export class ControllerJobsProcessor extends WorkerHost { private readonly contextImportOrchestrator: ContextImportOrchestratorService, private readonly contextImportConfigService: ExternalImportConfigService, private readonly knowledgeEmbeddingIndex: KnowledgeEmbeddingIndexService, - private readonly filterRulesSync: FilterRulesSyncService, - private readonly filterRulesService: FilterRulesService, + private readonly egressEventsIngest: EgressEventsIngestService, private readonly autonomousOrchestrator: AutonomousRunOrchestratorService, private readonly webhookDeliveryService: WebhookDeliveryService, private readonly webhookDeliveryRetentionService: WebhookDeliveryRetentionService, @@ -97,14 +95,11 @@ export class ControllerJobsProcessor extends WorkerHost { break; } - case ControllerJobName.FILTER_RULES_SYNC_COORDINATOR: - await this.runFilterRulesSyncCoordinator(); + case ControllerJobName.EGRESS_EVENTS_INGEST_COORDINATOR: + await this.runEgressEventsIngestCoordinator(); break; - case ControllerJobName.FILTER_RULES_SYNC_UNIT: - await this.filterRulesSync.processTargetById((job.data as { targetId: string }).targetId); - break; - case ControllerJobName.FILTER_RULES_RECONCILE: - await this.filterRulesService.reconcileAllGlobalRules(); + case ControllerJobName.EGRESS_EVENTS_INGEST_UNIT: + await this.egressEventsIngest.ingestClient((job.data as { clientId: string }).clientId); break; case ControllerJobName.OPENCODE_CONFIG_SYNC_COORDINATOR: await this.runOpencodeConfigSyncCoordinator(); @@ -316,16 +311,16 @@ export class ControllerJobsProcessor extends WorkerHost { } } - private async runFilterRulesSyncCoordinator(): Promise { - const targetIds = await this.filterRulesSync.findPendingTargetIds(getFilterRulesSyncBatchSize()); + private async runEgressEventsIngestCoordinator(): Promise { + const clientIds = await this.egressEventsIngest.findClientIdsDue(getEgressEventsIngestBatchSize()); - for (const targetId of targetIds) { + for (const clientId of clientIds) { await enqueueUnitJob({ queue: this.controllerQueue, - jobName: ControllerJobName.FILTER_RULES_SYNC_UNIT, - payload: { targetId }, - jobIdNamespace: 'filter-rules:target', - jobIdParts: [targetId], + jobName: ControllerJobName.EGRESS_EVENTS_INGEST_UNIT, + payload: { clientId }, + jobIdNamespace: 'egress-events:client', + jobIdParts: [clientId], }); } } diff --git a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts index fc6595283..ad2b71c70 100644 --- a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts +++ b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts @@ -1,7 +1,5 @@ import { - AgentConsoleRegexFilterRuleClientEntity, - AgentConsoleRegexFilterRuleEntity, - AgentConsoleRegexFilterRuleSyncTargetEntity, + EgressEventIngestCursorEntity, ProvisioningReferenceEntity, StatisticsAgentEntity, StatisticsChatFilterDropEntity, @@ -9,6 +7,7 @@ import { StatisticsChatIoEntity, StatisticsClientEntity, StatisticsClientUserEntity, + StatisticsEgressEventEntity, StatisticsEntityEventEntity, StatisticsProvisioningReferenceEntity, StatisticsUserEntity, @@ -96,7 +95,9 @@ export const typeormConfig: DataSourceOptions = { StatisticsChatIoEntity, StatisticsChatFilterDropEntity, StatisticsChatFilterFlagEntity, + StatisticsEgressEventEntity, StatisticsEntityEventEntity, + EgressEventIngestCursorEntity, TicketEntity, TicketCommentEntity, TicketActivityEntity, @@ -115,9 +116,6 @@ export const typeormConfig: DataSourceOptions = { KnowledgeNodeEmbeddingEntity, KnowledgePageActivityEntity, KnowledgeRelationEntity, - AgentConsoleRegexFilterRuleEntity, - AgentConsoleRegexFilterRuleClientEntity, - AgentConsoleRegexFilterRuleSyncTargetEntity, UserChatSessionReadStateEntity, UserEnvironmentReadStateEntity, GlobalOpencodeConfigEntity, diff --git a/apps/agenstra/backend-agent-manager/.start-containers.env.example b/apps/agenstra/backend-agent-manager/.start-containers.env.example index e5d821796..f823f4310 100644 --- a/apps/agenstra/backend-agent-manager/.start-containers.env.example +++ b/apps/agenstra/backend-agent-manager/.start-containers.env.example @@ -27,6 +27,15 @@ ENCRYPTION_KEY=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= AGENT_DEFAULT_IMAGE=registry.forenet.internal/forepath/agenstra-manager-worker:test OPENCODE_AGENT_DOCKER_IMAGE=registry.forenet.internal/forepath/agenstra-manager-worker:test +# Egress guard sidecar image (per-agent web traffic filtering). `start-containers` builds the local +# test image (`egress-guard-container-image:test`) under the internal-registry tag below. +AGENT_EGRESS_GUARD_IMAGE=registry.forenet.internal/forepath/agenstra-manager-egress-guard:test +AGENT_EGRESS_GUARD_ENABLED=true +AGENT_EGRESS_GUARD_MEMORY_MB=256 +AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED=true +AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS=15000 +AGENT_EGRESS_EVENTS_RETENTION_HOURS=72 + # CORS (HTTP uses CORS_ORIGIN; Socket.IO/VNC resolve WEBSOCKET_CORS_ORIGIN → CORS_ORIGIN → *) # Example: CORS_ORIGIN=http://localhost:4100 CORS_ORIGIN=http://localhost:4100 diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.egress-guard b/apps/agenstra/backend-agent-manager/Dockerfile.egress-guard new file mode 100644 index 000000000..e7e78cc84 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/Dockerfile.egress-guard @@ -0,0 +1,51 @@ +# ----------------------------------------------------------------------------- +# Image: agenstra-manager-egress-guard +# Project: backend-agent-manager — Nx target `egress-guard-container-image` +# Registry: ghcr.io/forepath/agenstra-manager-egress-guard +# Base: alpine:3.24 (Squid 7.x, dnsmasq, iptables-nft) +# User: root supervisor; Squid runs as egressproxy (10901), dnsmasq as egressdns (10902) +# Exposes: — (owns the agent's network namespace; agent ports 4096 / 6080 are published here) +# Build: npx nx run agenstra-backend-agent-manager:egress-guard-container-image +# Run: created by the agent manager per agent (`agenstra-egress-`) with CAP_NET_ADMIN +# Notes: Per-agent egress filtering sidecar. The agent container joins this container's network +# namespace (`NetworkMode: container:`), so all agent egress traverses the guard's +# netfilter rules: DNS → dnsmasq policy proxy, TCP/80 → Squid intercept, TCP/443 → Squid +# TLS peek / splice / terminate (no decryption, no trusted CA), explicit proxy on 3128, +# everything else default-deny. Fail-closed lockdown rules are installed before any +# policy is applied; invalid policy bundles never replace the last known good one. +# ----------------------------------------------------------------------------- +# +FROM docker.io/alpine:3.24 + +ARG VERSION=0.0.0 +ENV VERSION=${VERSION} + +RUN apk add --no-cache \ + bash \ + coreutils \ + dnsmasq \ + ip6tables \ + iptables \ + openssl \ + 'squid~7' \ + tini \ + && addgroup -S -g 10901 egressproxy \ + && adduser -S -D -H -h /var/empty -s /sbin/nologin -u 10901 -G egressproxy egressproxy \ + && addgroup -S -g 10902 egressdns \ + && adduser -S -D -H -h /var/empty -s /sbin/nologin -u 10902 -G egressdns egressdns \ + && install -d -m 0750 -o root -g egressproxy /var/lib/agenstra-egress \ + && install -d -m 0755 /run/agenstra-egress /etc/agenstra-egress \ + && install -d -m 0750 -o egressproxy -g egressproxy /var/log/agenstra-egress \ + && squid -v | head -n 1 + +COPY egress-guard/default/ /etc/agenstra-egress/default/ +COPY egress-guard/entrypoint.sh /usr/local/bin/agenstra-egress-entrypoint +COPY egress-guard/agenstra-egress /usr/local/bin/agenstra-egress + +RUN chmod 0755 /usr/local/bin/agenstra-egress-entrypoint /usr/local/bin/agenstra-egress \ + && chmod 0644 /etc/agenstra-egress/default/* + +LABEL io.agenstra.egress-guard.contract="1" + +STOPSIGNAL SIGTERM +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/agenstra-egress-entrypoint"] diff --git a/apps/agenstra/backend-agent-manager/docker-compose.yaml b/apps/agenstra/backend-agent-manager/docker-compose.yaml index 32458fb25..a9ef480c1 100644 --- a/apps/agenstra/backend-agent-manager/docker-compose.yaml +++ b/apps/agenstra/backend-agent-manager/docker-compose.yaml @@ -73,6 +73,13 @@ services: JWT_SECRET: ${JWT_SECRET:-} # Docker images (AGENT_DEFAULT_IMAGE is set above under Agent configuration) OPENCODE_AGENT_DOCKER_IMAGE: ${OPENCODE_AGENT_DOCKER_IMAGE:-} + # Egress guard sidecars (per-agent web traffic filtering; see docs/agenstra/features/egress-filtering.md) + AGENT_EGRESS_GUARD_ENABLED: ${AGENT_EGRESS_GUARD_ENABLED:-true} + AGENT_EGRESS_GUARD_IMAGE: ${AGENT_EGRESS_GUARD_IMAGE:-ghcr.io/forepath/agenstra-manager-egress-guard:latest} + AGENT_EGRESS_GUARD_MEMORY_MB: ${AGENT_EGRESS_GUARD_MEMORY_MB:-256} + AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED: ${AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED:-true} + AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS: ${AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS:-15000} + AGENT_EGRESS_EVENTS_RETENTION_HOURS: ${AGENT_EGRESS_EVENTS_RETENTION_HOURS:-72} OTEL_ENABLED: ${OTEL_ENABLED:-false} OTEL_USERNAME: ${OTEL_USERNAME:-} OTEL_PASSWORD: ${OTEL_PASSWORD:-} diff --git a/apps/agenstra/backend-agent-manager/egress-guard/agenstra-egress b/apps/agenstra/backend-agent-manager/egress-guard/agenstra-egress new file mode 100644 index 000000000..a115f362e --- /dev/null +++ b/apps/agenstra/backend-agent-manager/egress-guard/agenstra-egress @@ -0,0 +1,250 @@ +#!/bin/bash +# Agenstra egress guard control CLI (invoked by the agent manager via `docker exec`, as root). +# +# agenstra-egress lockdown install fail-closed netfilter rules (loopback + established only) +# agenstra-egress prepare reset the staged policy directory +# agenstra-egress apply validate the staged bundle, activate it atomically, reload services +# agenstra-egress activate (re)apply the active bundle's netfilter rules and reload services +# agenstra-egress status print guard status as JSON +# agenstra-egress counters print netfilter counters of counted categories (iptables-save -c lines) +# +# Exit codes: 0 success, 1 validation / apply failure (last known good policy stays active). +set -euo pipefail + +STATE_DIR=/var/lib/agenstra-egress +STAGED="$STATE_DIR/staged" +ACTIVE="$STATE_DIR/active" +PREVIOUS="$STATE_DIR/previous" +RUN_DIR=/run/agenstra-egress +DEFAULT_DIR=/etc/agenstra-egress/default +RESOLVERS_CHAIN=AGENSTRA_EGRESS_RESOLVERS +REQUIRED_FILES=(squid.conf dnsmasq.conf rules.v4 rules.v6 policy.json allow-domains.txt allow-ips.txt deny-domains.txt deny-ips.txt deny-url-patterns.txt private-ips.txt) + +fail() { + printf '%s\n' "$*" >&2 + exit 1 +} + +ipv6_available() { + if ip6tables -S >/dev/null 2>&1; then + return 0 + fi + + # Without ip6tables, IPv6 must be off in this namespace — otherwise it would be unfiltered. + if [ -e /proc/net/if_inet6 ] && [ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || echo 0)" != "1" ]; then + fail "IPv6 is enabled but ip6tables is unavailable; refusing to leave IPv6 unfiltered" + fi + + return 1 +} + +# Upstream resolvers dnsmasq may reach (IPv4 nameservers from the guard's resolv.conf; loopback +# resolvers such as Docker's embedded DNS are covered by the loopback rule). +render_resolver_rules() { + local ns + + printf ':%s - [0:0]\n' "$RESOLVERS_CHAIN" + + while read -r keyword ns _; do + [ "$keyword" = "nameserver" ] || continue + [[ "$ns" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue + [[ "$ns" == 127.* ]] && continue + printf -- '-A %s -d %s/32 -p udp --dport 53 -j ACCEPT\n' "$RESOLVERS_CHAIN" "$ns" + printf -- '-A %s -d %s/32 -p tcp --dport 53 -j ACCEPT\n' "$RESOLVERS_CHAIN" "$ns" + done < /etc/resolv.conf + + printf -- '-A %s -j RETURN\n' "$RESOLVERS_CHAIN" +} + +ensure_jump() { + local bin="$1" table="$2" parent="$3" chain="$4" + + "$bin" -t "$table" -C "$parent" -j "$chain" 2>/dev/null || "$bin" -t "$table" -I "$parent" 1 -j "$chain" +} + +ensure_jumps() { + ensure_jump iptables nat OUTPUT AGENSTRA_EGRESS_NAT + ensure_jump iptables filter OUTPUT AGENSTRA_EGRESS_OUT + ensure_jump iptables filter INPUT AGENSTRA_EGRESS_IN + + if ipv6_available; then + ensure_jump ip6tables filter OUTPUT AGENSTRA_EGRESS_OUT + fi +} + +cmd_lockdown() { + iptables-restore --noflush < "$check_conf" + + if ! output="$(squid -k parse -f "$check_conf" 2>&1)"; then + rm -f -- "$check_conf" + fail "squid configuration rejected: $(printf '%s' "$output" | grep -E 'FATAL|ERROR' | head -n 3 | tr '\n' ' ')" + fi + + if printf '%s' "$output" | grep -qE 'FATAL|Bungled'; then + rm -f -- "$check_conf" + fail "squid configuration rejected: $(printf '%s' "$output" | grep -E 'FATAL|Bungled' | head -n 3 | tr '\n' ' ')" + fi + + rm -f -- "$check_conf" + + dnsmasq --test --conf-file="$dir/dnsmasq.conf" >/dev/null 2>&1 || fail "dnsmasq configuration rejected" + iptables-restore --test --noflush "$dir/rules.v4" || fail "IPv4 netfilter rules rejected" + + if ipv6_available; then + ip6tables-restore --test --noflush "$dir/rules.v6" || fail "IPv6 netfilter rules rejected" + fi +} + +revision_of() { + sed -n 's/.*"revision":"\([^"]*\)".*/\1/p' "$1/policy.json" 2>/dev/null | head -n 1 +} + +signal_reload_and_wait() { + local revision="$1" + local supervisor_pid + + supervisor_pid="$(cat "$RUN_DIR/supervisor.pid" 2>/dev/null || true)" + [ -n "$supervisor_pid" ] || fail "guard supervisor is not running" + rm -f -- "$RUN_DIR/applied" + kill -HUP "$supervisor_pid" || fail "failed to signal guard supervisor" + + for _ in $(seq 1 80); do + if [ "$(cat "$RUN_DIR/applied" 2>/dev/null || true)" = "$revision" ]; then + return 0 + fi + + sleep 0.25 + done + + fail "guard services did not reload revision $revision in time" +} + +cmd_activate() { + [ -d "$ACTIVE" ] || fail "no active policy" + iptables-restore --noflush "$ACTIVE/rules.v4" + + if ipv6_available; then + ip6tables-restore --noflush "$ACTIVE/rules.v6" + fi + + ensure_jumps + + if [ "${1:-}" != "--no-reload" ]; then + signal_reload_and_wait "$(revision_of "$ACTIVE")" + fi +} + +cmd_apply() { + [ -d "$STAGED" ] || fail "no staged policy (run prepare and upload a bundle first)" + chown -R root:egressproxy "$STAGED" + find "$STAGED" -type f -exec chmod 0640 {} + + chmod 0750 "$STAGED" + validate_dir "$STAGED" + + rm -rf -- "$PREVIOUS" + + if [ -d "$ACTIVE" ]; then + mv -T -- "$ACTIVE" "$PREVIOUS" + fi + + mv -T -- "$STAGED" "$ACTIVE" + + if ! cmd_activate; then + # Roll back to the last known good bundle. + if [ -d "$PREVIOUS" ]; then + rm -rf -- "$ACTIVE" + mv -T -- "$PREVIOUS" "$ACTIVE" + cmd_activate || true + else + cmd_lockdown || true + fi + + fail "failed to activate policy; previous policy restored" + fi +} + +process_running() { + local pid + pid="$(cat "$1" 2>/dev/null || true)" + [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null +} + +cmd_status() { + local revision="" mode="" + local squid=false dnsmasq=false + + if [ -d "$ACTIVE" ]; then + revision="$(revision_of "$ACTIVE")" + mode="$(sed -n 's/.*"mode":"\([^"]*\)".*/\1/p' "$ACTIVE/policy.json" | head -n 1)" + fi + + process_running "$RUN_DIR/squid.pid" && squid=true + process_running "$RUN_DIR/dnsmasq.pid" && dnsmasq=true + + printf '{"revision":"%s","mode":"%s","applied":"%s","squid":%s,"dnsmasq":%s}\n' \ + "$revision" "$mode" "$(cat "$RUN_DIR/applied" 2>/dev/null || true)" "$squid" "$dnsmasq" +} + +cmd_counters() { + iptables-save -c -t filter | grep -F 'agenstra:' || true + + if ipv6_available; then + ip6tables-save -c -t filter | grep -F 'agenstra:' | sed 's/^/v6 /' || true + fi +} + +case "${1:-}" in + lockdown) cmd_lockdown ;; + prepare) cmd_prepare ;; + apply) cmd_apply ;; + activate) shift; cmd_activate "${1:-}" ;; + status) cmd_status ;; + counters) cmd_counters ;; + *) fail "usage: agenstra-egress {lockdown|prepare|apply|activate|status|counters}" ;; +esac diff --git a/apps/agenstra/backend-agent-manager/egress-guard/default/dnsmasq.conf b/apps/agenstra/backend-agent-manager/egress-guard/default/dnsmasq.conf new file mode 100644 index 000000000..b99abe61a --- /dev/null +++ b/apps/agenstra/backend-agent-manager/egress-guard/default/dnsmasq.conf @@ -0,0 +1,10 @@ +# Boot default (no policy applied yet): nothing resolves. +port=5353 +listen-address=127.0.0.1 +bind-interfaces +user=egressdns +group=egressdns +pid-file=/run/agenstra-egress/dnsmasq.pid +log-queries +log-facility=- +address=/#/ diff --git a/apps/agenstra/backend-agent-manager/egress-guard/default/squid.conf b/apps/agenstra/backend-agent-manager/egress-guard/default/squid.conf new file mode 100644 index 000000000..46489c6a0 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/egress-guard/default/squid.conf @@ -0,0 +1,24 @@ +# Boot default (no policy applied yet): listeners exist, every request is denied. +http_port 127.0.0.1:3128 name=explicit +http_port 127.0.0.1:3129 intercept name=http_intercept +https_port 127.0.0.1:3130 intercept ssl-bump name=https_intercept tls-cert=/var/lib/agenstra-egress/bootstrap.pem generate-host-certificates=off +pid_filename /run/agenstra-egress/squid.pid +cache_effective_user egressproxy +cache_effective_group egressproxy +cache deny all +cache_mem 8 MB +cache_log /var/log/agenstra-egress/cache.log +logfile_rotate 1 +coredump_dir /tmp +strip_query_terms on +via off +forwarded_for delete +httpd_suppress_version_string on +shutdown_lifetime 1 seconds +acl step1 at_step SslBump1 +note agenstra_rule guard all +http_access deny all +ssl_bump peek step1 +ssl_bump terminate all +logformat agenstra AGENSTRA_EGRESS %ts.%03tu %Ss %>Hs %rm %#ru %#>rd %#ssl::>sni %st %tr %#{agenstra_rule}note %err_code +access_log stdio:/var/log/agenstra-egress/access.log agenstra diff --git a/apps/agenstra/backend-agent-manager/egress-guard/entrypoint.sh b/apps/agenstra/backend-agent-manager/egress-guard/entrypoint.sh new file mode 100644 index 000000000..94d3f35d9 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/egress-guard/entrypoint.sh @@ -0,0 +1,192 @@ +#!/bin/bash +# Agenstra egress guard supervisor (runs under tini as the guard container's main process). +# +# 1. Installs fail-closed netfilter rules before anything else (no policy → no egress). +# 2. Re-activates the last applied policy bundle after a restart (container FS persists it). +# 3. Supervises Squid and dnsmasq, restarting them when they exit, and streams the Squid access +# log plus dnsmasq query log to stdout for the agent manager's egress events collector. +# 4. Reloads services on SIGHUP (sent by `agenstra-egress apply` after a new bundle is activated). +set -uo pipefail + +STATE_DIR=/var/lib/agenstra-egress +ACTIVE="$STATE_DIR/active" +RUN_DIR=/run/agenstra-egress +LOG_DIR=/var/log/agenstra-egress +DEFAULT_DIR=/etc/agenstra-egress/default +CLI=/usr/local/bin/agenstra-egress +ACCESS_LOG="$LOG_DIR/access.log" +ROTATE_BYTES=$((5 * 1024 * 1024)) + +SQUID_PID="" +DNSMASQ_PID="" +TAIL_PID="" +RELOAD_REQUESTED=0 +STOPPING=0 + +emit() { + local event="$1" detail="${2:-}" + + detail="${detail//\\/\\\\}" + detail="${detail//\"/\\\"}" + printf 'AGENSTRA_GUARD {"event":"%s","detail":"%s","ts":%s}\n' "$event" "$detail" "$(date +%s)" +} + +config_dir() { + if [ -f "$ACTIVE/squid.conf" ] && [ -f "$ACTIVE/dnsmasq.conf" ]; then + printf '%s' "$ACTIVE" + else + printf '%s' "$DEFAULT_DIR" + fi +} + +start_squid() { + local dir + dir="$(config_dir)" + squid -N -f "$dir/squid.conf" >>"$LOG_DIR/squid.stdout" 2>&1 & + SQUID_PID=$! +} + +start_dnsmasq() { + local dir + dir="$(config_dir)" + dnsmasq -k --conf-file="$dir/dnsmasq.conf" & + DNSMASQ_PID=$! +} + +start_tail() { + touch "$ACCESS_LOG" + chown egressproxy:egressproxy "$ACCESS_LOG" + tail -n 0 -F "$ACCESS_LOG" 2>/dev/null & + TAIL_PID=$! +} + +stop_pid() { + local pid="$1" + + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + + for _ in $(seq 1 20); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi +} + +reload_services() { + local dir revision + dir="$(config_dir)" + revision="$(sed -n 's/.*"revision":"\([^"]*\)".*/\1/p' "$dir/policy.json" 2>/dev/null | head -n 1)" + + # dnsmasq does not re-read its configuration on SIGHUP: restart it. + stop_pid "$DNSMASQ_PID" + start_dnsmasq + + # Squid listeners never change; a full restart keeps behaviour simple and deterministic. + stop_pid "$SQUID_PID" + rm -f -- "$RUN_DIR/squid.pid" + start_squid + + printf '%s' "$revision" > "$RUN_DIR/applied" + emit policy_applied "${revision:-default}" +} + +on_hup() { + RELOAD_REQUESTED=1 +} + +on_term() { + STOPPING=1 +} + +trap on_hup HUP +trap on_term TERM INT + +install -d -m 0750 -o root -g egressproxy "$STATE_DIR" +install -d -m 0755 "$RUN_DIR" +install -d -m 0750 -o egressproxy -g egressproxy "$LOG_DIR" + +if ! "$CLI" lockdown; then + emit lockdown_failed "netfilter lockdown could not be installed (CAP_NET_ADMIN missing?)" + exit 1 +fi + +if [ ! -s "$STATE_DIR/bootstrap.pem" ]; then + # Only used to initialise the TLS intercept listener; connections are spliced or terminated, + # so this certificate is never presented to (or trusted by) the agent. + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj "/CN=agenstra-egress-guard" \ + -keyout "$STATE_DIR/bootstrap.pem" -out "$STATE_DIR/bootstrap.crt" >/dev/null 2>&1 \ + && cat "$STATE_DIR/bootstrap.crt" >> "$STATE_DIR/bootstrap.pem" \ + && rm -f -- "$STATE_DIR/bootstrap.crt" + chown root:egressproxy "$STATE_DIR/bootstrap.pem" + chmod 0640 "$STATE_DIR/bootstrap.pem" +fi + +printf '%s' "$$" > "$RUN_DIR/supervisor.pid" + +if [ -d "$ACTIVE" ]; then + if ! "$CLI" activate --no-reload; then + emit activate_failed "stored policy could not be re-applied; guard stays locked down" + "$CLI" lockdown || true + fi +fi + +start_tail +start_dnsmasq +start_squid +printf '%s' "$(sed -n 's/.*"revision":"\([^"]*\)".*/\1/p' "$(config_dir)/policy.json" 2>/dev/null | head -n 1)" \ + > "$RUN_DIR/applied" +emit started "$(squid -v 2>/dev/null | head -n 1)" + +while [ "$STOPPING" -eq 0 ]; do + sleep 1 & + wait $! 2>/dev/null || true + + if [ "$STOPPING" -ne 0 ]; then + break + fi + + if [ "$RELOAD_REQUESTED" -eq 1 ]; then + RELOAD_REQUESTED=0 + reload_services + continue + fi + + if [ -n "$SQUID_PID" ] && ! kill -0 "$SQUID_PID" 2>/dev/null; then + emit process_restarted "squid exited: $(tail -n 3 "$LOG_DIR/cache.log" 2>/dev/null | tr '\n' ' ' | cut -c1-300)" + rm -f -- "$RUN_DIR/squid.pid" + sleep 1 + start_squid + fi + + if [ -n "$DNSMASQ_PID" ] && ! kill -0 "$DNSMASQ_PID" 2>/dev/null; then + emit process_restarted "dnsmasq exited" + sleep 1 + start_dnsmasq + fi + + if [ -n "$TAIL_PID" ] && ! kill -0 "$TAIL_PID" 2>/dev/null; then + start_tail + fi + + size="$(stat -c %s "$ACCESS_LOG" 2>/dev/null || echo 0)" + + if [ "$size" -gt "$ROTATE_BYTES" ]; then + squid -k rotate -f "$(config_dir)/squid.conf" >/dev/null 2>&1 || true + fi + + # Keep Squid's own diagnostics bounded as well. + for file in "$LOG_DIR/cache.log" "$LOG_DIR/squid.stdout"; do + if [ "$(stat -c %s "$file" 2>/dev/null || echo 0)" -gt "$ROTATE_BYTES" ]; then + : > "$file" + fi + done +done + +stop_pid "$SQUID_PID" +stop_pid "$DNSMASQ_PID" +stop_pid "$TAIL_PID" +exit 0 diff --git a/apps/agenstra/backend-agent-manager/project.json b/apps/agenstra/backend-agent-manager/project.json index b41e0662e..960f0512a 100644 --- a/apps/agenstra/backend-agent-manager/project.json +++ b/apps/agenstra/backend-agent-manager/project.json @@ -173,8 +173,41 @@ }, "defaultConfiguration": "test" }, + "egress-guard-container-image": { + "cache": false, + "executor": "@nx-tools/nx-container:build", + "options": { + "engine": "docker", + "file": "apps/agenstra/backend-agent-manager/Dockerfile.egress-guard", + "context": "apps/agenstra/backend-agent-manager", + "build-args": ["VERSION"] + }, + "configurations": { + "test": { + "load": true, + "labels": [ + "io.forepath.one.test-project=agenstra-backend-agent-manager" + ], + "tags": [ + "registry.forenet.internal/forepath/agenstra-manager-egress-guard:test" + ] + }, + "release": { + "push": true, + "tags": [ + "ghcr.io/forepath/agenstra-manager-egress-guard:latest", + "ghcr.io/forepath/agenstra-manager-egress-guard:$VERSION" + ] + } + }, + "defaultConfiguration": "test" + }, "start-containers": { - "dependsOn": ["api-container-image", "worker-container-image"], + "dependsOn": [ + "api-container-image", + "worker-container-image", + "egress-guard-container-image" + ], "cache": false, "executor": "nx:run-commands", "options": { diff --git a/apps/agenstra/backend-agent-manager/src/migrations/1792000000000_AddEgressGuardAndMessageFilters.ts b/apps/agenstra/backend-agent-manager/src/migrations/1792000000000_AddEgressGuardAndMessageFilters.ts new file mode 100644 index 000000000..3ef3cb021 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/src/migrations/1792000000000_AddEgressGuardAndMessageFilters.ts @@ -0,0 +1,147 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn, TableIndex } from 'typeorm'; + +/** + * Egress guard sidecar + layered message filters. + * - Agents track their guard container, the effective egress policy / message filter rules pushed by + * the controller, apply status and the guard log collection cursor. + * - `agent_egress_events` stores aggregated guard decisions for the controller's audit ingestion. + * - The legacy instance-wide `regex_filter_rules` table is dropped: message filters are now part of + * the layered agent configuration (global → workspace → environment) and arrive with config sync. + * Until the controller's first sync, existing agents keep enforcing the legacy rules (copied into + * `agents.message_filters`; invalid patterns are dropped at evaluation time). + */ +export class AddEgressGuardAndMessageFilters1792000000000 implements MigrationInterface { + name = 'AddEgressGuardAndMessageFilters1792000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('agents', [ + new TableColumn({ name: 'egress_guard_container_id', type: 'varchar', length: '255', isNullable: true }), + new TableColumn({ name: 'egress_policy', type: 'jsonb', isNullable: true }), + new TableColumn({ name: 'egress_policy_revision', type: 'varchar', length: '64', isNullable: true }), + new TableColumn({ name: 'egress_status', type: 'varchar', length: '16', isNullable: true }), + new TableColumn({ name: 'egress_last_error', type: 'text', isNullable: true }), + new TableColumn({ name: 'egress_applied_at', type: 'timestamptz', isNullable: true }), + new TableColumn({ name: 'egress_log_cursor', type: 'varchar', length: '64', isNullable: true }), + new TableColumn({ name: 'message_filters', type: 'jsonb', isNullable: true }), + ]); + + await queryRunner.createTable( + new Table({ + name: 'agent_egress_events', + columns: [ + { name: 'seq', type: 'bigint', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'agent_id', type: 'uuid', isNullable: false }, + { name: 'kind', type: 'varchar', length: '16', isNullable: false }, + { name: 'decision', type: 'varchar', length: '16', isNullable: false }, + { name: 'host', type: 'varchar', length: '255', isNullable: true }, + { name: 'rule', type: 'varchar', length: '32', isNullable: true }, + { name: 'matched_entry', type: 'varchar', length: '255', isNullable: true }, + { name: 'matched_source', type: 'varchar', length: '16', isNullable: true }, + { name: 'method', type: 'varchar', length: '32', isNullable: true }, + { name: 'http_status', type: 'int', isNullable: true }, + { name: 'sample_url', type: 'text', isNullable: true }, + { name: 'count', type: 'int', isNullable: false, default: 1 }, + { name: 'bytes_in', type: 'bigint', isNullable: false, default: 0 }, + { name: 'bytes_out', type: 'bigint', isNullable: false, default: 0 }, + { name: 'first_seen_at', type: 'timestamptz', isNullable: false }, + { name: 'last_seen_at', type: 'timestamptz', isNullable: false }, + { name: 'mode', type: 'varchar', length: '16', isNullable: true }, + { name: 'policy_revision', type: 'varchar', length: '64', isNullable: true }, + { name: 'message', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', isNullable: false, default: 'CURRENT_TIMESTAMP' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'agent_egress_events', + new TableIndex({ name: 'IDX_agent_egress_events_agent_last_seen', columnNames: ['agent_id', 'last_seen_at'] }), + ); + await queryRunner.createIndex( + 'agent_egress_events', + new TableIndex({ name: 'IDX_agent_egress_events_created_at', columnNames: ['created_at'] }), + ); + + await this.carryOverLegacyRules(queryRunner); + await queryRunner.query('DROP TABLE IF EXISTS "regex_filter_rules"'); + } + + private async carryOverLegacyRules(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('regex_filter_rules'))) { + return; + } + + const rows = (await queryRunner.query( + `SELECT "id", "pattern", "regex_flags", "direction", "filter_type", "replace_content" + FROM "regex_filter_rules" + ORDER BY "priority" ASC, "created_at" ASC + LIMIT 200`, + )) as Array<{ + id: string; + pattern: string; + regex_flags: string | null; + direction: string; + filter_type: string; + replace_content: string | null; + }>; + + if (rows.length === 0) { + return; + } + + const rules = rows.map((row, index) => { + const action = row.filter_type === 'drop' ? 'drop' : row.filter_type === 'filter' ? 'replace' : 'flag'; + + return { + name: `Migrated rule ${index + 1} (${row.id.slice(0, 8)})`, + pattern: row.pattern, + flags: row.regex_flags ?? 'g', + direction: ['incoming', 'outgoing', 'bidirectional'].includes(row.direction) ? row.direction : 'bidirectional', + action, + ...(action === 'replace' ? { replacement: row.replace_content ?? '' } : {}), + enabled: true, + source: 'global', + }; + }); + + await queryRunner.query('UPDATE "agents" SET "message_filters" = $1 WHERE "message_filters" IS NULL', [ + JSON.stringify(rules), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "regex_filter_rules" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + "pattern" text NOT NULL, + "regex_flags" varchar(16) NOT NULL DEFAULT 'g', + "direction" varchar(32) NOT NULL, + "filter_type" varchar(16) NOT NULL, + "replace_content" text NULL, + "priority" int NOT NULL DEFAULT 0, + "created_at" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_regex_filter_rules_priority" ON "regex_filter_rules" ("priority", "created_at")', + ); + await queryRunner.dropIndex('agent_egress_events', 'IDX_agent_egress_events_created_at'); + await queryRunner.dropIndex('agent_egress_events', 'IDX_agent_egress_events_agent_last_seen'); + await queryRunner.dropTable('agent_egress_events'); + + for (const column of [ + 'message_filters', + 'egress_log_cursor', + 'egress_applied_at', + 'egress_last_error', + 'egress_status', + 'egress_policy_revision', + 'egress_policy', + 'egress_guard_container_id', + ]) { + await queryRunner.dropColumn('agents', column); + } + } +} diff --git a/apps/agenstra/backend-agent-manager/src/typeorm.config.ts b/apps/agenstra/backend-agent-manager/src/typeorm.config.ts index edf8030dc..f4dd46a97 100644 --- a/apps/agenstra/backend-agent-manager/src/typeorm.config.ts +++ b/apps/agenstra/backend-agent-manager/src/typeorm.config.ts @@ -1,5 +1,6 @@ import { AgentChatSessionEntity, + AgentEgressEventEntity, AgentEntity, AgentDirectoryIndexEntity, AgentEnvironmentVariableEntity, @@ -7,7 +8,6 @@ import { AgentMessageEventEntity, DeploymentConfigurationEntity, DeploymentRunEntity, - RegexFilterRuleEntity, WorkspaceConfigurationOverrideEntity, } from '@forepath/agenstra/backend/feature-agent-manager'; import { CorrelationAwareTypeOrmLogger } from '@forepath/shared/backend/util-http-context'; @@ -45,12 +45,12 @@ export const typeormConfig: DataSourceOptions = { AgentEntity, AgentDirectoryIndexEntity, AgentChatSessionEntity, + AgentEgressEventEntity, AgentEnvironmentVariableEntity, AgentMessageEntity, AgentMessageEventEntity, DeploymentConfigurationEntity, DeploymentRunEntity, - RegexFilterRuleEntity, WorkspaceConfigurationOverrideEntity, ], // Migration paths: diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf index 2d960ea51..81b00bd66 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf @@ -6,10 +6,6 @@ Audit :: Agenstra Audit :: Agenstra - - Filters :: Agenstra - Filter :: Agenstra - Atlassian import :: Agenstra Atlassian-Import :: Agenstra @@ -34,7 +30,7 @@ Refresh Aktualisieren - + Dismiss Ausblenden @@ -1822,6 +1818,10 @@ Users Benutzer + + Config + Konfiguration + Access Tokens Zugriffstoken @@ -1830,14 +1830,6 @@ Tokens Token - - Filters - Filterregeln - - - Filters - Filterregeln - OpenCode config OpenCode-Konfiguration @@ -2026,7 +2018,7 @@ Create Configuration Konfiguration erstellen - + Close Schließen @@ -2467,7 +2459,7 @@ Delete Löschen - + New Name Neuer Name @@ -2476,7 +2468,7 @@ Rename Umbenennen - + Destination Path Zielpfad @@ -2493,7 +2485,7 @@ Move Verschieben - + Enter file name... Dateiname eingeben... @@ -2810,7 +2802,7 @@ Create Terminal Terminal erstellen - + Knowledge Wissen @@ -3027,182 +3019,6 @@ Delete Löschen - - Incoming - Eingehend - - - Outgoing - Ausgehend - - - Bidirectional - Bidirektional - - - No content change - Keine Inhaltsänderung - - - Replace content - Inhalt ersetzen - - - Drop message - Nachricht verwerfen - - - No match. - Keine Übereinstimmung. - - - Match: the message would be dropped. - Treffer: Die Nachricht würde verworfen. - - - Match: no content change. - Treffer: Keine Inhaltsänderung. - - - Match: replaced result: - Treffer: Ersetztes Ergebnis: - - - Sync to agent-manager failed - Synchronisation mit Agent-Manager fehlgeschlagen - - - Synced to agent-manager - Mit Agent-Manager synchronisiert - - - Waiting to sync to agent-manager - Wartet auf Synchronisation mit dem Agent-Manager - - - Filters - Filterregeln - - - Add filter rule - Filterregel hinzufügen - - - Search rules - Regeln suchen - - - Loading… - Wird geladen… - - - No filter rules yet. - Noch keine Filterregeln. - - - Inactive - Inaktiv - - - Active - Ausstehend - - - Synced - Synchronisiert - - - Test - Test - - - Edit - Bearbeiten - - - Delete - Löschen - - - All workspaces - Alle Arbeitsbereiche - - - Add filter rule - Filterregel hinzufügen - - - Pattern - Muster - - - [a-zA-Z0-9]+ - [a-zA-Z0-9]+ - - - Flags - Flags - - - Direction - Richtung - - - Filter type - Filtertyp - - - Replace template - Ersetzungsvorlage - - - Priority - Priorität - - - Enabled - Aktiviert - - - Global (all workspaces) - Global (alle Arbeitsbereiche) - - - Workspaces - Arbeitsbereiche - - - Save - Speichern - - - Edit filter rule - Filterregel bearbeiten - - - Delete rule - Regel löschen - - - Delete this rule? Remote copies will be removed where possible. - Diese Regel löschen? Remote-Kopien werden nach Möglichkeit entfernt. - - - This action cannot be undone. - Diese Aktion kann nicht rückgängig gemacht werden. - - - Test rule - Regel testen - - - Sample text - Beispieltext - - - Run test - Test ausführen - Pending Ausstehend @@ -3539,7 +3355,7 @@ Add subtask Unteraufgabe hinzufügen - + Subtasks Unteraufgaben @@ -3936,7 +3752,7 @@ Create Anlegen - + Search tickets Tickets durchsuchen @@ -4793,7 +4609,7 @@ Create personal access token Persönliches Zugriffstoken erstellen - + Close Schließen @@ -4806,7 +4622,7 @@ Copy this token now. It will not be shown again. Kopieren Sie dieses Token jetzt. Es wird nicht erneut angezeigt. - + Copied Kopiert @@ -4819,7 +4635,7 @@ Done Fertig - + Name Name @@ -4852,7 +4668,7 @@ Revoke token Token widerrufen - + Are you sure you want to revoke the token ? Möchten Sie das Token wirklich widerrufen? @@ -5033,10 +4849,6 @@ Save Speichern - - Create - Erstellen - Add Hinzufügen @@ -5097,7 +4909,7 @@ Edit personal access token Persönlichen Zugriffstoken bearbeiten - + Update Aktualisieren @@ -5442,7 +5254,7 @@ Response Antwort - + More deliveries exist than shown. Es gibt mehr Zustellungen als angezeigt. @@ -5475,7 +5287,7 @@ Check now Jetzt prüfen - + Loading updates... Updates werden geladen… @@ -5504,12 +5316,12 @@ Installed version Installierte Version - + Latest version Neueste Version - + View release Release anzeigen @@ -5518,7 +5330,7 @@ Frontend version Frontend-Version - + Last check Letzte Prüfung @@ -5527,7 +5339,7 @@ Service instances Service-Instanzen - + Instances Instanzen @@ -5560,22 +5372,22 @@ Product changelog Produkt-Changelog - + Shared changelog Geteiltes Changelog - + No product changelog entries. Keine Einträge im Produkt-Changelog. - + No shared changelog entries. Keine Einträge im geteilten Changelog. - + Healthy Gesund @@ -6084,6 +5896,386 @@ Accumulated configuration JSON Akkumulierte Konfiguration (JSON) + + Security + Sicherheit + + + Web traffic + Webverkehr + + + Guard status: + Guard-Status: + + + applied + angewendet + + + Mode + Modus + + + Filtering mode + Filtermodus + + + off: no guard · monitor: record would-be blocks · enforce: apply the lists · offline: block all egress + off: kein Guard · monitor: mögliche Sperren protokollieren · enforce: Listen anwenden · offline: gesamten ausgehenden Verkehr sperren + + + (inherit) + (erben) + + + Effective: + Wirksam: + + + yes + ja + + + no + nein + + + Destinations + Ziele + + + (inherited, read-only) + (geerbt, schreibgeschützt) + + + Non-web exceptions + Ausnahmen für Nicht-Web-Verkehr + + + Add network exception + Netzwerkausnahme hinzufügen + + + No network exceptions. + Keine Netzwerkausnahmen. + + + Protocol + Protokoll + + + Ports + Ports + + + Single port (22) or range (1000-2000) + Einzelner Port (22) oder Bereich (1000-2000) + + + Destinations + Ziele + + + IP addresses or CIDRs, comma separated + IP-Adressen oder CIDRs, durch Komma getrennt + + + Description + Beschreibung + + + Test a destination + Ziel testen + + + Hostname or URL + Hostname oder URL + + + Evaluated against the effective policy including unsaved changes on this layer + Ausgewertet gegen die wirksame Richtlinie inklusive ungespeicherter Änderungen dieser Ebene + + + Message filters + Nachrichtenfilter + + + Rules + Regeln + + + Add message filter rule + Nachrichtenfilterregel hinzufügen + + + Disabled + Deaktiviert + + + No message filter rules yet. + Noch keine Nachrichtenfilterregeln. + + + Name + Name + + + Shown in drop notices and the audit log (unique per layer) + Wird in Verwerfungshinweisen und im Audit-Protokoll angezeigt (pro Ebene eindeutig) + + + Pattern + Muster + + + JavaScript regular expression (nested quantifiers such as (a+)+ are rejected) + Regulärer JavaScript-Ausdruck (verschachtelte Quantoren wie (a+)+ werden abgelehnt) + + + Flags + Flags + + + Direction + Richtung + + + Action + Aktion + + + drop: block the message · replace: rewrite matches · flag: record only + drop: Nachricht verwerfen · replace: Treffer ersetzen · flag: nur protokollieren + + + Replacement + Ersetzung + + + Enabled + Aktiviert + + + Test a message + Nachricht testen + + + Sample message + Beispielnachricht + + + Evaluated against all effective rules including unsaved changes on this layer + Ausgewertet gegen alle wirksamen Regeln inklusive ungespeicherter Änderungen dieser Ebene + + + incoming (user → agent) + eingehend (Benutzer → Agent) + + + outgoing (agent → user) + ausgehend (Agent → Benutzer) + + + Block private networks + Private Netzwerke sperren + + + Deny loopback, link-local, RFC 1918 and cloud metadata addresses unless explicitly allowed + Loopback-, Link-Local-, RFC-1918- und Cloud-Metadaten-Adressen sperren, sofern nicht ausdrücklich erlaubt + + + Block IP literals + IP-Literale sperren + + + Deny connections to raw IP addresses (and TLS without a hostname) unless the address is allowed + Verbindungen zu reinen IP-Adressen (und TLS ohne Hostnamen) sperren, sofern die Adresse nicht erlaubt ist + + + Record allowed traffic + Erlaubten Verkehr protokollieren + + + Include allowed destinations in the audit log (blocked traffic is always recorded) + Erlaubte Ziele in das Audit-Protokoll aufnehmen (gesperrter Verkehr wird immer protokolliert) + + + Denylist + Sperrliste + + + Hostnames (subdomains match too; *.example.com is equivalent), IP addresses or CIDRs, one per line. Always wins. + Hostnamen (Subdomains passen ebenfalls; *.example.com ist gleichwertig), IP-Adressen oder CIDRs, einer pro Zeile. Hat immer Vorrang. + + + Allowlist + Erlaubnisliste + + + When not empty, only these destinations are reachable. Same syntax as the denylist. + Wenn nicht leer, sind nur diese Ziele erreichbar. Gleiche Syntax wie die Sperrliste. + + + Blocked plain-HTTP URL patterns + Gesperrte HTTP-URL-Muster + + + Regular expressions matched against http:// URLs (HTTPS paths are not visible), one per line. + Reguläre Ausdrücke für http://-URLs (HTTPS-Pfade sind nicht sichtbar), einer pro Zeile. + + + Denylisted destination + Gesperrtes Ziel + + + Not on the allowlist + Nicht auf der Erlaubnisliste + + + Private network + Privates Netzwerk + + + IP literal + IP-Literal + + + Blocked URL pattern + Gesperrtes URL-Muster + + + Offline mode + Offline-Modus + + + QUIC (UDP/443) + QUIC (UDP/443) + + + DNS over TLS + DNS over TLS + + + Undeclared protocol / port + Nicht freigegebenes Protokoll / Port + + + Guard + Guard + + + Allowed + Erlaubt + + + Blocked + Gesperrt + + + Would be blocked + Würde gesperrt + + + Guard error + Guard-Fehler + + + Web + Web + + + DNS + DNS + + + Network + Netzwerk + + + Guard + Guard + + + Web Traffic + Webverkehr + + + Decision + Entscheidung + + + All decisions + Alle Entscheidungen + + + Decision + Entscheidung + + + Type + Typ + + + Destination + Ziel + + + Rule + Regel + + + Count + Anzahl + + + Transfer + Übertragung + + + Blocked destinations + Gesperrte Ziele + + + Would-be blocks + Mögliche Sperren + + + Allowed requests + Erlaubte Anfragen + + + Message drops + Verworfene Nachrichten + + + Message flags + Markierte Nachrichten + + + Blocked destinations + Gesperrte Ziele + + + Would-be blocks + Mögliche Sperren + + + Security events over time + Sicherheitsereignisse im Zeitverlauf + + + Web traffic decisions over time + Webverkehr-Entscheidungen im Zeitverlauf + + + Top blocked destinations + Am häufigsten gesperrte Ziele + + + Blocks by rule + Sperren nach Regel + diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf index 7a073e8c7..b74d7b7c9 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf @@ -5,9 +5,6 @@ Audit :: Agenstra - - Filters :: Agenstra - Atlassian import :: Agenstra @@ -1480,12 +1477,6 @@ Users - - Filters - - - Filters - Agent configuration @@ -1496,7 +1487,7 @@ Agent configuration - Agent config + Config Agent configuration @@ -2875,141 +2866,6 @@ Delete - - Incoming - - - Outgoing - - - Bidirectional - - - No content change - - - Replace content - - - Drop message - - - No match. - - - Match: the message would be dropped. - - - Match: no content change. - - - Match: replaced result: - - - Sync to agent-manager failed - - - Synced to agent-manager - - - Waiting to sync to agent-manager - - - Filters - - - Add filter rule - - - Search rules - - - Loading… - - - No filter rules yet. - - - Inactive - - - Active - - - Synced - - - Test - - - Edit - - - Delete - - - All workspaces - - - Add filter rule - - - Pattern - - - [a-zA-Z0-9]+ - - - Flags - - - Direction - - - Filter type - - - Replace template - - - Priority - - - Enabled - - - Global (all workspaces) - - - Workspaces - - - Create - - - Edit filter rule - - - Save - - - Delete rule - - - Delete this rule? Remote copies will be removed where possible. - - - This action cannot be undone. - - - Test rule - - - Sample text - - - Run test - Pending @@ -4994,6 +4850,291 @@ Cookie Policy + + Security + + + Web traffic + + + Guard status: + + + applied + + + Mode + + + Filtering mode + + + off: no guard · monitor: record would-be blocks · enforce: apply the lists · offline: block all egress + + + (inherit) + + + Effective: + + + yes + + + no + + + Destinations + + + (inherited, read-only) + + + Non-web exceptions + + + Add network exception + + + No network exceptions. + + + Protocol + + + Ports + + + Single port (22) or range (1000-2000) + + + Destinations + + + IP addresses or CIDRs, comma separated + + + Description + + + Test a destination + + + Hostname or URL + + + Evaluated against the effective policy including unsaved changes on this layer + + + Message filters + + + Rules + + + Add message filter rule + + + Disabled + + + No message filter rules yet. + + + Name + + + Shown in drop notices and the audit log (unique per layer) + + + Pattern + + + JavaScript regular expression (nested quantifiers such as (a+)+ are rejected) + + + Flags + + + Direction + + + Action + + + drop: block the message · replace: rewrite matches · flag: record only + + + Replacement + + + Enabled + + + Test a message + + + Sample message + + + Evaluated against all effective rules including unsaved changes on this layer + + + incoming (user → agent) + + + outgoing (agent → user) + + + Block private networks + + + Deny loopback, link-local, RFC 1918 and cloud metadata addresses unless explicitly allowed + + + Block IP literals + + + Deny connections to raw IP addresses (and TLS without a hostname) unless the address is allowed + + + Record allowed traffic + + + Include allowed destinations in the audit log (blocked traffic is always recorded) + + + Denylist + + + Hostnames (subdomains match too; *.example.com is equivalent), IP addresses or CIDRs, one per line. Always wins. + + + Allowlist + + + When not empty, only these destinations are reachable. Same syntax as the denylist. + + + Blocked plain-HTTP URL patterns + + + Regular expressions matched against http:// URLs (HTTPS paths are not visible), one per line. + + + Denylisted destination + + + Not on the allowlist + + + Private network + + + IP literal + + + Blocked URL pattern + + + Offline mode + + + QUIC (UDP/443) + + + DNS over TLS + + + Undeclared protocol / port + + + Guard + + + Allowed + + + Blocked + + + Would be blocked + + + Guard error + + + Web + + + DNS + + + Network + + + Guard + + + Web Traffic + + + Decision + + + All decisions + + + Decision + + + Type + + + Destination + + + Rule + + + Count + + + Transfer + + + Blocked destinations + + + Would-be blocks + + + Allowed requests + + + Message drops + + + Message flags + + + Blocked destinations + + + Would-be blocks + + + Security events over time + + + Web traffic decisions over time + + + Top blocked destinations + + + Blocks by rule + diff --git a/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf b/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf index ed9adaff6..dad3ab068 100644 --- a/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-landingpage/src/i18n/messages.de.xlf @@ -835,8 +835,8 @@ Dokumentierte Automatisierung für Hetzner und DigitalOcean zur Installation des Agent-Managers und Mandanten-Registrierung. - Global regex filter rules, pluggable chat filters, statistics for drops/flags and entity events. - Globale Regex-Filterregeln, erweiterbare Chat-Filter, Statistiken zu Verwerfungen, Markierungen und Entitätsereignissen. + Layered message filters and per-environment web traffic filtering (egress guard), pluggable chat filters, audit statistics for drops, flags, blocked destinations and entity events. + Mehrstufige Nachrichtenfilter und Webverkehrsfilterung pro Umgebung (Egress-Guard), erweiterbare Chat-Filter, Audit-Statistiken zu Verwerfungen, Markierungen, gesperrten Zielen und Entitätsereignissen. Statistics REST for chat I/O, filter incidents, and entity lifecycle (per client and aggregate). diff --git a/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf b/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf index c19084797..644a18c7d 100644 --- a/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-landingpage/src/i18n/messages.xlf @@ -627,7 +627,7 @@ Documented automation for Hetzner and DigitalOcean to install agent-manager and register clients. - Global regex filter rules, pluggable chat filters, statistics for drops/flags and entity events. + Layered message filters and per-environment web traffic filtering (egress guard), pluggable chat filters, audit statistics for drops, flags, blocked destinations and entity events. Statistics REST for chat I/O, filter incidents, and entity lifecycle (per client and aggregate). diff --git a/docs/agenstra/api-reference/README.md b/docs/agenstra/api-reference/README.md index 3a96b7393..d9132d14d 100644 --- a/docs/agenstra/api-reference/README.md +++ b/docs/agenstra/api-reference/README.md @@ -22,7 +22,7 @@ The Agent Controller HTTP API provides: - Tickets, comments, activity, migration, automation, and assisted body-generation flows (`/tickets/*`) - Knowledge tree and relations (`/knowledge/*`) for workspace pages/folders - Usage statistics (`/clients/:id/statistics/*`, `/statistics/*`) -- Global message filter rules for administrators (`/filter-rules`) +- Agent web traffic (egress) status, policy evaluation, and event statistics (`/clients/:id/agents/:agentId/egress`, `/clients/:id/statistics/egress-events`) - Atlassian site connections and import configurations for administrators (`/imports/atlassian/*`) - Agent autonomy configuration per workspace and per agent - Proxied agent operations (CRUD, models, start/stop/restart, environment variables, deployments) @@ -83,7 +83,7 @@ The Agent Manager HTTP API provides: - Agent management (CRUD, models, start/stop/restart) - Latest agent message metadata (`GET /agents/{id}/messages/latest-agent`) for unread tracking -- Per-agent regex filter rules (`/agents-filters`) +- Egress guard status and decision event feed (`/agents/{id}/egress`, `/egress/events`) - Environment variable CRUD with container restart semantics - Workspace configuration overrides (`/configuration-overrides`) - File system operations (read, write, create, delete, move; optional `context` query) diff --git a/docs/agenstra/applications/backend-agent-controller.md b/docs/agenstra/applications/backend-agent-controller.md index 18181ee4f..d48e0565f 100644 --- a/docs/agenstra/applications/backend-agent-controller.md +++ b/docs/agenstra/applications/backend-agent-controller.md @@ -15,7 +15,7 @@ This application provides: - **Server Provisioning** Automated cloud server provisioning (Hetzner Cloud, DigitalOcean) with Docker and agent-manager deployment - **Tickets and Automation** Workspace tickets, migration, and automation APIs with optional ticket board WebSocket namespace - **Usage Statistics** Aggregated chat, filter, and entity metrics for operators and admins -- **Global Filter Rules** Admin-managed regex policies synced to workspaces +- **Message filters and egress policy** Layered `message_filters` and `egress` (web traffic) policy in the agent configuration, resolved and synced to managers; egress events ingested for the audit page - **Atlassian import** Admin-managed Atlassian site connections and Jira/Confluence import configurations (scheduled and on-demand) - **Agent configuration** Three-layer OpenCode V2 overlays (`/admin/opencode/config`, client and agent `/opencode/config`) with heredity locks and worker sync; see [Agent configuration](../features/agent-configuration.md) - **Per-Client Permissions** Fine-grained access control with user roles per client (keycloak/users mode) @@ -152,16 +152,17 @@ See [Usage Statistics](../features/usage-statistics.md). - `GET /api/statistics/summary` - Aggregate summary (access rules per OpenAPI) - `GET /api/statistics/chat-io`, `filter-drops`, `filter-flags`, `entity-events` - Global aggregates -### Global message filter rules (admin) +### Message filters and web traffic (egress) -See [Message Filter Rules](../features/message-filter-rules.md). +Message filters and egress policy are authored in the layered agent configuration (no dedicated CRUD routes). See [Message Filters](../features/message-filters.md) and [Egress Filtering](../features/egress-filtering.md). -- `GET/POST /api/filter-rules` - List (paginated) and create rules -- `GET/PUT/DELETE /api/filter-rules/:id` - Read, update, delete a rule +- `GET /api/clients/:id/agents/:agentId/egress` - Guard status, policy in force, message filters and recent decisions +- `POST /api/clients/:id/agents/:agentId/egress/evaluate` - Evaluate a destination against the environment's effective egress policy +- `GET /api/clients/:id/statistics/egress-events` and `GET /api/statistics/egress-events` - Ingested egress decision events ### Atlassian import (controller-native, admin) -See [Atlassian import](../features/atlassian-import.md). All paths are under **`/api/imports/atlassian`** with the same admin authorization model as filter rules (global admin session, or API key authentication). +See [Atlassian import](../features/atlassian-import.md). All paths are under **`/api/imports/atlassian`** with the same admin authorization model as the admin agent configuration (global admin session, or API key authentication). - `GET/POST /api/imports/atlassian/connections` - List (paginated) and create site connections - `GET/PUT/DELETE /api/imports/atlassian/connections/{id}` - Read, update, delete a connection @@ -474,7 +475,8 @@ Before deploying to production, ensure: - **[WebSocket Communication Feature](../features/websocket-communication.md)** WebSocket communication guide - **[Tickets and Workspaces](../features/tickets-and-workspaces.md)** Tickets, migration, automation - **[Usage Statistics](../features/usage-statistics.md)** Controller usage metrics -- **[Message Filter Rules](../features/message-filter-rules.md)** Global and per-agent filters +- **[Message Filters](../features/message-filters.md)** Layered chat message filters +- **[Egress Filtering](../features/egress-filtering.md)** Per-environment web traffic filtering - **[Atlassian import](../features/atlassian-import.md)** Atlassian site connections and Jira/Confluence import configurations - **[Agent configuration](../features/agent-configuration.md)** Three-layer runtime overlays and worker sync - **[Dynamic provider plugins](../features/dynamic-provider-plugins.md)** Runtime provisioning and import provider extensions diff --git a/docs/agenstra/applications/backend-agent-manager.md b/docs/agenstra/applications/backend-agent-manager.md index c9a844fbb..046139ece 100644 --- a/docs/agenstra/applications/backend-agent-manager.md +++ b/docs/agenstra/applications/backend-agent-manager.md @@ -13,6 +13,7 @@ This application provides: - **HTTP REST API** Full CRUD operations for agent management - **WebSocket Gateway** Real-time bidirectional communication with agents - **Container Integration** Docker container management for agent execution +- **Egress guard** Per-agent sidecar that filters outbound web traffic (Squid/dnsmasq/iptables) and reports decision events; see [Egress Filtering](../features/egress-filtering.md) - **Secure Authentication** Keycloak integration for HTTP endpoints and database-backed authentication for WebSocket - **Database Support** PostgreSQL with TypeORM for data persistence - **Auto Migrations** Automatic database schema migrations on startup @@ -64,13 +65,14 @@ Changes are persisted and synchronized to the agent’s Docker container. Contai - `DELETE /api/agents/:agentId/environment/:id` - Delete one variable - `DELETE /api/agents/:agentId/environment` - Delete all variables -### Per-agent message filter rules +### Egress guard (web traffic filtering) -Regex rules scoped to this manager instance (distinct from controller global rules). See [Message Filter Rules](../features/message-filter-rules.md). +Message filters and the egress policy arrive with the effective agent configuration pushed by the controller (the former `/api/agents-filters` routes were removed). See [Message Filters](../features/message-filters.md) and [Egress Filtering](../features/egress-filtering.md). -- `GET/POST /api/agents-filters` - List (ordered by priority) and create -- `GET /api/agents-filters/count` - Rule count -- `GET/PUT/DELETE /api/agents-filters/:id` - Read, update, delete +- `GET /api/agents/:id/egress` - Guard status, policy in force, message filters and recent decision events +- `GET /api/egress/events` - Aggregated decision events (`afterSeq`, `limit`, `agentId`) consumed by the controller's `egress-events-ingest` job + +Environment variables: `AGENT_EGRESS_GUARD_ENABLED`, `AGENT_EGRESS_GUARD_IMAGE`, `AGENT_EGRESS_GUARD_MEMORY_MB`, `AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED`, `AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS`, `AGENT_EGRESS_EVENTS_RETENTION_HOURS`. ### Deployments (CI/CD) @@ -322,7 +324,8 @@ Before deploying to production, ensure: - **[Agent Management Feature](../features/agent-management.md)** Agent management guide - **[WebSocket Communication Feature](../features/websocket-communication.md)** WebSocket communication guide - **[Deployment Feature](../features/deployment.md)** CI/CD configuration and operations -- **[Message Filter Rules](../features/message-filter-rules.md)** Per-agent regex filters +- **[Message Filters](../features/message-filters.md)** Layered chat message filters +- **[Egress Filtering](../features/egress-filtering.md)** Per-environment web traffic filtering - **[Dynamic provider plugins](../features/dynamic-provider-plugins.md)** Custom agent, pipeline, and filter providers - **[Backend Agent Controller](./backend-agent-controller.md)** Control plane and proxy paths used by the console - **[Deployment Guide](../deployment/production-checklist.md)** Production deployment guide diff --git a/docs/agenstra/applications/frontend-agent-console.md b/docs/agenstra/applications/frontend-agent-console.md index 29a4908dc..d72a187f2 100644 --- a/docs/agenstra/applications/frontend-agent-console.md +++ b/docs/agenstra/applications/frontend-agent-console.md @@ -21,7 +21,7 @@ This application provides: - **Tickets** Workspace ticket board with comments, activity, migration, and automation runs ([Tickets and Workspaces](../features/tickets-and-workspaces.md)) - **Deployments** CI/CD configuration and runs per agent ([Deployment](../features/deployment.md)) - **Usage statistics** Controller-backed analytics slices for operators ([Usage Statistics](../features/usage-statistics.md)) -- **Message filter rules** Global admin rule manager ([Message Filter Rules](../features/message-filter-rules.md)) +- **Message filters and web traffic** Security tabs in the agent configuration editor ([Message Filters](../features/message-filters.md), [Egress Filtering](../features/egress-filtering.md)) - **Atlassian import** Admin UI for Atlassian site connections and Jira/Confluence import configs ([Atlassian import](../features/atlassian-import.md)) - **Agent autonomy** Configure which agents may run autonomously per workspace - **Server Provisioning** Provision cloud servers (Hetzner, DigitalOcean) with automated deployment @@ -112,10 +112,6 @@ The application uses NgRx for state management with the following state slices: - Summary, chat I/O, filter drops/flags, and entity events (controller API) -### Filter rules state (`filterRules`) - -- Global filter rules for administrators - ### Atlassian context import state (`atlassianContextImport`) - Site connections and import configurations for administrators (loads when the agent-console feature routes are active) @@ -143,8 +139,7 @@ Routes are defined in the framework library `libs/domains/agenstra/frontend/feat - **Identity** `identityAuthRoutes` (login, register, password reset, email confirmation, user management) merged under the shell component - `/audit`. Audit (authenticated) - `/agent-config`. Admin **Agent configuration** editor (structured + raw; **admin** guard); see [Agent configuration](../features/agent-configuration.md) -- `/filters`. Global message filter rules (**admin** guard) -- `/imports/atlassian`. Atlassian site connections and import configurations (**admin** guard; sidebar with Users/Filters) +- `/imports/atlassian`. Atlassian site connections and import configurations (**admin** guard; sidebar with Users) - `/tickets`, `/tickets/:clientId`. Ticket board (**authenticated**; requires active client context) - `/clients`. Default **Manager** view: client and agent chat shell - `/clients`. Landing list @@ -168,10 +163,6 @@ Main workspace shell: chat (including a **session switcher** for multiple user-v Ticket board and detail experience for the active workspace (`/tickets`). -### RuleManagerComponent - -Administrative UI for global regex filter rules (`/filters`). - ### AtlassianImportAdminComponent Administrative UI for Atlassian site connections and import configurations (`/imports/atlassian`). @@ -316,7 +307,8 @@ Before deploying to production: - **[Tickets and Workspaces](../features/tickets-and-workspaces.md)** Ticket board and automation - **[Deployment](../features/deployment.md)** CI/CD from the console - **[Usage Statistics](../features/usage-statistics.md)** Controller analytics -- **[Message Filter Rules](../features/message-filter-rules.md)** Global and per-agent filters +- **[Message Filters](../features/message-filters.md)** Layered chat message filters +- **[Egress Filtering](../features/egress-filtering.md)** Per-environment web traffic filtering - **[Atlassian import](../features/atlassian-import.md)** Atlassian site connections and import configurations (admin UI) - **[WebSocket Communication](../features/websocket-communication.md)** Dual-namespace behavior - **[Backend Agent Controller](./backend-agent-controller.md)** API and WebSocket surface diff --git a/docs/agenstra/architecture/components.md b/docs/agenstra/architecture/components.md index 731276d0b..46c909379 100644 --- a/docs/agenstra/architecture/components.md +++ b/docs/agenstra/architecture/components.md @@ -12,14 +12,14 @@ This document provides a detailed breakdown of all system components, their resp **Key Components** (via `ClientsModule` and related providers from `@forepath/agenstra/backend`): -- `ClientsController` - HTTP REST API for clients, proxy paths, tickets, statistics, filter rules, provisioning +- `ClientsController` - HTTP REST API for clients, proxy paths, tickets, statistics, egress status/evaluation, provisioning - `ClientsService` - Business logic for clients with permission checks - `ClientUsersService` - Manages client-user relationships and per-client roles - `ClientsGateway` - WebSocket `socket/clients` namespace (manager proxy, ticket hints for chat) - `TicketsBoardGateway` - WebSocket `socket/tickets` namespace (ticket board realtime) - `ClientAgentProxyService` - Proxies HTTP requests to remote agent-managers - `ProvisioningService` - Automated cloud server provisioning -- Ticket, automation, statistics, and filter-rule services and repositories (see library source) +- Ticket, automation, statistics, and egress-event ingestion services and repositories (see library source) **Dependencies**: @@ -48,7 +48,7 @@ This document provides a detailed breakdown of all system components, their resp - `AgentsGateway` - WebSocket gateway for agent communication - `DockerService` - Container management and log streaming - `AgentProviderFactory` - Plugin-based agent provider system -- Regex filter rule services for `/api/agents-filters` +- `AgentEgressService`, `AgentEgressEventsCollectorService` and `AgentsEgressController` for the per-agent egress guard sidecar (`/api/agents/:id/egress`, `/api/egress/events`) **Dependencies**: @@ -77,7 +77,6 @@ This document provides a detailed breakdown of all system components, their resp - `AgentConsoleChatComponent` - Main workspace shell (chat, files, Git, env, deployments) - `AgentConsoleContainerComponent` - Layout and lazy-loaded routes - `TicketsBoardComponent` - Ticket board -- `RuleManagerComponent` - Admin filter rules - `AuditComponent` - Admin audit views - Monaco Editor integration - NgRx state management (including identity auth routes) @@ -111,7 +110,7 @@ This document provides a detailed breakdown of all system components, their resp - `ClientAgentCredentialEntity` - Agent credential storage - Ticket, comment, activity, automation, and body-generation entities - Statistics shadow and event entities (`statistics_*` tables) -- Global console filter rule entities and sync-target tables +- Egress event entities (`statistics_egress_events`, ingest cursors) - `ClientsRepository` - Data access layer - `ClientUsersRepository` - Client-user relationship data access - `ClientsService` - Business logic with permission checks @@ -134,7 +133,7 @@ This document provides a detailed breakdown of all system components, their resp - `AgentEntity` - Agent domain model - Deployment configuration and deployment run entities - `AgentEnvironmentVariableEntity` - Per-agent environment variables -- `RegexFilterRuleEntity` - Per-agent regex chat filters +- `AgentEgressEventEntity` - Aggregated egress guard decisions (agent entity also stores the effective `egressPolicy`, `messageFilters` and guard status) - `AgentMessageEventEntity` - Structured agent message stream events - `AgentsRepository` - Data access layer - `AgentsService` - Business logic @@ -187,7 +186,6 @@ This document provides a detailed breakdown of all system components, their resp - `deployments` - CI/CD state - `tickets`, `ticketAutomation` - Ticket board and automation - `clientAgentAutonomy` - Autonomy configuration - - `filterRules` - Global filter rules (admin) - **Facades**: - `ClientsFacade` - Client operations - `AgentsFacade` - Agent operations @@ -198,7 +196,7 @@ This document provides a detailed breakdown of all system components, their resp - `FilesFacade` - File operations - `EnvFacade` - Environment variables - `VcsFacade` - Git operations - - `StatisticsFacade`, `DeploymentsFacade`, `TicketsFacade`, `TicketAutomationFacade`, `ClientAgentAutonomyFacade`, `FilterRulesFacade` + - `StatisticsFacade`, `DeploymentsFacade`, `TicketsFacade`, `TicketAutomationFacade`, `ClientAgentAutonomyFacade` - **Effects**: NgRx effects for side effects - **Selectors**: State selectors @@ -278,8 +276,8 @@ graph TB ### Database -- **Agent Controller**: Clients, credentials, provisioning references, client users, tickets (`tickets`, `ticket_comments`, `ticket_activity`, `ticket_body_generation_sessions`), ticket automation (`client_agent_autonomy`, `ticket_automation`, `ticket_automation_run`, `ticket_automation_lease`, `ticket_automation_run_step`), statistics shadow and event tables (`statistics_users`, `statistics_clients`, `statistics_agents`, `statistics_provisioning_references`, `statistics_client_users`, `statistics_chat_io`, `statistics_chat_filter_drops`, `statistics_chat_filter_flags`, `statistics_entity_events`), global console filter tables (`agent_console_regex_filter_rules`, `agent_console_regex_filter_rule_clients`, `agent_console_regex_filter_rule_sync_targets`) -- **Agent Manager**: Agents, chat messages (`agent_chat_messages`), agent message events (`agent_message_events`), environment variables (`agent_environment_variables`), `deployment_configurations`, `deployment_runs`, per-agent regex rules (`regex_filter_rules`) +- **Agent Controller**: Clients, credentials, provisioning references, client users, tickets (`tickets`, `ticket_comments`, `ticket_activity`, `ticket_body_generation_sessions`), ticket automation (`client_agent_autonomy`, `ticket_automation`, `ticket_automation_run`, `ticket_automation_lease`, `ticket_automation_run_step`), statistics shadow and event tables (`statistics_users`, `statistics_clients`, `statistics_agents`, `statistics_provisioning_references`, `statistics_client_users`, `statistics_chat_io`, `statistics_chat_filter_drops`, `statistics_chat_filter_flags`, `statistics_entity_events`, `statistics_egress_events`), egress ingest cursors +- **Agent Manager**: Agents, chat messages (`agent_chat_messages`), agent message events (`agent_message_events`), environment variables (`agent_environment_variables`), `deployment_configurations`, `deployment_runs`, egress guard events (`agent_egress_events`) ## Related documentation diff --git a/docs/agenstra/deployment/background-jobs.md b/docs/agenstra/deployment/background-jobs.md index 99a22eab6..6735d333a 100644 --- a/docs/agenstra/deployment/background-jobs.md +++ b/docs/agenstra/deployment/background-jobs.md @@ -25,7 +25,7 @@ Job registration (queue names, repeatable intervals, job names) lives in: - `apps/agenstra/backend-agent-controller/src/queue/job-registry.ts` -Coordinators fan out **unit jobs** (one ticket, one import config, etc.). BullMQ `jobId` values prevent duplicate active work for the same entity. Custom job IDs use `.` separators and only allowed characters (alphanumeric, `.`, `-`, `_`, `~`), for example `coordinator.filter-rules-sync`. Colons and slashes are not valid. +Coordinators fan out **unit jobs** (one ticket, one import config, etc.). BullMQ `jobId` values prevent duplicate active work for the same entity. Custom job IDs use `.` separators and only allowed characters (alphanumeric, `.`, `-`, `_`, `~`), for example `coordinator.egress-events-ingest`. Colons and slashes are not valid. Notification delivery jobs on the same queue: @@ -42,7 +42,7 @@ Application update check (same queue): - `opencode-mcp-servers.refresh`: walk all cursor pages of the [official MCP Registry](https://registry.modelcontextprotocol.io/docs) `GET /v0.1/servers?version=latest` and upsert into `opencode_mcp_servers` (`OPENCODE_MCP_SERVERS_REFRESH_INTERVAL_MS`, default 24h). On first startup with an empty table, a one-shot bootstrap job is enqueued. See [Agent configuration](../features/agent-configuration.md). - `opencode-config-sync.coordinator` / `.unit`: retry durable per-agent OpenCode effective-config sync targets (`pending`/`failed`). Interval `OPENCODE_CONFIG_SYNC_INTERVAL_MS` (default 30s), batch `OPENCODE_CONFIG_SYNC_BATCH_SIZE`. - `opencode-layer-files-sync.coordinator` / `.unit`: retry layer-file emit targets (`pending`/`failed`). Interval `OPENCODE_LAYER_FILES_SYNC_INTERVAL_MS` (default 30s), batch `OPENCODE_LAYER_FILES_SYNC_BATCH_SIZE`. -- `filter-rules-sync.coordinator` / `.unit` / `filter-rules-sync.reconcile`: push regex filter rules to agent managers. +- `egress-events-ingest.coordinator` / `.unit`: pull aggregated egress guard decisions from each workspace's agent manager (`GET /api/egress/events`) into `statistics_egress_events` and publish `egress.*` notifications. Interval `EGRESS_EVENTS_INGEST_INTERVAL_MS` (default 30s; `0` disables), batch `EGRESS_EVENTS_INGEST_BATCH_SIZE` (default 10). See [Egress Filtering](../features/egress-filtering.md). See [Application updates](../features/application-updates.md). diff --git a/docs/agenstra/deployment/environment-configuration.md b/docs/agenstra/deployment/environment-configuration.md index 18c3cf836..24aab9f30 100644 --- a/docs/agenstra/deployment/environment-configuration.md +++ b/docs/agenstra/deployment/environment-configuration.md @@ -104,6 +104,11 @@ These variables tune the **Atlassian Cloud** import scheduler and provider on th - `CONTEXT_IMPORT_ITEM_BUDGET` - Soft cap on import items processed **per config per run** for scheduler and on-demand runs (default: `25`). - `ATLASSIAN_IMPORT_DISABLED` - When set to `true`, the Atlassian import provider skips work for import runs (connections and configs remain manageable via the admin API). +### Egress events ingestion + +- `EGRESS_EVENTS_INGEST_INTERVAL_MS` - Milliseconds between coordinator runs that pull egress guard events from agent managers (default: `30000`; `0` disables). See [Egress Filtering](../features/egress-filtering.md). +- `EGRESS_EVENTS_INGEST_BATCH_SIZE` - Workspaces polled per coordinator run (default: `10`). + ### Dynamic provider plugins Optional runtime extensions for provisioning and context import. See [Dynamic provider plugins](../features/dynamic-provider-plugins.md) for resolution order, post-build mounts, and export contract. @@ -180,6 +185,17 @@ Optional runtime extensions for provisioning and context import. See [Dynamic pr - `OPENCODE_AGENT_DOCKER_IMAGE` - Primary worker image (default: `ghcr.io/forepath/agenstra-manager-worker:latest`) +### Egress guard (web traffic filtering) + +- `AGENT_EGRESS_GUARD_ENABLED` - Allow per-agent egress guard sidecars on this manager (default: `true`). +- `AGENT_EGRESS_GUARD_IMAGE` - Guard image (default: `ghcr.io/forepath/agenstra-manager-egress-guard:latest`). +- `AGENT_EGRESS_GUARD_MEMORY_MB` - Memory limit per guard container (default: `256`). +- `AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED` - Collect guard decision events (default: `true`). +- `AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS` - Collector poll interval (default: `15000`). +- `AGENT_EGRESS_EVENTS_RETENTION_HOURS` - Event retention on the manager (default: `72`). + +See [Egress Filtering](../features/egress-filtering.md). + ### Agent Client Protocol (ACP) - `ACP_AUTO_APPROVE` - When not `false`, auto-select the first option on `session/request_permission` (default: enabled for headless agents). See [Agent Client Protocol](../ai-agents/agent-client-protocol.md). @@ -196,6 +212,11 @@ When building `Dockerfile.api` images that mount `/var/run/docker.sock`: - `GIT_AUTHOR_NAME` - Git commit author name (default: `Agenstra`) - `GIT_AUTHOR_EMAIL` - Git commit author email (default: `noreply@agenstra.com`) +### Egress events ingestion + +- `EGRESS_EVENTS_INGEST_INTERVAL_MS` - Milliseconds between coordinator runs that pull egress guard events from agent managers (default: `30000`; `0` disables). See [Egress Filtering](../features/egress-filtering.md). +- `EGRESS_EVENTS_INGEST_BATCH_SIZE` - Workspaces polled per coordinator run (default: `10`). + ### Dynamic provider plugins Optional runtime extensions for agents, CI/CD pipelines, and chat filters. See [Dynamic provider plugins](../features/dynamic-provider-plugins.md). diff --git a/docs/agenstra/deployment/system-requirements.md b/docs/agenstra/deployment/system-requirements.md index ddbe8825f..15dc2876a 100644 --- a/docs/agenstra/deployment/system-requirements.md +++ b/docs/agenstra/deployment/system-requirements.md @@ -65,7 +65,7 @@ Host path **`/opt/agents`** must exist and be writable by UID **10001** on manag ### PostgreSQL (pgvector) -Stores clients, tickets, knowledge embeddings, statistics, and filter rules. +Stores clients, tickets, knowledge embeddings, statistics, and egress events. | Profile | vCPU | Memory | Disk | Notes | | ------------------- | ---- | ------ | -------- | ------------------------------------------ | @@ -106,7 +106,7 @@ HTTP **3100** (REST `/api/`, Socket.IO namespaces under `/socket/`, VNC `/socket ### Controller Worker (`QUEUE_ROLE=worker`) -Processes unit jobs: context import, **knowledge embedding reindex**, filter-rule sync, autonomous ticket automation. Default **`QUEUE_WORKER_CONCURRENCY=5`**. +Processes unit jobs: context import, **knowledge embedding reindex**, egress event ingestion, autonomous ticket automation. Default **`QUEUE_WORKER_CONCURRENCY=5`**. | Profile | vCPU | Memory limit | Notes | | --------------- | ---- | ------------ | ------------------------------------------------- | @@ -129,7 +129,7 @@ The manager API orchestrates Docker containers; it does not replace Docker host ### Manager PostgreSQL -Agent metadata, chat history, deployment runs, and filter rules. +Agent metadata, chat history, deployment runs, and egress guard events. | Profile | vCPU | Memory | Disk | Notes | | --------------- | ---- | ------- | ------ | ----------------------------------------- | diff --git a/docs/agenstra/features/README.md b/docs/agenstra/features/README.md index 5ee687274..6a218a5c0 100644 --- a/docs/agenstra/features/README.md +++ b/docs/agenstra/features/README.md @@ -21,7 +21,8 @@ Agenstra provides a complete set of features for managing distributed AI agent i - **Authentication** Multiple authentication methods with configurable user registration - **Tickets and Workspaces** Ticket boards, migration, and automation on the controller - **Usage Statistics** Controller-backed usage and filter metrics (distinct from container stats) -- **Message Filter Rules** Global and per-agent regex policies for chat traffic +- **Message Filters** Layered regex policies for chat traffic +- **Egress Filtering** Per-environment web traffic filtering via an egress guard sidecar - **Atlassian import** Admin-managed site connections and import configs (Jira and Confluence) into tickets and knowledge - **Dynamic provider plugins** Extend provisioning, agents, pipelines, context import, and chat filters via env-configured packages - **OpenTelemetry** Optional Prometheus metrics and OTLP export (disabled by default) @@ -200,9 +201,13 @@ Aggregated usage and filter metrics on the agent controller for reporting and op - Chat I/O, filter drops, filter flags, and entity events - Complements live **container** statistics from the agent-manager -### [Message Filter Rules](./message-filter-rules.md) +### [Message Filters](./message-filters.md) -Regex-based rules at controller (global, admin) and manager (per-agent) levels. +Regex-based message filters (`message_filters`) layered global, workspace and environment; most restrictive wins. + +### [Egress Filtering](./egress-filtering.md) + +Per-environment web traffic filtering through an egress guard sidecar (Squid, dnsmasq, iptables) with audit events. **Key Capabilities**: @@ -261,7 +266,8 @@ graph TB AUTH[Authentication] TK[Tickets and Workspaces] ST[Usage Statistics] - FR[Message Filter Rules] + FR[Message Filters] + EG[Egress Filtering] DP[Dynamic Provider Plugins] SP --> CM diff --git a/docs/agenstra/features/agent-configuration.md b/docs/agenstra/features/agent-configuration.md index 66df44f11..fb159c0ff 100644 --- a/docs/agenstra/features/agent-configuration.md +++ b/docs/agenstra/features/agent-configuration.md @@ -16,23 +16,25 @@ Frontend copy never uses the vendor name “OpenCode”; APIs and worker sync ke ## UI tabs ↔ config fields -| Section | Tab | Config / secrets | -| ---------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | -| Core | General | `username`, `theme`, `model` (known provider/model picker), `share`, `keybinds`, `tui`, `server` | -| Core | Models | UI allow/deny + known provider/model pickers → `enabled_providers` / `disabled_providers` / `model_allow` / `model_deny` | -| Core | Providers | `providers` + credential secrets; catalog models locked read-only for built-ins | -| Extensions | MCP | `mcp_allow` / `mcp_deny` + `mcp.servers` + secrets/OAuth; registry catalog picker (built-in vs custom) | -| Extensions | Skills & instructions | `skills`, `instructions` | -| Extensions | Commands & plugins | `commands`, `plugins` | -| Extensions | Agents | `agents` | -| Extensions | References | `references` | -| Security | Permissions & policies | `permissions`, `experimental.policies` | -| Runtime | Websearch & network | `websearch`; Network → secrets `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_EXTRA_CA_CERTS` | -| Runtime | Compaction | `compaction` | -| Runtime | Formatters & attachments | `formatter`, `media` / attachment limits | -| Runtime | Warming | `warming` | -| Runtime | Tool output | `tool_output` | -| Advanced | Overrides | Layer `overrides` JSON (merged over structured `config`; same heredity locks) | +| Section | Tab | Config / secrets | +| ---------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Core | General | `username`, `theme`, `model` (known provider/model picker), `share`, `keybinds`, `tui`, `server` | +| Core | Models | UI allow/deny + known provider/model pickers → `enabled_providers` / `disabled_providers` / `model_allow` / `model_deny` | +| Core | Providers | `providers` + credential secrets; catalog models locked read-only for built-ins | +| Extensions | MCP | `mcp_allow` / `mcp_deny` + `mcp.servers` + secrets/OAuth; registry catalog picker (built-in vs custom) | +| Extensions | Skills & instructions | `skills`, `instructions` | +| Extensions | Commands & plugins | `commands`, `plugins` | +| Extensions | Agents | `agents` | +| Extensions | References | `references` | +| Security | Permissions & policies | `permissions`, `experimental.policies` | +| Security | Web traffic | `egress` (`mode`, `allow_hosts`, `deny_hosts`, `deny_url_patterns`, `network_allow`, `block_private_networks`, `block_ip_literals`, `log_allowed`); see [Egress Filtering](./egress-filtering.md) | +| Security | Message filters | `message_filters` (regex rules with `drop` / `replace` / `flag`); see [Message Filters](./message-filters.md) | +| Runtime | Websearch & network | `websearch`; Network → secrets `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_EXTRA_CA_CERTS` | +| Runtime | Compaction | `compaction` | +| Runtime | Formatters & attachments | `formatter`, `media` / attachment limits | +| Runtime | Warming | `warming` | +| Runtime | Tool output | `tool_output` | +| Advanced | Overrides | Layer `overrides` JSON (merged over structured `config`; same heredity locks) | Modes: **Structured** (sectioned vertical tabs) and **Raw JSON**. Both enforce heredity. @@ -42,6 +44,8 @@ Within a layer, the editable surface is `compose(config, overrides)` — structu - **Replace + lock:** `permissions`, provider allow/deny lists, model allow/deny lists, MCP allow/deny lists (`mcp_allow` / `mcp_deny`), `experimental.policies`, and similar security roots. Parent presence locks the path for children. - **Explicit locks (Global / Workspace):** each layer may store a `locks` array of JSON Pointers (e.g. `/model`, `/skills`, `/tabs/mcp`). Locks apply to **this layer and** lower layers **even when the parent value is unset**. Tab locks use `/tabs/{id}` and expand to all fields owned by that tab. Environment cannot author locks. +- **Egress (`egress`, tab lock `/tabs/egress`):** scalars `mode`, `block_private_networks`, `block_ip_literals`, `log_allowed` are **lock-on-presence** (the highest layer that sets one locks it for lower layers). Lists `allow_hosts`, `deny_hosts`, `deny_url_patterns`, `network_allow` are **additive** (lower layers append, never remove). Deny wins over allow; an empty allowlist means unrestricted. +- **Message filters (`message_filters`, tab lock `/tabs/message_filters`):** rules are additive across global → workspace → environment; inherited rules cannot be removed or edited. The most restrictive outcome wins (`drop` > `replace` > `flag`). Patterns with nested quantifiers (ReDoS risk) are rejected. - **Map merge:** `providers`, `mcp.servers`, `commands`, `agents`, `references`, `formatter` (object) — parent entry keys are inherited; children may add new keys and may set `disabled` / `hidden` on inherited keys (deep-merged), unless the map root is explicitly locked. Parent `formatter: false` fully locks formatters. When an inherited map entry is prohibited by allow/deny (this layer or a higher layer), the editor and **backend PUT** write a `{ disabled: true }` stub into the overlay (checkbox cannot override). Local prohibited entries are **deleted** from the overlay. - **Array concat:** `skills`, `instructions`, `plugins` — parent items stack; children append (skills are converted to OpenCode `{ paths, urls }` on worker sync), unless the array root is explicitly locked. - Responses expose `locks` (this layer), `lockedPaths` (from higher layers, presence + expanded explicit locks), and `inheritedAdditive`. diff --git a/docs/agenstra/features/agent-management.md b/docs/agenstra/features/agent-management.md index 005ee5ef5..ae630d13f 100644 --- a/docs/agenstra/features/agent-management.md +++ b/docs/agenstra/features/agent-management.md @@ -201,7 +201,7 @@ See also [Deployment](./deployment.md) for CI/CD tokens stored in deployment con ### Message filters -Per-agent regex filters live on the manager (`/api/agents-filters`). Global policies live on the controller and are documented in [Message Filter Rules](./message-filter-rules.md). +Message filters are part of the layered agent configuration and are enforced on the manager per environment; see [Message Filters](./message-filters.md). Outbound web traffic of each environment can additionally be filtered by an egress guard sidecar; see [Egress Filtering](./egress-filtering.md). ## API Endpoints @@ -226,7 +226,8 @@ For detailed API documentation, see the application and API reference docs linke - **[Version Control](./version-control.md)** Git operations in containers - **[WebSocket Communication](./websocket-communication.md)** Real-time communication - **[Usage Statistics](./usage-statistics.md)** Controller-side usage metrics -- **[Message Filter Rules](./message-filter-rules.md)** Regex filters +- **[Message Filters](./message-filters.md)** Regex filters +- **[Egress Filtering](./egress-filtering.md)** Web traffic filtering - **[Dynamic provider plugins](./dynamic-provider-plugins.md)** Custom agent, pipeline, and filter providers - **[Backend Agent Manager Application](../applications/backend-agent-manager.md)** Application details diff --git a/docs/agenstra/features/atlassian-import.md b/docs/agenstra/features/atlassian-import.md index d3beacc0b..09c4475bc 100644 --- a/docs/agenstra/features/atlassian-import.md +++ b/docs/agenstra/features/atlassian-import.md @@ -15,7 +15,7 @@ Tickets and knowledge trees already live on the controller ([Tickets and Workspa ## Access control -- **HTTP** All routes under `/api/imports/atlassian` require the same **admin check** as global [Message Filter Rules](./message-filter-rules.md): the user must be a **global admin** (`UserRole.ADMIN`) unless the request is authenticated as an **API key** (`isApiKeyAuth`), in which case the role check is bypassed for trusted automation (consistent with `/api/filter-rules`). +- **HTTP** All routes under `/api/imports/atlassian` require the same **admin check** as the global [agent configuration](./agent-configuration.md): the user must be a **global admin** (`UserRole.ADMIN`) unless the request is authenticated as an **API key** (`isApiKeyAuth`), in which case the role check is bypassed for trusted automation. - **Console** Route **`/imports/atlassian`** uses `authGuard` and `adminGuard` (same predicate as `/filters` and user management). The sidebar **Import** entry is shown only when that admin predicate is true. Non-admin interactive users receive **403** from the API and cannot open the admin UI for import settings. @@ -56,7 +56,7 @@ Boards do not embed full import administration; operators manage imports from th ## Related documentation - **[Tickets and Workspaces](./tickets-and-workspaces.md)** Target model for Jira imports -- **[Message Filter Rules](./message-filter-rules.md)** Same admin / API-key authorization pattern on the controller +- **[Agent Configuration](./agent-configuration.md)** Same admin / API-key authorization pattern on the controller - **[Authentication](./authentication.md)** Roles and authentication modes - **[Dynamic provider plugins](./dynamic-provider-plugins.md)** `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` and shared loader behavior - **[Backend Agent Controller](../applications/backend-agent-controller.md)** Nest application and `/api` prefix diff --git a/docs/agenstra/features/chat-interface.md b/docs/agenstra/features/chat-interface.md index 194dd8a95..6dba2250d 100644 --- a/docs/agenstra/features/chat-interface.md +++ b/docs/agenstra/features/chat-interface.md @@ -152,7 +152,7 @@ sequenceDiagram ## Related documentation - **[WebSocket Communication](./websocket-communication.md)** Real-time communication details -- **[Message Filter Rules](./message-filter-rules.md)** Regex policies affecting chat +- **[Message Filters](./message-filters.md)** Regex policies affecting chat - **[Agent Management](./agent-management.md)** Agent authentication - **[Agent Client Protocol](../ai-agents/agent-client-protocol.md)** ACP session suffixes diff --git a/docs/agenstra/features/dynamic-provider-plugins.md b/docs/agenstra/features/dynamic-provider-plugins.md index a0dcc132d..95d545fd1 100644 --- a/docs/agenstra/features/dynamic-provider-plugins.md +++ b/docs/agenstra/features/dynamic-provider-plugins.md @@ -159,7 +159,7 @@ Or inspect startup logs for `DynamicProviderRegistry` / loader errors. - **Agents** [Agent Management](./agent-management.md); `DYNAMIC_AGENT_PROVIDERS` extends agent types beyond the built-in OpenCode provider. - **Pipelines** [Deployment](./deployment.md); `DYNAMIC_PIPELINE_PROVIDERS` adds CI/CD backends. -- **Chat filters** [Message Filter Rules](./message-filter-rules.md); `DYNAMIC_CHAT_FILTERS` adds filter implementations on the manager. +- **Chat filters** [Message Filters](./message-filters.md); `DYNAMIC_CHAT_FILTERS` adds filter implementations on the manager. ## Docker Compose (optional plugins) diff --git a/docs/agenstra/features/egress-filtering.md b/docs/agenstra/features/egress-filtering.md new file mode 100644 index 000000000..25b77b965 --- /dev/null +++ b/docs/agenstra/features/egress-filtering.md @@ -0,0 +1,197 @@ +# Egress Filtering (Web Traffic) + +Agenstra can filter the **outbound network traffic of every agent environment**. Each environment gets a small **egress guard** sidecar container that owns the agent's network namespace. All egress is forced through the guard, evaluated against the layered **Web traffic** policy from the [agent configuration](./agent-configuration.md), and recorded as audit events. + +## Architecture + +```text + agent manager (Docker API) + | + creates / configures / reads logs + | + +---------------------------v----------------------------+ + | egress guard (agenstra-egress-, CAP_NET_ADMIN) | + | iptables - DNS -> dnsmasq, TCP/80 -> Squid, TCP/443 -> Squid | + | dnsmasq - DNS policy proxy (127.0.0.1:5353) | + | Squid 7 - explicit proxy 3128, HTTP intercept 3129, | + | TLS peek/splice/terminate 3130 | + +---------------------------^----------------------------+ + | shared network namespace + | (NetworkMode: container:) + agent container +``` + +- **Sidecar** One guard container per agent (`agenstra-egress-`, labels `io.agenstra.egress-guard=1` and `io.agenstra.agent-id`). Only the guard holds `CAP_NET_ADMIN`; the agent has none and cannot change the rules. +- **Network namespace sharing** The agent container joins the guard's namespace with `NetworkMode: container:`. The agent's published ports (OpenCode serve `4096`, VNC websockify `6080`) are therefore published on the guard. +- **Components** `iptables` redirects traffic; **Squid 7** makes web decisions; **dnsmasq** enforces DNS policy. Squid runs as UID `10901` and marks its upstream connections (`tcp_outgoing_mark`, which needs `CAP_NET_ADMIN`); netfilter exempts only **marked** traffic, so a process that merely switches to Squid's UID gains nothing. Squid resolves names through dnsmasq. dnsmasq runs as UID `10902` and may only reach the upstream resolvers from the guard's `resolv.conf` on port 53. +- **No TLS decryption** HTTPS is handled with Squid `peek` / `splice` / `terminate`. The guard reads the TLS SNI, never decrypts, and no CA has to be trusted by the agent. + +### Port layout (loopback, inside the shared namespace) + +| Port | Purpose | +| ---- | ---------------------------------------------------------------- | +| 3128 | Explicit forward proxy (`HTTP_PROXY` / `HTTPS_PROXY` point here) | +| 3129 | Transparent plaintext HTTP interception | +| 3130 | Transparent TLS peek / splice / terminate listener | +| 5353 | DNS policy proxy (dnsmasq) | + +While a guard enforces, the manager injects `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` (`localhost,127.0.0.1,::1`) into the agent environment. Transparent interception still applies to clients that ignore proxy variables. + +## Modes + +| Mode | Behavior | +| --------- | ------------------------------------------------------------------------------------------------------------- | +| `off` | No guard enforcement (default). Agents without a guard keep their legacy network. | +| `monitor` | Everything is allowed; would-be blocks are recorded as `observed` so a policy can be tested before enforcing. | +| `enforce` | Allow and deny lists apply. **Deny wins** over allow; an **empty allowlist means unrestricted**. | +| `offline` | All egress is blocked (`network_allow` exceptions are ignored). Loopback and inbound manager traffic work. | + +## Policy fields + +Stored under the `egress` root of a layer overlay (**Security -> Web traffic** in the editor). + +| Field | Kind | Description | +| ------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------- | +| `mode` | scalar | `off`, `monitor`, `enforce`, `offline` | +| `block_private_networks` | scalar | Block RFC 1918 / loopback / link-local style destinations (default `true`) | +| `block_ip_literals` | scalar | Block requests addressed to raw IPs instead of hostnames (default `false`) | +| `log_allowed` | scalar | Also record allowed requests (default `true`) | +| `allow_hosts` | list | Hostnames (`example.com` matches itself and subdomains; `*.example.com` accepted), IPs or CIDRs | +| `deny_hosts` | list | Same format; always wins | +| `deny_url_patterns` | list | POSIX extended regexes matched against **plain HTTP** URLs only (HTTPS URLs are not visible) | +| `network_allow` | list | Non-web L3/L4 exceptions: `{ protocol: tcp\|udp, ports: "22" or "1000-2000", destinations: [IP/CIDR], description }` | + +Limits: 5000 host entries, 500 URL patterns (512 characters each), 100 network rules with up to 64 destinations each. Explicit allow entries lift the private-network and IP-literal blocks for the matching destination. + +Evaluation order: offline -> deny list -> private networks -> IP literals -> URL patterns (plain HTTP) -> allowlist. + +URL patterns use the guard's **POSIX extended** regex engine: lookarounds, backreferences, `(?:…)` / named groups, lazy quantifiers (`*?`), JavaScript escape classes (`\d`, `\w`, `\s`, …) and a leading `#` are rejected, as are patterns at risk of catastrophic backtracking (nested quantifiers such as `(a+)+` or overlapping repeated alternatives such as `(a|aa)+`). Use bracket expressions (`[0-9]`, `[[:alnum:]]`) instead. + +### Heredity and locks + +Layers are **global -> workspace -> environment**. + +- **Scalars** (`mode`, `block_private_networks`, `block_ip_literals`, `log_allowed`) are **lock-on-presence**: the highest layer that sets a scalar wins and locks it for lower layers. +- **Lists** (`allow_hosts`, `deny_hosts`, `deny_url_patterns`, `network_allow`) are **additive**: lower layers may append but never remove inherited entries. +- **Allow lists are bounded by higher layers.** Once a higher layer declares `allow_hosts`, a lower layer's allow entries only take effect if a higher-layer entry covers them (e.g. `api.github.com` under `github.com`, `10.1.0.0/16` under `10.0.0.0/8`); uncovered entries are ignored. The same applies to `network_allow` (same protocol, port range and destinations within an inherited rule). Lower layers can therefore narrow, but never widen, an inherited allowlist. Without a higher-layer allowlist, lower layers may introduce one (which restricts traffic). To stop lower layers from declaring any exceptions at all, lock the tab. +- The whole tab can be locked with the explicit lock `/tabs/egress` on the global or workspace layer. + +The **Evaluate** call (`POST /api/clients/{id}/agents/{agentId}/egress/evaluate`) tests a hostname or URL against an environment's effective policy without sending traffic. It and `GET /api/clients/{id}/agents/{agentId}/egress` expose policy details, so they require `clients:read` plus workspace management access (same as the agent configuration endpoints). + +## What is and is not inspected + +| Traffic | Visibility | +| ------------------- | ------------------------------------------------------ | +| Plain HTTP (TCP/80) | Full URL, method and status; `deny_url_patterns` apply | +| HTTPS (TCP/443) | Hostname from TLS **SNI** only; no path, no content | +| DNS | Queries are filtered and logged by dnsmasq | +| Other TCP/UDP | Denied unless covered by `network_allow` | + +## How blocked connections look to clients + +Blocked traffic is dropped; the guard never substitutes content (no Squid error pages): + +| Traffic | Client sees | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| Plain HTTP (explicit proxy or intercepted) | TCP reset (`deny_info TCP_RESET`), e.g. Chrome `ERR_CONNECTION_RESET`, curl `Connection reset by peer` | +| HTTPS via the explicit proxy (`CONNECT`) | Bare `403` on the tunnel request, e.g. Chrome `ERR_TUNNEL_CONNECTION_FAILED` | +| Intercepted HTTPS | Connection closed during the TLS handshake | +| DNS | `NXDOMAIN` | +| Other protocols | ICMP port unreachable / TCP reset | + +A reset carries no reason, so clients cannot tell a policy block from a network fault. Tools that retry on connection resets (some package managers, browsers on reused keep-alive connections) may retry a few times before failing, and each attempt is recorded as a separate blocked decision. Use the audit log or the **Evaluate** call to see why a destination was blocked. + +## Protocol denials + +- **QUIC / HTTP/3** (UDP/443) is denied so clients fall back to TCP, where SNI is inspectable. +- **DNS over TLS** (TCP/853) is denied to prevent bypassing the DNS policy. +- **Undeclared protocols** (anything not web, DNS or `network_allow`) are denied. + +## Fail-closed behavior + +- The guard installs **lockdown** netfilter rules before anything else: no policy means no egress (loopback and established inbound connections excepted). +- At agent creation the manager provisions the guard and applies the policy **before** the agent container exists; on failure the guard is removed and creation fails. +- Policy bundles are staged, validated (`squid -k parse`, `dnsmasq --test`, `iptables-restore --test`) and only then activated. An **invalid bundle never replaces the last known good one**, and the failure is reported (`egress.policy_failed`). +- Squid and dnsmasq are supervised and restarted if they exit; after a restart the last applied bundle is re-activated. +- If the guard container disappears, the agent loses its interfaces and has no connectivity until it is restarted to join a new guard. +- If a policy that requires a guard (`monitor`, `enforce`, `offline`) cannot be provisioned for an agent that is **not** inside a guard namespace yet (e.g. switching an existing agent from `off` to `enforce` while the guard image is unavailable), the manager **stops the agent** and refuses to start it until the guard exists. The next successful sync recreates and starts it inside the guard namespace. Agents already inside a guard keep running under the last known good bundle. +- Setting `AGENT_EGRESS_GUARD_ENABLED=false` on a manager makes provisioning of enforcing policies fail rather than silently skipping them. + +## Upstream proxy chaining + +If the layered **Network** secrets define `HTTPS_PROXY` / `HTTP_PROXY` (and optionally `NO_PROXY`), the guard's Squid uses it as a **parent proxy** (plain `http://` proxies only; credentials in the URL are forwarded when Squid can represent them). The agent itself is pointed at the guard, not at the corporate proxy. + +## Audit events and notifications + +The manager's events collector polls each guard's counters and logs and stores **aggregated** decisions per agent (`allowed`, `blocked`, `observed`, `error`; kinds `web`, `dns`, `network`, `guard`; rules `offline`, `deny_host`, `private_network`, `ip_literal`, `deny_url`, `not_allowlisted`, `quic`, `dns_over_tls`, `undeclared_protocol`, `guard`). + +The controller ingests them with the BullMQ job **`egress-events-ingest`** (coordinator + per-workspace unit job) which calls the manager's `GET /api/egress/events?afterSeq=` feed with a monotonic cursor and writes `statistics_egress_events`. + +- **Console** The **Audit** page (`/audit`) shows web traffic charts and a table next to filter drops. +- **API** `GET /api/clients/{id}/statistics/egress-events` (and the global `/api/statistics/egress-events`) with filters `agentId`, `kind`, `decision`, `from`, `to`, `search`, `limit`, `offset`; `GET /api/clients/{id}/agents/{agentId}/egress` for status, policy and recent events. +- **Notifications / webhooks** `egress.blocked`, `egress.observed`, `egress.guard_failed`, `egress.policy_applied`, `egress.policy_failed` (see [Webhooks](./webhooks.md)). Blocked / observed notifications are summarized per ingestion run to avoid floods. + +## Configuration + +### Agent manager + +| Variable | Default | Description | +| --------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------ | +| `AGENT_EGRESS_GUARD_ENABLED` | `true` | Allow guard provisioning on this manager | +| `AGENT_EGRESS_GUARD_IMAGE` | `ghcr.io/forepath/agenstra-manager-egress-guard:latest` | Guard image | +| `AGENT_EGRESS_GUARD_MEMORY_MB` | `256` | Memory limit per guard | +| `AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED` | `true` | Collect guard events | +| `AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS` | `15000` | Collector poll interval | +| `AGENT_EGRESS_EVENTS_RETENTION_HOURS` | `72` | Retention of events on the manager (the controller keeps its copy) | + +The manager needs access to the Docker socket (as for agent containers) and the ability to grant `CAP_NET_ADMIN` to the guard. + +### Agent controller + +| Variable | Default | Description | +| ---------------------------------- | ------- | --------------------------------------------- | +| `EGRESS_EVENTS_INGEST_INTERVAL_MS` | `30000` | Coordinator interval (`0` disables ingestion) | +| `EGRESS_EVENTS_INGEST_BATCH_SIZE` | `10` | Workspaces polled per coordinator run | + +See [Background Jobs](../deployment/background-jobs.md). + +## Building the guard image + +```bash +npx nx run agenstra-backend-agent-manager:egress-guard-container-image # test tag, loaded locally +npx nx run agenstra-backend-agent-manager:egress-guard-container-image:release # push ghcr.io/forepath/agenstra-manager-egress-guard +``` + +The image is built from `apps/agenstra/backend-agent-manager/Dockerfile.egress-guard` (Alpine, Squid 7, dnsmasq, iptables-nft). `start-containers` builds the test image automatically. + +## Limitations + +- **Hostname filtering of HTTPS relies on SNI / Host.** Squid runs with `host_verify_strict on`, so a client cannot connect to an IP different from what DNS resolves for the requested name. **Domain fronting on shared CDNs is not prevented**: a client can present an allowed SNI while the encrypted `Host` header targets another site on the same CDN. Use `block_ip_literals`, narrow allowlists and `offline` mode where this matters. +- Encrypted ClientHello (ECH) hides the SNI; such connections cannot be matched by name. +- **DNS policy bypass for root agents.** dnsmasq's upstream queries are identified by its UID, which a process running as root inside the agent container can assume. Such a process can query the upstream resolvers directly (bypassing the DNS allow/deny lists and enabling DNS tunnelling), but **not** reach web or other destinations: those are only exempted for Squid's marked connections. Run agent workloads as a non-root user where DNS exfiltration matters. +- HTTPS paths and content are never inspected; `deny_url_patterns` only apply to plain HTTP. +- Only IPv4/IPv6 TCP and UDP are handled; the guard is not a full IDS. +- Changing the mode of an agent that ran on a legacy network recreates the agent container once so it can join the guard namespace. + +## Troubleshooting + +| Symptom | Check | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| No connectivity after enabling | `GET /clients/{id}/agents/{agentId}/egress` for `status` / last error; run `docker logs agenstra-egress-` | +| Environment fails to create | Guard image missing (`AGENT_EGRESS_GUARD_IMAGE`) or guard not ready within 30 s; fix and retry | +| Policy not applied (`egress.policy_failed`) | The last known good bundle stays active; inspect the error and correct the rule that Squid or dnsmasq rejected | +| Legitimate host blocked | Use **Evaluate**; remember deny wins and private networks are blocked by default | +| Nothing in the Audit page | Check `AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED`, `EGRESS_EVENTS_INGEST_INTERVAL_MS`, and the manager's `/egress/events` feed | +| Agent offline after guard removal | Restart the environment so it joins a fresh guard | + +## Related documentation + +- **[Agent Configuration](./agent-configuration.md)** Layers, tabs and locks +- **[Message Filters](./message-filters.md)** Chat message policy in the same Security section +- **[Usage Statistics](./usage-statistics.md)** Egress statistics +- **[Webhooks](./webhooks.md)** Egress notifications +- **[Backend Agent Manager](../applications/backend-agent-manager.md)** `/egress/events`, `/agents/{id}/egress` + +--- + +_Diagram: `libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd`._ diff --git a/docs/agenstra/features/message-filter-rules.md b/docs/agenstra/features/message-filter-rules.md deleted file mode 100644 index d0a13b17c..000000000 --- a/docs/agenstra/features/message-filter-rules.md +++ /dev/null @@ -1,34 +0,0 @@ -# Message Filter Rules - -Agenstra supports **regex-based message filter rules** at two levels: **global** rules on the agent controller (synced to workspaces) and **per-agent** rules on each agent manager. Together they control how chat traffic is evaluated, dropped, or flagged before or during agent processing. - -## Overview - -- **Global rules (controller)** Stored on the agent controller. Only **administrators** may list, create, update, or delete them via `/api/filter-rules`. Responses include a summary of how rules apply per workspace where relevant (see OpenAPI). -- **Per-agent rules (manager)** Stored on the agent manager for a specific deployment. CRUD is available at `/api/agents-filters` (and by id) with normal agent-manager HTTP authentication. - -The console exposes global rule management on route **`/filters`** (admin guard). Per-agent rules are managed in agent context through the same APIs the UI calls via the controller proxy where applicable. - -## Why two tiers? - -- **Global** Organization-wide policies (PII patterns, banned phrases, uniform compliance) pushed consistently across clients/workspaces the controller manages. -- **Per-agent** Fine tuning for a single agent or team without changing global policy (experimentation, repo-specific jargon). - -Rule **priority** and matching semantics are defined in the OpenAPI schemas (`CreateFilterRuleDto`, `CreateRegexFilterRuleDto`, etc.). Dropped and flagged messages contribute to [Usage Statistics](./usage-statistics.md) endpoints. - -## Related documentation - -- **[Chat Interface](./chat-interface.md)** Where filtered traffic originates -- **[Usage Statistics](./usage-statistics.md)** Filter drops and flags metrics -- **[Atlassian import](./atlassian-import.md)** Another admin-only controller surface (`/imports/atlassian`) with the same API-key carve-out pattern -- **[Backend Agent Controller](../applications/backend-agent-controller.md)** `/filter-rules` -- **[Backend Agent Manager](../applications/backend-agent-manager.md)** `/agents-filters` - -## API references - -- [Agent Controller OpenAPI](/spec/agent-controller/openapi.yaml) `/filter-rules`, `/filter-rules/{id}` -- [Agent Manager OpenAPI](/spec/agent-manager/openapi.yaml) `/agents-filters`, `/agents-filters/count`, `/agents-filters/{id}` - ---- - -_For validation rules, priority ordering, and sync behavior, use the OpenAPI specifications._ diff --git a/docs/agenstra/features/message-filters.md b/docs/agenstra/features/message-filters.md new file mode 100644 index 000000000..0e02928f1 --- /dev/null +++ b/docs/agenstra/features/message-filters.md @@ -0,0 +1,79 @@ +# Message Filters + +Agenstra evaluates **regex-based message filters** on chat traffic between users and agents. Filters are part of the layered [agent configuration](./agent-configuration.md) under the root key **`message_filters`**, so they follow the same **global → workspace → environment** heredity as other security policy. The previous standalone filter rule APIs and admin page were removed. + +## Overview + +- **Where they are authored** Console **Agent configuration** (`/agent-config`) for the global layer, and the workspace / environment agent configuration modals in the chat view. Open **Security → Message filters**. +- **Where they are enforced** On the agent manager, synchronously for every chat message. The controller resolves the effective rule list and ships it to the manager with every config sync. +- **No dedicated endpoints** Rules are stored in the layer's config document (`GET/PUT /admin/opencode/config`, `/clients/:id/opencode/config`, `/clients/:id/agents/:agentId/opencode/config`). The old `/filter-rules` (controller) and `/agents-filters` (manager) routes no longer exist. + +## Rule shape + +Each rule is an object in the `message_filters` array: + +| Field | Required | Description | +| ------------- | ------------- | --------------------------------------------------- | +| `name` | yes | Unique within the layer (max 120 characters) | +| `pattern` | yes | JavaScript regular expression (max 2000 characters) | +| `flags` | no | Subset of `g i m s u y d` (default `g`) | +| `direction` | no | `incoming`, `outgoing` or `bidirectional` | +| `action` | yes | `drop`, `replace` or `flag` | +| `replacement` | for `replace` | Replacement text (max 2000 characters) | +| `enabled` | no | Set `false` to keep a rule without evaluating it | + +A layer may hold at most 200 rules. + +## Layering and precedence + +Rules are evaluated **global → workspace → environment**. Lower layers may **append** rules but never remove or edit inherited ones. The **most restrictive outcome wins**: + +1. Any matching **`drop`** rule drops the message (the first match by layer precedence is reported). +2. Otherwise every matching **`replace`** rule rewrites the message, in precedence order. +3. Otherwise any matching **`flag`** rule flags the message; content is unchanged. + +The whole tab can be locked with the explicit lock `/tabs/message_filters` on the global or workspace layer, exactly like other tabs (see [Agent configuration](./agent-configuration.md#heredity)). + +## Validation and ReDoS protection + +Workspace managers can author rules that run on every chat message, so patterns are validated on save (UI, API and raw JSON) and again when compiled: + +- Invalid regular expressions, unknown keys, unsupported flags and duplicate names are rejected. +- Patterns containing **nested quantifiers** such as `(a+)+`, `(\w*)*` or `(x{1,9})+`, or **repeated groups with overlapping alternatives** such as `(a|aa)+` or `(\w|x)*`, are rejected because they risk catastrophic backtracking. Repeated alternations are accepted when every alternative starts with a distinct literal character (e.g. `(foo|bar)+`). +- `replacement` is mandatory for `replace` rules. + +## Notifications and statistics + +- A drop or flag publishes the **`message_filter.triggered`** notification (renamed from `filter_rule.triggered`; see [Webhooks](./webhooks.md)). +- Dropped and flagged messages contribute to [Usage Statistics](./usage-statistics.md) (`filter-drops`, `filter-flags`) and are charted on the **Audit** page (`/audit`). +- Saving a layer publishes `agent_config.updated`, including the changed security roots. + +## Migration from filter rules + +The tables `regex_filter_rules` (manager) and the controller's global rule tables, the `filter-rules-sync` background jobs (`FILTER_RULES_SYNC_*`, their Redis repeatables are removed on startup), the `/filters` console page and the `filter_rules:write` PAT scope were removed. + +The database migrations move existing rules automatically: + +- **Controller** Global rules become `message_filters` entries on the global layer; workspace-scoped rules are added to each linked workspace layer (named `Migrated rule N ()`, at most 200 per layer). Rules with invalid regexes, unsupported flags or catastrophic-backtracking risk are skipped and listed in the migration log so they can be recreated manually. +- **Manager** Until the controller's first sync after the upgrade, existing agents keep enforcing the manager's legacy rules (copied into the agent's stored message filters). +- **Semantics** The first matching rule by priority no longer decides alone: the **most restrictive outcome wins** (drop > replace > flag) and multiple replace rules chain. Review migrated flag rules that precede drop rules. +- Webhook subscriptions to `filter_rule.triggered` are moved to `message_filter.triggered`; the `filter_rule.created`, `filter_rule.updated` and `filter_rule.deleted` events are no longer emitted (configuration changes publish `agent_config.updated`). +- Upgrade the controller and the agent managers together: an older controller still calls the removed manager filter endpoint. + +## Related documentation + +- **[Agent Configuration](./agent-configuration.md)** Layers, tabs and locks +- **[Egress Filtering](./egress-filtering.md)** The sibling security policy for outbound web traffic +- **[Chat Interface](./chat-interface.md)** Where filtered traffic originates +- **[Usage Statistics](./usage-statistics.md)** Filter drops and flags metrics +- **[Webhooks](./webhooks.md)** `message_filter.triggered` +- **[Backend Agent Controller](../applications/backend-agent-controller.md)** +- **[Backend Agent Manager](../applications/backend-agent-manager.md)** + +## API references + +- [Agent Controller OpenAPI](/spec/agent-controller/openapi.yaml) `/admin/opencode/config`, `/clients/{id}/opencode/config`, `/clients/{id}/agents/{agentId}/opencode/config`, `/clients/{id}/statistics/filter-drops`, `/clients/{id}/statistics/filter-flags` + +--- + +_For validation limits and exact schemas, use the OpenAPI specifications and the shared `util-opencode-config` library (`message-filters.ts`)._ diff --git a/docs/agenstra/features/opentelemetry.md b/docs/agenstra/features/opentelemetry.md index 4f1f854ec..f0004c557 100644 --- a/docs/agenstra/features/opentelemetry.md +++ b/docs/agenstra/features/opentelemetry.md @@ -71,17 +71,15 @@ When BullMQ is configured, the controller registers **`queueNames`** for the not Domain gauges (meter `forepath.agenstra`, polled every 60 seconds when OTEL is enabled): -| Gauge | Labels | Description | -| ----------------------------------- | -------------------------------------------- | --------------------------------------------------- | -| `agenstra.workspaces` | - | Workspace (client) count | -| `agenstra.tickets` | `client_id`, `status` | Ticket counts per workspace and status | -| `agenstra.chat_messages` | `client_id`, `direction`, `interaction_kind` | Cumulative chat I/O message counts from statistics | -| `agenstra.chat_words` | `client_id`, `direction`, `interaction_kind` | Cumulative word counts | -| `agenstra.chat_chars` | `client_id`, `direction`, `interaction_kind` | Cumulative character counts | -| `agenstra.filter_drops` | `client_id`, `direction`, `filter_type` | Messages dropped by chat filters | -| `agenstra.filter_flags` | `client_id`, `direction`, `filter_type` | Messages flagged/modified by filters | -| `agenstra.filter_rules` | `enabled` | Console regex filter rules | -| `agenstra.filter_rule_sync_targets` | `sync_status` | Rule sync targets (`pending` / `synced` / `failed`) | +| Gauge | Labels | Description | +| ------------------------ | -------------------------------------------- | -------------------------------------------------- | +| `agenstra.workspaces` | - | Workspace (client) count | +| `agenstra.tickets` | `client_id`, `status` | Ticket counts per workspace and status | +| `agenstra.chat_messages` | `client_id`, `direction`, `interaction_kind` | Cumulative chat I/O message counts from statistics | +| `agenstra.chat_words` | `client_id`, `direction`, `interaction_kind` | Cumulative word counts | +| `agenstra.chat_chars` | `client_id`, `direction`, `interaction_kind` | Cumulative character counts | +| `agenstra.filter_drops` | `client_id`, `direction`, `filter_type` | Messages dropped by chat filters | +| `agenstra.filter_flags` | `client_id`, `direction`, `filter_type` | Messages flagged/modified by filters | ### Application updates @@ -113,7 +111,6 @@ The agent manager enables shared host and runtime metrics. When OTEL is enabled | `agenstra.manager.chat_words` | `actor`, `filtered` | Approximate word counts from message text | | `agenstra.manager.chat_chars` | `actor`, `filtered` | Character counts from message text | | `agenstra.manager.filter_triggers` | `actor` | Messages marked filtered (manager has no drop/flag split) | -| `agenstra.manager.filter_rules` | `direction`, `filter_type` | Local regex filter rules (zero-filled for all combos) | ## Optional OTLP export diff --git a/docs/agenstra/features/search-indexes.md b/docs/agenstra/features/search-indexes.md index 965ac0abb..705c191b7 100644 --- a/docs/agenstra/features/search-indexes.md +++ b/docs/agenstra/features/search-indexes.md @@ -10,7 +10,7 @@ Search queries always scope by authenticated client/workspace access (fail close ## Console list UX -Agent console lists (workspaces/clients, environments/agents, filter rules, and related tables) use **infinite scroll** where applicable: first page on entry, then append on scroll with `fpcInfiniteScroll` and `fpc-list-append-footer` from `@forepath/shared/frontend/ui-components`. Append errors pause loading until retry. Ticket board lane cards use CDK virtual scroll. +Agent console lists (workspaces/clients, environments/agents, and related tables) use **infinite scroll** where applicable: first page on entry, then append on scroll with `fpcInfiniteScroll` and `fpc-list-append-footer` from `@forepath/shared/frontend/ui-components`. Append errors pause loading until retry. Ticket board lane cards use CDK virtual scroll. ### Search pattern diff --git a/docs/agenstra/features/usage-statistics.md b/docs/agenstra/features/usage-statistics.md index 001cc6a0b..e51d336d9 100644 --- a/docs/agenstra/features/usage-statistics.md +++ b/docs/agenstra/features/usage-statistics.md @@ -10,16 +10,19 @@ Statistics help operators and admins answer questions such as: how much assistan Base path pattern: `/api/clients/{id}/statistics/...` (requires access to that client). -| Area | Purpose | -| ----------------- | ------------------------------------------------------------------------------------------------ | -| **Summary** | Aggregate counts such as messages, words, characters, and filter-related drops for the workspace | -| **Chat I/O** | Time-bounded or bucketed chat input and output metrics for usage reporting | -| **Filter drops** | Metrics when messages are dropped by filtering rules | -| **Filter flags** | Metrics when messages are flagged rather than dropped | -| **Entity events** | Auditable-style entity change events relevant to statistics dashboards | +| Area | Purpose | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Summary** | Aggregate counts such as messages, words, characters, and filter-related drops for the workspace | +| **Chat I/O** | Time-bounded or bucketed chat input and output metrics for usage reporting | +| **Filter drops** | Metrics when messages are dropped by filtering rules | +| **Filter flags** | Metrics when messages are flagged rather than dropped | +| **Egress events** | Web traffic decisions (`allowed`, `blocked`, `observed`, `error`) per agent from the egress guard, ingested from the managers; filter by `agentId`, `kind`, `decision`, `from`, `to`, `search` | +| **Entity events** | Auditable-style entity change events relevant to statistics dashboards | Global equivalents exist under `/api/statistics/...` for cross-workspace views where your role allows aggregated access (see OpenAPI for query parameters such as optional `clientId` scoping). +Egress events live in `statistics_egress_events` and are served by `GET /api/clients/{id}/statistics/egress-events` (global: `GET /api/statistics/egress-events`). The console **Audit** page (`/audit`) charts web traffic next to filter drops and flags. See [Egress Filtering](./egress-filtering.md) and [Message Filters](./message-filters.md). + ## Who can access what - **Client-scoped routes** Same client access rules as other controller APIs (plus admin visibility where documented). @@ -34,13 +37,15 @@ Global equivalents exist under `/api/statistics/...` for cross-workspace views w ## Related documentation +- **[Egress Filtering](./egress-filtering.md)** Web traffic audit events +- **[Message Filters](./message-filters.md)** Filter drops and flags - **[Backend Agent Controller](../applications/backend-agent-controller.md)** Endpoint group listing - **[Agent Management](./agent-management.md)** Container stats and agent lifecycle - **[Authentication](./authentication.md)** Roles and access modes ## API reference -- [Agent Controller OpenAPI](/spec/agent-controller/openapi.yaml) `/clients/{id}/statistics/*` and `/statistics/*` +- [Agent Controller OpenAPI](/spec/agent-controller/openapi.yaml) `/clients/{id}/statistics/*` (including `egress-events`) and `/statistics/*` --- diff --git a/docs/agenstra/features/webhooks.md b/docs/agenstra/features/webhooks.md index 1de68115b..01cc475e5 100644 --- a/docs/agenstra/features/webhooks.md +++ b/docs/agenstra/features/webhooks.md @@ -40,12 +40,15 @@ Events are published from the **agent-controller** service after proxied operati **Comments:** Adding a comment publishes `ticket.comment.created`. Comments cannot be updated or deleted via the API, so there are no update/delete comment events. -### Chat and filter rules +### Chat, filters and egress - `chat_message.created`, user or agent chat message in a workspace session (payload may include `chatId`) - `chat_session.created`, `chat_session.updated`, `chat_session.deleted` — user-visible chat session lifecycle for an agent environment -- `filter_rule.created`, `filter_rule.updated`, `filter_rule.deleted` -- `filter_rule.triggered`, a filter rule dropped or flagged a message +- `message_filter.triggered`, a message filter dropped, rewrote or flagged a message (renamed from `filter_rule.triggered`) +- `agent_config.updated`, an agent configuration layer was saved (global, workspace or environment) +- `egress.blocked`, `egress.observed`, egress guard blocked a request or would have blocked it in `monitor` mode (summarized per ingestion run) +- `egress.guard_failed`, an egress guard sidecar reported an error +- `egress.policy_applied`, `egress.policy_failed`, an egress policy bundle was activated or rejected (last known good stays active) ### Agent environment variables @@ -116,7 +119,7 @@ Author email is never included. Ticket body `content` is never included on ticke `kind` is `primary` or `user`. Delete events always include `id` and `agentId`; other fields may be null when only identifiers are available. -### `filter_rule.triggered` +### `message_filter.triggered` ```json { @@ -128,11 +131,58 @@ Author email is never included. Ticket body `content` is never included on ticke "reason": "Matched secret pattern", "wordCount": 3, "charCount": 11, - "userId": "user-uuid" + "userId": "user-uuid", + "messagePreview": "..." } ``` -`status` is `dropped` or `filtered`. +`status` is `dropped` or `filtered`; the rule name is in `filterDisplayName`. + +### `agent_config.updated` + +```json +{ + "layer": "workspace", + "clientId": "client-uuid", + "agentId": null, + "changedRoots": ["egress", "message_filters"], + "securityRootsChanged": ["egress", "message_filters"], + "locksChanged": false, + "secretKeysChanged": [], + "updatedByUserId": "user-uuid" +} +``` + +Values are never included, only the changed root keys. Global-layer saves carry no `clientId` and are delivered instance-wide. + +### `egress.blocked` / `egress.observed` + +```json +{ + "agentId": "agent-uuid", + "kind": "web", + "decision": "blocked", + "host": "example.org", + "rule": "not_allowlisted", + "matchedEntry": null, + "matchedSource": null, + "method": "CONNECT", + "count": 4, + "firstSeenAt": "2026-07-01T10:00:00.000Z", + "lastSeenAt": "2026-07-01T10:00:12.000Z", + "omittedDecisions": 0 +} +``` + +`egress.observed` has the same shape and is emitted in `monitor` mode. See [Egress Filtering](./egress-filtering.md). + +### `egress.guard_failed` / `egress.policy_applied` / `egress.policy_failed` + +```json +{ "agentId": "agent-uuid", "message": "Egress guard error", "occurredAt": "2026-07-01T10:00:00.000Z" } +``` + +`egress.guard_failed` uses the payload above. `egress.policy_applied` and `egress.policy_failed` carry `{ "agentId", "mode", "revision", "error" }`. ### `environment.created` / `environment.updated` / `environment.deleted` diff --git a/docs/agenstra/getting-started.md b/docs/agenstra/getting-started.md index d8ac40955..23c6375ff 100644 --- a/docs/agenstra/getting-started.md +++ b/docs/agenstra/getting-started.md @@ -269,7 +269,7 @@ You're now ready to: - Open **Tickets** from the navigation (`/tickets`) after selecting a workspace to manage tickets, automation, and migration between clients ([Tickets and Workspaces](./features/tickets-and-workspaces.md)) - Use workspace-level **usage statistics** in the console where exposed to review chat volume and filter metrics ([Usage Statistics](./features/usage-statistics.md)); these differ from live **container** stats in the chat view -- **Global administrators** can open **Filters** (`/filters`) for message filter rules and **Import** (`/imports/atlassian`) for Atlassian site connections and Jira/Confluence import configurations ([Atlassian import](./features/atlassian-import.md)) +- **Global administrators** can open **Agent configuration** (`/agent-config`) for global message filters and web traffic policy and **Import** (`/imports/atlassian`) for Atlassian site connections and Jira/Confluence import configurations ([Atlassian import](./features/atlassian-import.md)) ## Next Steps diff --git a/docs/agenstra/security/container-images.md b/docs/agenstra/security/container-images.md index 07ee68336..92fa77144 100644 --- a/docs/agenstra/security/container-images.md +++ b/docs/agenstra/security/container-images.md @@ -28,6 +28,8 @@ When the agent manager creates an agent, it bind-mounts host paths into child co | ---------- | ------------------------- | ---------- | ---------------- | | `opencode` | `agenstra-manager-worker` | `/app` | `/app` | +The per-agent **egress guard** sidecar uses its own image (`agenstra-manager-egress-guard`, built by the `egress-guard-container-image` target); it is not an agent type. + Each agent worker mounts its host directory at the provider **`basePath`** (typically `/app`). ### Host directory ownership @@ -74,6 +76,7 @@ docker exec id -u # expect: 0 only for the en ## Manager and controller API images +- The **egress guard** image (`agenstra-manager-egress-guard`, Alpine) runs a root supervisor (tini) with Squid as UID `10901` and dnsmasq as UID `10902`; the manager creates it with all capabilities dropped except `NET_ADMIN`, `NET_BIND_SERVICE` (lets Squid keep `NET_ADMIN` to mark its upstream sockets), `SETUID`/`SETGID`, `CHOWN`, `DAC_OVERRIDE`, `FOWNER` and `KILL`, `no-new-privileges`, IPv6 disabled and no host ports other than the agent's forwarded ports. See [Egress Filtering](../features/egress-filtering.md). - Mount **`/var/run/docker.sock`** only when the service must create agent containers on the host. - Build-time **`DOCKER_GID`** (default **995**) should match the host `docker` group when known at build time: `stat -c '%g' /var/run/docker.sock` diff --git a/docs/agenstra/security/operational-hardening.md b/docs/agenstra/security/operational-hardening.md index 2e88aedf8..1264517ff 100644 --- a/docs/agenstra/security/operational-hardening.md +++ b/docs/agenstra/security/operational-hardening.md @@ -102,6 +102,13 @@ Accepted risk: **AR-002** in **[Accepted risks](./accepted-risks.md)**. - The agent console connects using `controller.vncWebsocketUrl`, or derives `ws(s)://…/socket/vnc` from `controller.websocketUrl` (same host/port as Socket.IO). - Desktop session starts as non-privileged user **`agenstra`** (no greeter). Worker image is larger due to XFCE + Chromium. +## Agent egress (web traffic) guard + +- Each agent environment can be placed behind a per-agent **egress guard** sidecar that owns the agent's network namespace (`NetworkMode: container:`). Only the guard holds `CAP_NET_ADMIN` (agents in a guard namespace additionally lose `NET_ADMIN` / `NET_RAW`). Netfilter exempts only Squid's **marked** upstream connections (a mark cannot be set without `CAP_NET_ADMIN`) and dnsmasq's DNS queries to the configured resolvers — never a bare UID. +- Fail closed: lockdown rules are installed before any policy, invalid policy bundles never replace the last known good one, QUIC, DNS over TLS and undeclared protocols are denied, enforcing policies are not silently skipped when `AGENT_EGRESS_GUARD_ENABLED=false`, and agents whose required guard cannot be provisioned are stopped instead of running unfiltered. +- Lower configuration layers can narrow but never widen an inherited allowlist; URL patterns and message filters reject regexes at risk of catastrophic backtracking. +- HTTPS is filtered by SNI without decryption. `host_verify_strict` prevents connecting to an IP other than what DNS resolves for the name, but **domain fronting on shared CDNs is not prevented**. Details and limitations: [Egress Filtering](../features/egress-filtering.md). + ## Origin allowlist (unsafe HTTP methods) Browser-originated **state-changing** requests can be restricted by origin allowlist middleware on backends (see `origin-allowlist.middleware.ts` in identity util-auth). Configure per deployment expectations. diff --git a/graph/graph.json b/graph/graph.json index ffc029a9a..98c271987 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-12T09:30:06.008Z", + "generatedAt": "2026-10-13T05:26:32.243Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -1489,6 +1489,7 @@ "test", "api-container-image", "worker-container-image", + "egress-guard-container-image", "start-containers", "openapi-client-js", "sbom" @@ -3925,15 +3926,6 @@ "projectName": "agenstra-frontend-data-access-agent-console" } }, - { - "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "type": "service", - "attrs": { - "path": "libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "languageOrKind": "ts", - "projectName": "agenstra-frontend-data-access-agent-console" - } - }, { "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", "type": "service", @@ -4216,24 +4208,6 @@ ] } }, - { - "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules", - "type": "state", - "attrs": { - "path": "libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules", - "languageOrKind": "ts", - "projectName": "agenstra-frontend-data-access-agent-console", - "sliceName": "filter-rules", - "memberFiles": [ - "filter-rules.actions.ts", - "filter-rules.effects.ts", - "filter-rules.facade.ts", - "filter-rules.reducer.ts", - "filter-rules.selectors.ts", - "filter-rules.types.ts" - ] - } - }, { "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge", "type": "state", @@ -4501,10 +4475,10 @@ } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/filter-rules-sync.mmd", + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/egress-events-ingest.mmd", "type": "diagram", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/docs/filter-rules-sync.mmd", + "path": "libs/domains/agenstra/backend/feature-agent-controller/docs/egress-events-ingest.mmd", "languageOrKind": "mmd", "projectName": "agenstra-backend-feature-agent-controller" } @@ -4788,6 +4762,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts", "type": "controller", @@ -4878,15 +4861,6 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "type": "controller", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", "type": "controller", @@ -5076,33 +5050,6 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/create-filter-rule.dto.ts", - "type": "dto", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/create-filter-rule.dto.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/filter-rule-response.dto.ts", - "type": "dto", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/filter-rule-response.dto.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/update-filter-rule.dto.ts", - "type": "dto", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/update-filter-rule.dto.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/knowledge/create-knowledge-node.dto.ts", "type": "dto", @@ -5229,6 +5176,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-egress-event.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-egress-event.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-entity-event.dto.ts", "type": "dto", @@ -5409,33 +5365,6 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts", - "type": "entity", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.ts", - "type": "entity", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.ts", - "type": "entity", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/atlassian-site-connection.entity.ts", "type": "entity", @@ -5472,6 +5401,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/egress-event-ingest-cursor.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/egress-event-ingest-cursor.entity.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/external-import-config.entity.ts", "type": "entity", @@ -5643,6 +5581,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-egress-event.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-egress-event.entity.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-entity-event.entity.ts", "type": "entity", @@ -5850,15 +5797,6 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts", - "type": "module", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/identity-email-bridge.module.ts", "type": "module", @@ -5940,6 +5878,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics.repository.ts", "type": "repository", @@ -6039,15 +5986,6 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", - "type": "service", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-instance-scrape.service.ts", "type": "service", @@ -6255,6 +6193,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts", "type": "service", @@ -6291,24 +6238,6 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts", - "type": "service", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", - "type": "service", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-controller" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-board-realtime.service.ts", "type": "service", @@ -10527,28 +10456,28 @@ } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/lifecycle.mmd", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd", "type": "diagram", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/docs/lifecycle.mmd", + "path": "libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd", "languageOrKind": "mmd", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/overview.mmd", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/lifecycle.mmd", "type": "diagram", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/docs/overview.mmd", + "path": "libs/domains/agenstra/backend/feature-agent-manager/docs/lifecycle.mmd", "languageOrKind": "mmd", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/regex-filters.mmd", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/overview.mmd", "type": "diagram", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/docs/regex-filters.mmd", + "path": "libs/domains/agenstra/backend/feature-agent-manager/docs/overview.mmd", "languageOrKind": "mmd", "projectName": "agenstra-backend-feature-agent-manager" } @@ -10689,28 +10618,28 @@ } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts", "type": "controller", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", "type": "controller", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts", "type": "controller", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } @@ -10823,6 +10752,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-egress.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-egress.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-models-response.dto.ts", "type": "dto", @@ -10931,15 +10869,6 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-regex-filter-rule.dto.ts", - "type": "dto", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-regex-filter-rule.dto.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-manager" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-vnc-session-response.dto.ts", "type": "dto", @@ -11075,15 +11004,6 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/regex-filter-rule-response.dto.ts", - "type": "dto", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/regex-filter-rule-response.dto.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-manager" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/reply-permission.dto.ts", "type": "dto", @@ -11156,15 +11076,6 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-regex-filter-rule.dto.ts", - "type": "dto", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-regex-filter-rule.dto.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-manager" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/upsert-workspace-configuration-override.dto.ts", "type": "dto", @@ -11201,6 +11112,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-egress-event.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-egress-event.entity.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-environment-variable.entity.ts", "type": "entity", @@ -11255,15 +11175,6 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.ts", - "type": "entity", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-manager" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/workspace-configuration-override.entity.ts", "type": "entity", @@ -11408,6 +11319,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.ts", "type": "repository", @@ -11462,15 +11382,6 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts", - "type": "repository", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-manager" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/workspace-configuration-overrides.repository.ts", "type": "repository", @@ -11507,6 +11418,24 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", "type": "service", @@ -11544,37 +11473,37 @@ } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts", "type": "service", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-runtime-refresh.service.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", "type": "service", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-runtime-refresh.service.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-runtime-refresh.service.ts", "type": "service", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-runtime-refresh.service.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } }, { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts", "type": "service", "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts", "languageOrKind": "ts", "projectName": "agenstra-backend-feature-agent-manager" } @@ -11660,24 +11589,6 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts", - "type": "service", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-manager" - } - }, - { - "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts", - "type": "service", - "attrs": { - "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts", - "languageOrKind": "ts", - "projectName": "agenstra-backend-feature-agent-manager" - } - }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/vnc-bridge.service.ts", "type": "service", @@ -14124,6 +14035,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/agenstra/features/egress-filtering.md", + "type": "doc", + "attrs": { + "path": "docs/agenstra/features/egress-filtering.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/agenstra/features/email-notifications.md", "type": "doc", @@ -14149,10 +14068,10 @@ } }, { - "id": "file:docs/agenstra/features/message-filter-rules.md", + "id": "file:docs/agenstra/features/message-filters.md", "type": "doc", "attrs": { - "path": "docs/agenstra/features/message-filter-rules.md", + "path": "docs/agenstra/features/message-filters.md", "languageOrKind": "md" } }, @@ -15690,61 +15609,6 @@ "specKind": "openapi" } }, - { - "id": "api:HTTP:GET:/filter-rules", - "type": "endpoint", - "attrs": { - "method": "GET", - "pathOrChannel": "/filter-rules", - "operationId": "listFilterRules", - "summary": "List regex filter rules (admin only)", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:POST:/filter-rules", - "type": "endpoint", - "attrs": { - "method": "POST", - "pathOrChannel": "/filter-rules", - "operationId": "createFilterRule", - "summary": "Create filter rule (admin only)", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:GET:/filter-rules/{id}", - "type": "endpoint", - "attrs": { - "method": "GET", - "pathOrChannel": "/filter-rules/{id}", - "operationId": "getFilterRule", - "summary": "Get filter rule (admin only)", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:PUT:/filter-rules/{id}", - "type": "endpoint", - "attrs": { - "method": "PUT", - "pathOrChannel": "/filter-rules/{id}", - "operationId": "updateFilterRule", - "summary": "Update filter rule (admin only)", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:DELETE:/filter-rules/{id}", - "type": "endpoint", - "attrs": { - "method": "DELETE", - "pathOrChannel": "/filter-rules/{id}", - "operationId": "deleteFilterRule", - "summary": "Delete filter rule (admin only)", - "specKind": "openapi" - } - }, { "id": "api:HTTP:GET:/imports/atlassian/connections", "type": "endpoint", @@ -17340,6 +17204,17 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/clients/{id}/statistics/egress-events", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/statistics/egress-events", + "operationId": "getClientStatisticsEgressEvents", + "summary": "Get egress events for a client", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/clients/{id}/statistics/entity-events", "type": "endpoint", @@ -17395,6 +17270,17 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/statistics/egress-events", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/statistics/egress-events", + "operationId": "getStatisticsEgressEvents", + "summary": "Get egress events (aggregate or by client)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/statistics/entity-events", "type": "endpoint", @@ -17725,6 +17611,28 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/egress", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/agents/{agentId}/egress", + "operationId": "getClientAgentEgress", + "summary": "Get agent egress guard details", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/clients/{id}/agents/{agentId}/egress/evaluate", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/clients/{id}/agents/{agentId}/egress/evaluate", + "operationId": "evaluateClientAgentEgressDestination", + "summary": "Evaluate a destination against the effective egress policy", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/opencode/config", "type": "endpoint", @@ -17824,6 +17732,15 @@ "specKind": "openapi" } }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:agent_config.updated", + "type": "webhook-event", + "attrs": { + "eventName": "agent_config.updated", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, { "id": "webhook-event:agenstra-backend-feature-agent-controller:agent.acp.permission_denied", "type": "webhook-event", @@ -17987,64 +17904,82 @@ } }, { - "id": "webhook-event:agenstra-backend-feature-agent-controller:environment.created", + "id": "webhook-event:agenstra-backend-feature-agent-controller:egress.blocked", "type": "webhook-event", "attrs": { - "eventName": "environment.created", + "eventName": "egress.blocked", "projectName": "agenstra-backend-feature-agent-controller", "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, { - "id": "webhook-event:agenstra-backend-feature-agent-controller:environment.deleted", + "id": "webhook-event:agenstra-backend-feature-agent-controller:egress.guard_failed", "type": "webhook-event", "attrs": { - "eventName": "environment.deleted", + "eventName": "egress.guard_failed", "projectName": "agenstra-backend-feature-agent-controller", "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, { - "id": "webhook-event:agenstra-backend-feature-agent-controller:environment.updated", + "id": "webhook-event:agenstra-backend-feature-agent-controller:egress.observed", "type": "webhook-event", "attrs": { - "eventName": "environment.updated", + "eventName": "egress.observed", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:egress.policy_applied", + "type": "webhook-event", + "attrs": { + "eventName": "egress.policy_applied", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:egress.policy_failed", + "type": "webhook-event", + "attrs": { + "eventName": "egress.policy_failed", "projectName": "agenstra-backend-feature-agent-controller", "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, { - "id": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.created", + "id": "webhook-event:agenstra-backend-feature-agent-controller:environment.created", "type": "webhook-event", "attrs": { - "eventName": "filter_rule.created", + "eventName": "environment.created", "projectName": "agenstra-backend-feature-agent-controller", "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, { - "id": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.deleted", + "id": "webhook-event:agenstra-backend-feature-agent-controller:environment.deleted", "type": "webhook-event", "attrs": { - "eventName": "filter_rule.deleted", + "eventName": "environment.deleted", "projectName": "agenstra-backend-feature-agent-controller", "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, { - "id": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.triggered", + "id": "webhook-event:agenstra-backend-feature-agent-controller:environment.updated", "type": "webhook-event", "attrs": { - "eventName": "filter_rule.triggered", + "eventName": "environment.updated", "projectName": "agenstra-backend-feature-agent-controller", "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, { - "id": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.updated", + "id": "webhook-event:agenstra-backend-feature-agent-controller:message_filter.triggered", "type": "webhook-event", "attrs": { - "eventName": "filter_rule.updated", + "eventName": "message_filter.triggered", "projectName": "agenstra-backend-feature-agent-controller", "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } @@ -22516,6 +22451,15 @@ "specKind": "asyncapi" } }, + { + "id": "api:channel:agents/egressStatusChanged", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/egressStatusChanged", + "summary": "Emitted to sockets logged in to the affected agent whenever the egress guard (web traffic filtering)\nstatus of that environment changes (guard applied, failed, or deactivated). Payload is the egress status\nof the agent; use `GET /api/agents/{id}/egress` for the full policy and recent events.\n", + "specKind": "asyncapi" + } + }, { "id": "api:channel:agents/workspaceIndexChanged", "type": "channel", @@ -22777,68 +22721,24 @@ } }, { - "id": "api:HTTP:GET:/agents-filters", + "id": "api:HTTP:GET:/egress/events", "type": "endpoint", "attrs": { "method": "GET", - "pathOrChannel": "/agents-filters", - "operationId": "listRegexFilterRules", - "summary": "List regex filter rules", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:POST:/agents-filters", - "type": "endpoint", - "attrs": { - "method": "POST", - "pathOrChannel": "/agents-filters", - "operationId": "createRegexFilterRule", - "summary": "Create regex filter rule", + "pathOrChannel": "/egress/events", + "operationId": "listEgressEvents", + "summary": "List egress guard decision events (ingestion feed)", "specKind": "openapi" } }, { - "id": "api:HTTP:GET:/agents-filters/count", + "id": "api:HTTP:GET:/agents/{id}/egress", "type": "endpoint", "attrs": { "method": "GET", - "pathOrChannel": "/agents-filters/count", - "operationId": "countRegexFilterRules", - "summary": "Count regex filter rules", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:GET:/agents-filters/{id}", - "type": "endpoint", - "attrs": { - "method": "GET", - "pathOrChannel": "/agents-filters/{id}", - "operationId": "getRegexFilterRule", - "summary": "Get regex filter rule by id", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:PUT:/agents-filters/{id}", - "type": "endpoint", - "attrs": { - "method": "PUT", - "pathOrChannel": "/agents-filters/{id}", - "operationId": "updateRegexFilterRule", - "summary": "Update regex filter rule", - "specKind": "openapi" - } - }, - { - "id": "api:HTTP:DELETE:/agents-filters/{id}", - "type": "endpoint", - "attrs": { - "method": "DELETE", - "pathOrChannel": "/agents-filters/{id}", - "operationId": "deleteRegexFilterRule", - "summary": "Delete regex filter rule", + "pathOrChannel": "/agents/{id}/egress", + "operationId": "getAgentEgress", + "summary": "Get egress guard details of an agent", "specKind": "openapi" } }, @@ -26414,6 +26314,116 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-egress-filtering-web-traffic", + "type": "concept", + "attrs": { + "title": "Egress Filtering (Web Traffic)", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "egress-filtering-web-traffic", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-modes", + "type": "concept", + "attrs": { + "title": "Modes", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "modes", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-policy-fields", + "type": "concept", + "attrs": { + "title": "Policy fields", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "policy-fields", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-what-is-and-is-not-inspected", + "type": "concept", + "attrs": { + "title": "What is and is not inspected", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "what-is-and-is-not-inspected", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-how-blocked-connections-look-to-clients", + "type": "concept", + "attrs": { + "title": "How blocked connections look to clients", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "how-blocked-connections-look-to-clients", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-protocol-denials", + "type": "concept", + "attrs": { + "title": "Protocol denials", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "protocol-denials", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-fail-closed-behavior", + "type": "concept", + "attrs": { + "title": "Fail-closed behavior", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "fail-closed-behavior", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-upstream-proxy-chaining", + "type": "concept", + "attrs": { + "title": "Upstream proxy chaining", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "upstream-proxy-chaining", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-audit-events-and-notifications", + "type": "concept", + "attrs": { + "title": "Audit events and notifications", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "audit-events-and-notifications", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-building-the-guard-image", + "type": "concept", + "attrs": { + "title": "Building the guard image", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "building-the-guard-image", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-limitations", + "type": "concept", + "attrs": { + "title": "Limitations", + "docPath": "docs/agenstra/features/egress-filtering.md", + "sectionAnchor": "limitations", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-email-notifications-agenstra", "type": "concept", @@ -26535,22 +26545,62 @@ } }, { - "id": "concept:agenstra-message-filter-rules", + "id": "concept:agenstra-message-filters", "type": "concept", "attrs": { - "title": "Message Filter Rules", - "docPath": "docs/agenstra/features/message-filter-rules.md", - "sectionAnchor": "message-filter-rules", + "title": "Message Filters", + "docPath": "docs/agenstra/features/message-filters.md", + "sectionAnchor": "message-filters", "domain": "agenstra" } }, { - "id": "concept:agenstra-why-two-tiers", + "id": "concept:agenstra-rule-shape", "type": "concept", "attrs": { - "title": "Why two tiers?", - "docPath": "docs/agenstra/features/message-filter-rules.md", - "sectionAnchor": "why-two-tiers", + "title": "Rule shape", + "docPath": "docs/agenstra/features/message-filters.md", + "sectionAnchor": "rule-shape", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-layering-and-precedence", + "type": "concept", + "attrs": { + "title": "Layering and precedence", + "docPath": "docs/agenstra/features/message-filters.md", + "sectionAnchor": "layering-and-precedence", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-validation-and-redos-protection", + "type": "concept", + "attrs": { + "title": "Validation and ReDoS protection", + "docPath": "docs/agenstra/features/message-filters.md", + "sectionAnchor": "validation-and-redos-protection", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-notifications-and-statistics", + "type": "concept", + "attrs": { + "title": "Notifications and statistics", + "docPath": "docs/agenstra/features/message-filters.md", + "sectionAnchor": "notifications-and-statistics", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-migration-from-filter-rules", + "type": "concept", + "attrs": { + "title": "Migration from filter rules", + "docPath": "docs/agenstra/features/message-filters.md", + "sectionAnchor": "migration-from-filter-rules", "domain": "agenstra" } }, @@ -27634,6 +27684,16 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-agent-egress-web-traffic-guard", + "type": "concept", + "attrs": { + "title": "Agent egress (web traffic) guard", + "docPath": "docs/agenstra/security/operational-hardening.md", + "sectionAnchor": "agent-egress-web-traffic-guard", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-origin-allowlist-unsafe-http-methods", "type": "concept", @@ -33841,6 +33901,11 @@ "to": "project:agenstra-frontend-util-configuration", "type": "depends_on" }, + { + "from": "project:agenstra-frontend-data-access-agent-console", + "to": "project:agenstra-shared-util-opencode-config", + "type": "depends_on" + }, { "from": "project:agenstra-frontend-data-access-agent-console", "to": "project:shared-frontend-util-configuration", @@ -34073,17 +34138,17 @@ }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "project:shared-shared-util-network-address", + "to": "project:agenstra-shared-util-opencode-config", "type": "depends_on" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "project:shared-backend-util-otel", + "to": "project:shared-shared-util-network-address", "type": "depends_on" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "project:agenstra-shared-util-opencode-config", + "to": "project:shared-backend-util-otel", "type": "depends_on" }, { @@ -37386,11 +37451,6 @@ "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/files.service.ts", "type": "contains" }, - { - "from": "project:agenstra-frontend-data-access-agent-console", - "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "type": "contains" - }, { "from": "project:agenstra-frontend-data-access-agent-console", "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", @@ -37491,11 +37551,6 @@ "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/files", "type": "contains" }, - { - "from": "project:agenstra-frontend-data-access-agent-console", - "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules", - "type": "contains" - }, { "from": "project:agenstra-frontend-data-access-agent-console", "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge", @@ -37583,7 +37638,7 @@ }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/filter-rules-sync.mmd", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/egress-events-ingest.mmd", "type": "contains" }, { @@ -37741,6 +37796,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts", @@ -37791,11 +37851,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", @@ -37901,21 +37956,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/create-client.dto.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/create-filter-rule.dto.ts", - "type": "contains" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/filter-rule-response.dto.ts", - "type": "contains" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/update-filter-rule.dto.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/knowledge/create-knowledge-node.dto.ts", @@ -37986,6 +38026,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-chat-io.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-egress-event.dto.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-entity-event.dto.ts", @@ -38086,21 +38131,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agenstra-attachment.entity.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts", - "type": "contains" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.ts", - "type": "contains" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/atlassian-site-connection.entity.ts", @@ -38121,6 +38151,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/client-opencode-config.entity.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/egress-event-ingest-cursor.entity.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/external-import-config.entity.ts", @@ -38216,6 +38251,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-client.entity.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-egress-event.entity.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-entity-event.entity.ts", @@ -38331,11 +38371,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/context-import.module.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/identity-email-bridge.module.ts", @@ -38381,6 +38416,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/provisioning-references.repository.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics.repository.ts", @@ -38436,11 +38476,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-instance-scrape.service.ts", @@ -38558,32 +38593,27 @@ }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts", - "type": "contains" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/local-embedding.provider.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-config.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-sync-marker.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/local-embedding.provider.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-config.service.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-sync-marker.service.ts", "type": "contains" }, { @@ -40913,17 +40943,17 @@ }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/lifecycle.mmd", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/overview.mmd", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/lifecycle.mmd", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/regex-filters.mmd", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/docs/overview.mmd", "type": "contains" }, { @@ -41003,17 +41033,17 @@ }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts", "type": "contains" }, { @@ -41076,6 +41106,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-egress.dto.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-models-response.dto.ts", @@ -41136,11 +41171,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-file.dto.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-regex-filter-rule.dto.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-vnc-session-response.dto.ts", @@ -41216,11 +41246,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/rebase.dto.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/regex-filter-rule-response.dto.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/reply-permission.dto.ts", @@ -41261,11 +41286,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-environment-variable.dto.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-regex-filter-rule.dto.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/upsert-workspace-configuration-override.dto.ts", @@ -41286,6 +41306,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-directory-index.entity.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-egress-event.entity.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-environment-variable.entity.ts", @@ -41316,11 +41341,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/deployment-run.entity.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/workspace-configuration-override.entity.ts", @@ -41401,6 +41421,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.ts", @@ -41431,11 +41456,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/deployment-runs.repository.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/workspace-configuration-overrides.repository.ts", @@ -41456,6 +41476,16 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-directory-index.service.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", @@ -41478,22 +41508,22 @@ }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-runtime-refresh.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-runtime-refresh.service.ts", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts", "type": "contains" }, { @@ -41541,16 +41571,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/prompt-context-composer.service.ts", "type": "contains" }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts", - "type": "contains" - }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts", - "type": "contains" - }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/vnc-bridge.service.ts", @@ -43016,31 +43036,6 @@ "to": "api:HTTP:DELETE:/users/{id}/2fa/totp", "type": "contains" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", - "to": "api:HTTP:GET:/filter-rules", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", - "to": "api:HTTP:POST:/filter-rules", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", - "to": "api:HTTP:GET:/filter-rules/{id}", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", - "to": "api:HTTP:PUT:/filter-rules/{id}", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", - "to": "api:HTTP:DELETE:/filter-rules/{id}", - "type": "contains" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:GET:/imports/atlassian/connections", @@ -43766,6 +43761,11 @@ "to": "api:HTTP:GET:/clients/{id}/statistics/filter-flags", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/clients/{id}/statistics/egress-events", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:GET:/clients/{id}/statistics/entity-events", @@ -43791,6 +43791,11 @@ "to": "api:HTTP:GET:/statistics/filter-flags", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/statistics/egress-events", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:GET:/statistics/entity-events", @@ -43941,6 +43946,16 @@ "to": "api:HTTP:DELETE:/clients/{id}/opencode/config/files/{path}", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/egress", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/egress/evaluate", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/opencode/config", @@ -43991,6 +44006,11 @@ "to": "api:HTTP:GET:/otel/metrics", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:agent_config.updated", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "webhook-event:agenstra-backend-feature-agent-controller:agent.acp.permission_denied", @@ -44083,37 +44103,47 @@ }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "webhook-event:agenstra-backend-feature-agent-controller:environment.created", + "to": "webhook-event:agenstra-backend-feature-agent-controller:egress.blocked", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "webhook-event:agenstra-backend-feature-agent-controller:environment.deleted", + "to": "webhook-event:agenstra-backend-feature-agent-controller:egress.guard_failed", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "webhook-event:agenstra-backend-feature-agent-controller:environment.updated", + "to": "webhook-event:agenstra-backend-feature-agent-controller:egress.observed", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.created", + "to": "webhook-event:agenstra-backend-feature-agent-controller:egress.policy_applied", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.deleted", + "to": "webhook-event:agenstra-backend-feature-agent-controller:egress.policy_failed", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.triggered", + "to": "webhook-event:agenstra-backend-feature-agent-controller:environment.created", "type": "contains" }, { "from": "project:agenstra-backend-feature-agent-controller", - "to": "webhook-event:agenstra-backend-feature-agent-controller:filter_rule.updated", + "to": "webhook-event:agenstra-backend-feature-agent-controller:environment.deleted", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:environment.updated", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:message_filter.triggered", "type": "contains" }, { @@ -46451,6 +46481,11 @@ "to": "api:channel:agents/opencodeConfigSynced", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/egressStatusChanged", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", "to": "api:channel:agents/workspaceIndexChanged", @@ -46573,32 +46608,12 @@ }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", - "to": "api:HTTP:GET:/agents-filters", + "to": "api:HTTP:GET:/egress/events", "type": "contains" }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", - "to": "api:HTTP:POST:/agents-filters", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", - "to": "api:HTTP:GET:/agents-filters/count", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", - "to": "api:HTTP:GET:/agents-filters/{id}", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", - "to": "api:HTTP:PUT:/agents-filters/{id}", - "type": "contains" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", - "to": "api:HTTP:DELETE:/agents-filters/{id}", + "to": "api:HTTP:GET:/agents/{id}/egress", "type": "contains" }, { @@ -48981,6 +48996,81 @@ "to": "concept:agenstra-related-documentation", "type": "contains" }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-egress-filtering-web-traffic", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-architecture", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-modes", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-policy-fields", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-what-is-and-is-not-inspected", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-how-blocked-connections-look-to-clients", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-protocol-denials", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-fail-closed-behavior", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-upstream-proxy-chaining", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-audit-events-and-notifications", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-configuration", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-building-the-guard-image", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-limitations", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-troubleshooting", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "concept:agenstra-related-documentation", + "type": "contains" + }, { "from": "file:docs/agenstra/features/email-notifications.md", "to": "concept:agenstra-email-notifications-agenstra", @@ -49062,27 +49152,47 @@ "type": "contains" }, { - "from": "file:docs/agenstra/features/message-filter-rules.md", - "to": "concept:agenstra-message-filter-rules", + "from": "file:docs/agenstra/features/message-filters.md", + "to": "concept:agenstra-message-filters", "type": "contains" }, { - "from": "file:docs/agenstra/features/message-filter-rules.md", + "from": "file:docs/agenstra/features/message-filters.md", "to": "concept:agenstra-overview", "type": "contains" }, { - "from": "file:docs/agenstra/features/message-filter-rules.md", - "to": "concept:agenstra-why-two-tiers", + "from": "file:docs/agenstra/features/message-filters.md", + "to": "concept:agenstra-rule-shape", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/message-filters.md", + "to": "concept:agenstra-layering-and-precedence", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/message-filters.md", + "to": "concept:agenstra-validation-and-redos-protection", "type": "contains" }, { - "from": "file:docs/agenstra/features/message-filter-rules.md", + "from": "file:docs/agenstra/features/message-filters.md", + "to": "concept:agenstra-notifications-and-statistics", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/message-filters.md", + "to": "concept:agenstra-migration-from-filter-rules", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/message-filters.md", "to": "concept:agenstra-related-documentation", "type": "contains" }, { - "from": "file:docs/agenstra/features/message-filter-rules.md", + "from": "file:docs/agenstra/features/message-filters.md", "to": "concept:agenstra-api-references", "type": "contains" }, @@ -49911,6 +50021,11 @@ "to": "concept:agenstra-agent-virtual-desktop-vnc", "type": "contains" }, + { + "from": "file:docs/agenstra/security/operational-hardening.md", + "to": "concept:agenstra-agent-egress-web-traffic-guard", + "type": "contains" + }, { "from": "file:docs/agenstra/security/operational-hardening.md", "to": "concept:agenstra-origin-allowlist-unsafe-http-methods", @@ -54896,6 +55011,16 @@ "to": "api:HTTP:GET:/clients/{id}/statistics/filter-flags", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:GET:/clients/{id}/statistics/egress-events", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/clients/{id}/statistics/egress-events", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", "to": "api:HTTP:GET:/clients/{id}/statistics/entity-events", @@ -54976,6 +55101,26 @@ "to": "api:HTTP:DELETE:/clients/{id}/opencode/config/files/{path}", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/egress", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/egress", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/egress/evaluate", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/egress/evaluate", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/opencode/config", @@ -55196,56 +55341,6 @@ "to": "api:HTTP:DELETE:/imports/atlassian/configs/{id}/markers", "type": "implements" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "to": "api:HTTP:GET:/filter-rules", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "api:HTTP:GET:/filter-rules", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "to": "api:HTTP:POST:/filter-rules", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "api:HTTP:POST:/filter-rules", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "to": "api:HTTP:GET:/filter-rules/{id}", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "api:HTTP:GET:/filter-rules/{id}", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "to": "api:HTTP:PUT:/filter-rules/{id}", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "api:HTTP:PUT:/filter-rules/{id}", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "to": "api:HTTP:DELETE:/filter-rules/{id}", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-controller", - "to": "api:HTTP:DELETE:/filter-rules/{id}", - "type": "implements" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", "to": "api:HTTP:GET:/knowledge", @@ -55506,6 +55601,16 @@ "to": "api:HTTP:GET:/statistics/filter-flags", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts", + "to": "api:HTTP:GET:/statistics/egress-events", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/statistics/egress-events", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts", "to": "api:HTTP:GET:/statistics/entity-events", @@ -59471,66 +59576,6 @@ "to": "api:HTTP:DELETE:/projects/{projectId}/time-entries/{timeEntryId}", "type": "implements" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", - "to": "api:HTTP:GET:/agents-filters", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "api:HTTP:GET:/agents-filters", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", - "to": "api:HTTP:POST:/agents-filters", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "api:HTTP:POST:/agents-filters", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", - "to": "api:HTTP:GET:/agents-filters/count", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "api:HTTP:GET:/agents-filters/count", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", - "to": "api:HTTP:GET:/agents-filters/{id}", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "api:HTTP:GET:/agents-filters/{id}", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", - "to": "api:HTTP:PUT:/agents-filters/{id}", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "api:HTTP:PUT:/agents-filters/{id}", - "type": "implements" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", - "to": "api:HTTP:DELETE:/agents-filters/{id}", - "type": "implements" - }, - { - "from": "project:agenstra-backend-feature-agent-manager", - "to": "api:HTTP:DELETE:/agents-filters/{id}", - "type": "implements" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", "to": "api:HTTP:GET:/agents", @@ -59751,6 +59796,16 @@ "to": "api:HTTP:POST:/agents/{id}/opencode/mcp/{name}/auth/callback", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:GET:/agents/{id}/egress", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:GET:/agents/{id}/egress", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", "to": "api:HTTP:GET:/agents/{agentId}/files", @@ -60361,6 +60416,11 @@ "to": "api:HTTP:POST:/agents/{id}/opencode/mcp/{name}/auth/callback", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-permissions.controller.ts", + "to": "api:HTTP:GET:/agents/{id}/egress", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-permissions.controller.ts", "to": "api:HTTP:GET:/agents/{agentId}/files", @@ -60721,6 +60781,11 @@ "to": "api:HTTP:POST:/agents/{id}/opencode/mcp/{name}/auth/callback", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:GET:/agents/{id}/egress", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", "to": "api:HTTP:GET:/agents/{agentId}/files", @@ -61501,6 +61566,31 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-opencode-config-proxy.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/client-users.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.ts", @@ -61701,6 +61791,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/atlassian-site-connection.service.ts", @@ -61726,11 +61821,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/providers/import/atlassian-external-import.provider.ts", "type": "injects" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", - "type": "injects" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts", @@ -62003,7 +62093,7 @@ }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts", "type": "injects" }, { @@ -62056,16 +62146,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-console-status-realtime.service.ts", "type": "injects" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts", - "type": "injects" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", - "type": "injects" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-instance-scrape.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", @@ -62352,37 +62432,37 @@ "type": "injects" }, { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/local-embedding.provider.ts", + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", "type": "injects" }, { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-config.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts", "type": "injects" }, { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-config.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.ts", + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.ts", "type": "injects" }, { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts", "type": "injects" }, { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/embeddings/local-embedding.provider.ts", "type": "injects" }, { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-config.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", "type": "injects" }, { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/external-import-config.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.ts", "type": "injects" }, @@ -62561,6 +62641,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-user-sync.service.ts", "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/users.repository.ts", @@ -62586,6 +62671,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-automation-chat-realtime.service.ts", @@ -66236,6 +66326,21 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/deployments.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", @@ -66246,11 +66351,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.ts", "type": "injects" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", - "type": "injects" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", @@ -66396,6 +66496,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/vnc.gateway.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", @@ -66446,6 +66551,16 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-runtime-refresh.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts", @@ -66476,6 +66591,36 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/workspace-change-notifier.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.ts", @@ -66551,6 +66696,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts", @@ -66566,16 +66716,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/workspace-inotify-supervisor.service.ts", "type": "injects" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts", - "type": "injects" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts", - "type": "injects" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts", @@ -66656,6 +66796,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/environment-progress.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/deployments.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/deployment-configurations.repository.ts", @@ -66671,16 +66816,6 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", "type": "injects" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts", - "type": "injects" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts", - "type": "injects" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/vnc-bridge.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts", @@ -66923,7 +67058,7 @@ }, { "from": "file:apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", "type": "injects" }, { @@ -67246,6 +67381,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/mcp-oauth-callback.controller.ts", @@ -67456,6 +67596,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-layer-files.service.ts", @@ -67616,26 +67761,6 @@ "to": "file:libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.ts", "type": "provides" }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts", - "type": "provides" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts", - "type": "provides" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts", - "type": "provides" - }, - { - "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts", - "type": "provides" - }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/identity-email-bridge.module.ts", "to": "file:libs/domains/shared/backend/feature-notifications/src/lib/services/email-notification-dispatcher.service.ts", @@ -67651,6 +67776,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics.repository.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/statistics.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/statistics.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.ts", @@ -69103,7 +69233,7 @@ }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts", "type": "provides" }, { @@ -69338,22 +69468,22 @@ }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts", "type": "provides" }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts", "type": "provides" }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts", "type": "provides" }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", - "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts", "type": "provides" }, { @@ -70731,31 +70861,6 @@ "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/files", "type": "calls" }, - { - "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "to": "api:HTTP:GET:/filter-rules", - "type": "calls" - }, - { - "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "to": "api:HTTP:GET:/filter-rules/{id}", - "type": "calls" - }, - { - "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "to": "api:HTTP:POST:/filter-rules", - "type": "calls" - }, - { - "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "to": "api:HTTP:PUT:/filter-rules/{id}", - "type": "calls" - }, - { - "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "to": "api:HTTP:DELETE:/filter-rules/{id}", - "type": "calls" - }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", "to": "api:HTTP:GET:/knowledge", @@ -70881,6 +70986,16 @@ "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/opencode/config", "type": "calls" }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/egress", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/egress/evaluate", + "type": "calls" + }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts", "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/opencode/config/agents", @@ -71011,6 +71126,16 @@ "to": "api:HTTP:GET:/statistics/entity-events", "type": "calls" }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/statistics.service.ts", + "to": "api:HTTP:GET:/clients/{id}/statistics/egress-events", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/statistics.service.ts", + "to": "api:HTTP:GET:/statistics/egress-events", + "type": "calls" + }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.ts", "to": "api:HTTP:GET:/tickets", @@ -71526,11 +71651,6 @@ "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/files.service.ts", "type": "contains" }, - { - "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules", - "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts", - "type": "contains" - }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/knowledge", "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/knowledge.service.ts", @@ -71816,16 +71936,6 @@ "to": "api:HTTP:POST:/users", "type": "documents" }, - { - "from": "concept:agenstra-agent-controller-api", - "to": "api:HTTP:GET:/filter-rules", - "type": "documents" - }, - { - "from": "concept:agenstra-agent-controller-api", - "to": "api:HTTP:POST:/filter-rules", - "type": "documents" - }, { "from": "concept:agenstra-agent-controller-api", "to": "api:HTTP:GET:/knowledge", @@ -71916,6 +72026,21 @@ "to": "api:HTTP:POST:/clients", "type": "documents" }, + { + "from": "concept:agenstra-agent-controller-api", + "to": "api:HTTP:GET:/statistics/egress-events", + "type": "documents" + }, + { + "from": "concept:agenstra-agent-controller-api", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-agent-controller-api", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, { "from": "concept:agenstra-agent-manager-api", "to": "api:HTTP:GET:/otel/metrics", @@ -71958,12 +72083,12 @@ }, { "from": "concept:agenstra-agent-manager-api", - "to": "api:HTTP:GET:/agents-filters", + "to": "api:HTTP:GET:/egress/events", "type": "documents" }, { "from": "concept:agenstra-agent-manager-api", - "to": "api:HTTP:POST:/agents-filters", + "to": "api:HTTP:GET:/agents/{id}/egress", "type": "documents" }, { @@ -72213,12 +72338,7 @@ }, { "from": "concept:agenstra-backend-applications", - "to": "api:HTTP:GET:/agents-filters", - "type": "documents" - }, - { - "from": "concept:agenstra-backend-applications", - "to": "api:HTTP:POST:/agents-filters", + "to": "api:HTTP:GET:/egress/events", "type": "documents" }, { @@ -72826,26 +72946,6 @@ "to": "api:HTTP:POST:/auth/login", "type": "documents" }, - { - "from": "concept:agenstra-container-interaction", - "to": "api:HTTP:GET:/agents", - "type": "documents" - }, - { - "from": "concept:agenstra-container-interaction", - "to": "api:HTTP:POST:/agents", - "type": "documents" - }, - { - "from": "concept:agenstra-container-interaction", - "to": "api:HTTP:GET:/agents-filters", - "type": "documents" - }, - { - "from": "concept:agenstra-container-interaction", - "to": "api:HTTP:POST:/agents-filters", - "type": "documents" - }, { "from": "concept:agenstra-application-updates-agenstra", "to": "api:HTTP:POST:/auth/login", @@ -72906,16 +73006,6 @@ "to": "api:HTTP:POST:/tickets", "type": "documents" }, - { - "from": "concept:agenstra-access-control", - "to": "api:HTTP:GET:/filter-rules", - "type": "documents" - }, - { - "from": "concept:agenstra-access-control", - "to": "api:HTTP:POST:/filter-rules", - "type": "documents" - }, { "from": "concept:agenstra-jira-vs-confluence", "to": "api:HTTP:POST:/imports/atlassian/configs", @@ -73008,67 +73098,117 @@ }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:GET:/filter-rules", + "to": "api:HTTP:GET:/clients", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:POST:/filter-rules", + "to": "api:HTTP:POST:/clients", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:GET:/filter-rules/{id}", + "to": "api:HTTP:GET:/clients/{id}", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:PUT:/filter-rules/{id}", + "to": "api:HTTP:POST:/clients/{id}", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:DELETE:/filter-rules/{id}", + "to": "api:HTTP:DELETE:/clients/{id}", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:GET:/agents", + "to": "api:HTTP:GET:/clients/{id}/agents", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:POST:/agents", + "to": "api:HTTP:POST:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:GET:/agents-filters", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:POST:/agents-filters", + "to": "api:HTTP:GET:/clients/{id}/statistics/filter-drops", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:GET:/agents-filters/count", + "to": "api:HTTP:GET:/clients/{id}/statistics/filter-flags", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:GET:/agents-filters/{id}", + "to": "api:HTTP:GET:/statistics/filter-drops", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:PUT:/agents-filters/{id}", + "to": "api:HTTP:GET:/statistics/filter-flags", "type": "documents" }, { "from": "concept:agenstra-api-references", - "to": "api:HTTP:DELETE:/agents-filters/{id}", + "to": "api:HTTP:GET:/admin/opencode/config", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:PUT:/admin/opencode/config", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:GET:/clients/{id}/opencode/config", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:PUT:/clients/{id}/opencode/config", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/opencode/config", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/opencode/config", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-api-references", + "to": "api:HTTP:GET:/config", "type": "documents" }, { @@ -73482,28 +73622,173 @@ "type": "documents" }, { - "from": "concept:agenstra-wiring", - "to": "api:HTTP:POST:/auth/register", + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:GET:/clients", "type": "documents" }, { - "from": "concept:agenstra-events", - "to": "api:HTTP:POST:/auth/login", + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:POST:/clients", "type": "documents" }, { - "from": "concept:agenstra-events", - "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:GET:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:POST:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:DELETE:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:GET:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:POST:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}", "type": "documents" }, { - "from": "concept:agenstra-why-two-tiers", - "to": "api:HTTP:POST:/filter-rules", + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/egress", "type": "documents" }, { - "from": "concept:agenstra-why-two-tiers", - "to": "api:HTTP:POST:/agents-filters", + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/egress/evaluate", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/clients", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:POST:/clients", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:POST:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:DELETE:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:POST:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/clients/{id}/statistics/egress-events", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/statistics/egress-events", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/egress", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "api:HTTP:GET:/egress/events", + "type": "documents" + }, + { + "from": "concept:agenstra-building-the-guard-image", + "to": "project:agenstra-backend-agent-manager", + "type": "documents" + }, + { + "from": "concept:agenstra-wiring", + "to": "api:HTTP:POST:/auth/register", + "type": "documents" + }, + { + "from": "concept:agenstra-events", + "to": "api:HTTP:POST:/auth/login", + "type": "documents" + }, + { + "from": "concept:agenstra-events", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", "type": "documents" }, { @@ -73651,6 +73936,16 @@ "to": "api:HTTP:DELETE:/clients/{id}", "type": "documents" }, + { + "from": "concept:agenstra-per-workspace-statistics", + "to": "api:HTTP:GET:/clients/{id}/statistics/egress-events", + "type": "documents" + }, + { + "from": "concept:agenstra-per-workspace-statistics", + "to": "api:HTTP:GET:/statistics/egress-events", + "type": "documents" + }, { "from": "concept:agenstra-git-operation-flow", "to": "api:HTTP:GET:/clients", @@ -79716,6 +80011,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "file:docs/agenstra/features/egress-filtering.md", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "file:docs/agenstra/features/email-notifications.md", "to": "domain:agenstra", @@ -79732,7 +80032,7 @@ "type": "belongs_to" }, { - "from": "file:docs/agenstra/features/message-filter-rules.md", + "from": "file:docs/agenstra/features/message-filters.md", "to": "domain:agenstra", "type": "belongs_to" }, @@ -81676,6 +81976,61 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-egress-filtering-web-traffic", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-modes", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-policy-fields", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-what-is-and-is-not-inspected", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-how-blocked-connections-look-to-clients", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-protocol-denials", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-fail-closed-behavior", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-upstream-proxy-chaining", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-audit-events-and-notifications", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-building-the-guard-image", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-limitations", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-email-notifications-agenstra", "to": "domain:agenstra", @@ -81737,12 +82092,32 @@ "type": "belongs_to" }, { - "from": "concept:agenstra-message-filter-rules", + "from": "concept:agenstra-message-filters", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-rule-shape", "to": "domain:agenstra", "type": "belongs_to" }, { - "from": "concept:agenstra-why-two-tiers", + "from": "concept:agenstra-layering-and-precedence", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-validation-and-redos-protection", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-notifications-and-statistics", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-migration-from-filter-rules", "to": "domain:agenstra", "type": "belongs_to" }, @@ -82286,6 +82661,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-agent-egress-web-traffic-guard", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-origin-allowlist-unsafe-http-methods", "to": "domain:agenstra", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/README.md b/libs/domains/agenstra/backend/feature-agent-controller/README.md index 9bc1ccdec..c4058beee 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/README.md +++ b/libs/domains/agenstra/backend/feature-agent-controller/README.md @@ -69,6 +69,7 @@ The library follows Domain-Driven Design (DDD) principles with clear separation All diagrams are available in the [`docs/`](./docs/) directory: - **[Overview Diagram](./docs/overview.mmd)** - High-level flowchart showing HTTP REST API for clients, proxied agent operations, and WebSocket event forwarding +- **[Egress Events Ingestion](./docs/egress-events-ingest.mmd)** - `egress-events-ingest` coordinator/unit jobs pulling guard decisions from managers into `statistics_egress_events`; see [Egress Filtering](../../../../docs/agenstra/features/egress-filtering.md) - **[Statistics Model](./docs/statistics-architecture.mmd)** - Flowchart of shadow tables and statistics/event tables; persistent statistics (chat I/O, filter drops, entity lifecycle), collection points, REST API - **[HTTP Statistics Sequence Diagram](./docs/sequence-http-statistics.mmd)** - Sequence diagram for Statistics REST API (client-scoped and aggregate endpoints) - **[HTTP Sequence Diagram](./docs/sequence-http.mmd)** - Detailed sequence diagram for all HTTP CRUD operations (client management and proxied agent operations) @@ -261,11 +262,13 @@ REST endpoints for querying chat I/O, filter drops, filter flags, and entity lif - `GET /api/clients/:id/statistics/filter-drops` - Filter drop incidents (paginated) - `GET /api/clients/:id/statistics/filter-flags` - Filter flag incidents (paginated, messages flagged but not dropped) - `GET /api/clients/:id/statistics/entity-events` - Entity lifecycle events (paginated) +- `GET /api/clients/:id/statistics/egress-events` - Web traffic (egress guard) decision events (paginated) - `GET /api/statistics/summary` - Aggregate statistics (all accessible clients or filter by `clientId`) - `GET /api/statistics/chat-io` - Chat I/O records - `GET /api/statistics/filter-drops` - Filter drops - `GET /api/statistics/filter-flags` - Filter flags (messages flagged but not dropped) - `GET /api/statistics/entity-events` - Entity events +- `GET /api/statistics/egress-events` - Egress events ### Server Provisioning diff --git a/libs/domains/agenstra/backend/feature-agent-controller/docs/egress-events-ingest.mmd b/libs/domains/agenstra/backend/feature-agent-controller/docs/egress-events-ingest.mmd new file mode 100644 index 000000000..ecc3ef97a --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/docs/egress-events-ingest.mmd @@ -0,0 +1,45 @@ +sequenceDiagram + participant Sched as BullMQ repeatable job + participant Coord as EgressEventsIngestCoordinator + participant Queue as Controller queue + participant Worker as EgressEventsIngestUnit + participant Ingest as EgressEventsIngestService + participant Proxy as ClientAgentProxyService + participant Mgr as Agent Manager
(GET /api/egress/events) + participant Stats as StatisticsService + participant Pub as AgenstraNotificationPublisher + participant DB as Database + + Note over Sched,DB: egress-events-ingest.coordinator / .unit (EGRESS_EVENTS_INGEST_INTERVAL_MS, default 30s) + + Sched->>Coord: tick (egress-events-ingest.coordinator) + Coord->>Ingest: findClientIdsDue(EGRESS_EVENTS_INGEST_BATCH_SIZE) + Ingest->>DB: SELECT client ids + egress ingest cursors + Ingest-->>Coord: never-polled first, then least recently polled + loop Each due client + Coord->>Queue: enqueue egress-events-ingest.unit (jobId per client) + end + + Queue->>Worker: egress-events-ingest.unit { clientId } + Worker->>Ingest: ingestClient(clientId) + Ingest->>DB: load cursor (lastSeq) + + loop Up to 10 pages of 500 events + Ingest->>Proxy: listClientEgressEvents(clientId, afterSeq, 500) + Proxy->>Mgr: GET /api/egress/events?afterSeq=&limit=500 + Mgr-->>Proxy: { events, nextSeq, hasMore } + Proxy-->>Ingest: page + Ingest->>Stats: recordEgressEvents(clientId, events) + Stats->>DB: INSERT statistics_egress_events (dedupe by source seq) + Ingest->>Ingest: advance cursor to nextSeq + end + + alt Manager answers 404 (no egress feed) + Ingest->>DB: save cursor lastError = egress feed not available + else Success + Ingest->>DB: save cursor (lastSeq, lastIngestedAt) + end + + Ingest->>Pub: egress.blocked / egress.observed (grouped per agent, host, rule; capped per run) + Ingest->>Pub: egress.guard_failed (guard error events) + Note over Pub: Delivered as webhooks / email; /audit reads statistics_egress_events diff --git a/libs/domains/agenstra/backend/feature-agent-controller/docs/filter-rules-sync.mmd b/libs/domains/agenstra/backend/feature-agent-controller/docs/filter-rules-sync.mmd deleted file mode 100644 index 928148acd..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/docs/filter-rules-sync.mmd +++ /dev/null @@ -1,21 +0,0 @@ -sequenceDiagram - participant Coord as FilterRulesSyncCoordinator - participant Worker as FilterRulesSyncWorker - participant Sync as FilterRulesSyncService - participant DB as SyncTargetsTable - participant AM as AgentManager - - Sched->>Sync: processBatch - Sync->>DB: load pending targets with rule - alt desired_on_manager false - Sync->>AM: DELETE /api/agents-filters/{managerRuleId} - Sync->>DB: clear manager_rule_id synced - else desired_on_manager true - alt no manager_rule_id - Sync->>AM: POST /api/agents-filters - Sync->>DB: store manager_rule_id synced - else has manager_rule_id - Sync->>AM: PUT /api/agents-filters/{id} - Sync->>DB: mark synced - end - end diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml index 504a81f30..84da76b44 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -630,131 +630,6 @@ paths: description: Authenticator removed '404': description: User not found - /filter-rules: - get: - summary: List regex filter rules (admin only) - operationId: listFilterRules - parameters: - - in: query - name: limit - required: false - schema: - type: integer - minimum: 1 - default: 10 - description: Maximum number of rules to return - - in: query - name: offset - required: false - schema: - type: integer - minimum: 0 - default: 0 - description: Number of rules to skip (pagination) - - in: query - name: search - schema: - type: string - description: Case-insensitive search across pattern, filterType, direction, replaceContent, id - responses: - '200': - description: Rules with per-workspace sync summary - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/FilterRuleResponseDto' - '403': - description: Forbidden - post: - summary: Create filter rule (admin only) - operationId: createFilterRule - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateFilterRuleDto' - responses: - '201': - description: Created - content: - application/json: - schema: - $ref: '#/components/schemas/FilterRuleResponseDto' - '400': - description: Validation error - '403': - description: Forbidden - /filter-rules/{id}: - get: - summary: Get filter rule (admin only) - operationId: getFilterRule - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - responses: - '200': - description: Rule - content: - application/json: - schema: - $ref: '#/components/schemas/FilterRuleResponseDto' - '403': - description: Forbidden - '404': - description: Not found - put: - summary: Update filter rule (admin only) - operationId: updateFilterRule - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateFilterRuleDto' - responses: - '200': - description: Updated - content: - application/json: - schema: - $ref: '#/components/schemas/FilterRuleResponseDto' - '400': - description: Validation error - '403': - description: Forbidden - '404': - description: Not found - delete: - summary: Delete filter rule (admin only) - operationId: deleteFilterRule - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - responses: - '204': - description: Deleted - '403': - description: Forbidden - '404': - description: Not found /imports/atlassian/connections: get: summary: List Atlassian site connections (admin only) @@ -5099,6 +4974,71 @@ paths: $ref: '#/components/schemas/StatisticsFilterFlagListDto' '403': description: Access denied to this client + /clients/{id}/statistics/egress-events: + get: + summary: Get egress events for a client + description: | + Returns paginated egress guard decisions (web traffic filtering). Identical decisions + within one collection cycle are aggregated into a single event with a `count`. + operationId: getClientStatisticsEgressEvents + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: query + name: agentId + schema: + type: string + format: uuid + - in: query + name: kind + schema: + type: string + enum: [web, dns, network, guard] + description: Filter by kind (web, dns, network, guard) + - in: query + name: decision + schema: + type: string + enum: [allowed, blocked, observed, error] + description: Filter by decision (allowed, blocked, observed, error) + - in: query + name: from + schema: + type: string + format: date-time + - in: query + name: to + schema: + type: string + format: date-time + - in: query + name: limit + schema: + type: integer + minimum: 0 + - in: query + name: offset + schema: + type: integer + minimum: 0 + - in: query + name: search + schema: + type: string + description: Case-insensitive search across host, rule, matchedEntry, decision, kind, id + responses: + '200': + description: Paginated egress events + content: + application/json: + schema: + $ref: '#/components/schemas/StatisticsEgressEventListDto' + '403': + description: Access denied to this client /clients/{id}/statistics/entity-events: get: summary: Get entity lifecycle events for a client @@ -5372,6 +5312,71 @@ paths: $ref: '#/components/schemas/StatisticsFilterFlagListDto' '403': description: Access denied to requested client + /statistics/egress-events: + get: + summary: Get egress events (aggregate or by client) + description: | + Returns paginated egress guard decisions (web traffic filtering). Identical decisions + within one collection cycle are aggregated into a single event with a `count`. + operationId: getStatisticsEgressEvents + parameters: + - in: query + name: clientId + schema: + type: string + format: uuid + description: Optional client filter; must be accessible + - in: query + name: agentId + schema: + type: string + format: uuid + - in: query + name: kind + schema: + type: string + enum: [web, dns, network, guard] + description: Filter by kind (web, dns, network, guard) + - in: query + name: decision + schema: + type: string + enum: [allowed, blocked, observed, error] + description: Filter by decision (allowed, blocked, observed, error) + - in: query + name: from + schema: + type: string + format: date-time + - in: query + name: to + schema: + type: string + format: date-time + - in: query + name: limit + schema: + type: integer + minimum: 0 + - in: query + name: offset + schema: + type: integer + minimum: 0 + - in: query + name: search + schema: + type: string + description: Case-insensitive search across host, rule, matchedEntry, decision, kind, id + responses: + '200': + description: Paginated egress events + content: + application/json: + schema: + $ref: '#/components/schemas/StatisticsEgressEventListDto' + '403': + description: Access denied to requested client /statistics/entity-events: get: summary: Get entity events (aggregate or by client) @@ -6023,10 +6028,14 @@ paths: properties: ok: type: boolean - /clients/{id}/agents/{agentId}/opencode/config: + /clients/{id}/agents/{agentId}/egress: get: - summary: Get agent OpenCode config - operationId: getClientAgentOpencodeConfig + summary: Get agent egress guard details + description: | + Returns the egress guard (web traffic filtering) status of the environment, the effective + policy in force on the agent manager, the effective message filters and recent guard decisions. + Requires the `clients:read` scope and workspace management access (exposes layered policy details, like the agent configuration endpoints). + operationId: getClientAgentEgress parameters: - in: path name: id @@ -6042,14 +6051,23 @@ paths: format: uuid responses: '200': - description: Agent OpenCode config with effective merge + description: Egress guard details content: application/json: schema: - $ref: '#/components/schemas/OpencodeConfigResponseDto' - put: - summary: Update agent OpenCode config - operationId: putClientAgentOpencodeConfig + $ref: '#/components/schemas/AgentEgressDetailsDto' + '403': + description: Access denied to this client + '404': + description: Client or agent not found + /clients/{id}/agents/{agentId}/egress/evaluate: + post: + summary: Evaluate a destination against the effective egress policy + description: | + Evaluates a hostname, IP address or URL against the environment's effective (layered) + egress policy (global, workspace, environment). Only `http://` and `https://` URLs are accepted. + Requires the `clients:read` scope and workspace management access (exposes layered policy details, like the agent configuration endpoints). + operationId: evaluateClientAgentEgressDestination parameters: - in: path name: id @@ -6068,18 +6086,24 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UpsertOpencodeConfigDto' + $ref: '#/components/schemas/EvaluateEgressDestinationDto' responses: '200': - description: Updated agent OpenCode config with effective merge + description: Evaluation result content: application/json: schema: - $ref: '#/components/schemas/OpencodeConfigResponseDto' - /clients/{id}/agents/{agentId}/opencode/config/agents: + $ref: '#/components/schemas/EgressEvaluationResponseDto' + '400': + description: Invalid destination + '403': + description: Access denied to this client + '404': + description: Client or agent not found + /clients/{id}/agents/{agentId}/opencode/config: get: - summary: List effective OpenCode agents for a remote worker - operationId: listClientAgentOpencodeAgents + summary: Get agent OpenCode config + operationId: getClientAgentOpencodeConfig parameters: - in: path name: id @@ -6095,18 +6119,14 @@ paths: format: uuid responses: '200': - description: Effective OpenCode agents (primary + subagents) + description: Agent OpenCode config with effective merge content: application/json: schema: - $ref: '#/components/schemas/OpencodeAgentsListResponseDto' - /clients/{id}/agents/{agentId}/opencode/config/commands: - get: - summary: List slash-invokable OpenCode commands for a remote worker - description: > - Proxies the agent-manager list of OpenCode `GET /command` entries (config commands, - builtins, skills, MCP prompts). - operationId: listClientAgentOpencodeCommands + $ref: '#/components/schemas/OpencodeConfigResponseDto' + put: + summary: Update agent OpenCode config + operationId: putClientAgentOpencodeConfig parameters: - in: path name: id @@ -6120,13 +6140,70 @@ paths: schema: type: string format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertOpencodeConfigDto' responses: '200': - description: Slash commands available on the worker + description: Updated agent OpenCode config with effective merge content: application/json: schema: - $ref: '#/components/schemas/OpencodeCommandsListResponseDto' + $ref: '#/components/schemas/OpencodeConfigResponseDto' + /clients/{id}/agents/{agentId}/opencode/config/agents: + get: + summary: List effective OpenCode agents for a remote worker + operationId: listClientAgentOpencodeAgents + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Effective OpenCode agents (primary + subagents) + content: + application/json: + schema: + $ref: '#/components/schemas/OpencodeAgentsListResponseDto' + /clients/{id}/agents/{agentId}/opencode/config/commands: + get: + summary: List slash-invokable OpenCode commands for a remote worker + description: > + Proxies the agent-manager list of OpenCode `GET /command` entries (config commands, + builtins, skills, MCP prompts). + operationId: listClientAgentOpencodeCommands + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Slash commands available on the worker + content: + application/json: + schema: + $ref: '#/components/schemas/OpencodeCommandsListResponseDto' /clients/{id}/agents/{agentId}/opencode/mcp: get: summary: List live MCP server statuses for an Environment @@ -7583,6 +7660,13 @@ components: autoEnrichmentChars, filterDropCount, filterTypesBreakdown, + egressAllowedCount, + egressBlockedCount, + egressObservedCount, + egressGuardErrorCount, + egressBreakdown, + egressTopBlockedHosts, + egressRulesBreakdown, ] properties: totalMessages: @@ -7655,6 +7739,80 @@ components: type: integer charCount: type: integer + egressAllowedCount: + type: integer + description: Egress guard decisions (sum of aggregated counts) with decision allowed + egressBlockedCount: + type: integer + egressObservedCount: + type: integer + egressGuardErrorCount: + type: integer + egressBreakdown: + type: array + description: Decisions by kind and decision + items: + type: object + required: [kind, decision, count] + properties: + kind: + type: string + enum: [web, dns, network, guard] + decision: + type: string + enum: [allowed, blocked, observed, error] + count: + type: integer + egressTopBlockedHosts: + type: array + description: Most frequently blocked / observed destinations (top 10) + items: + type: object + required: [host, count] + properties: + host: + type: string + count: + type: integer + egressRulesBreakdown: + type: array + description: Blocked / observed decisions by guard rule + items: + type: object + required: [rule, count] + properties: + rule: + type: string + count: + type: integer + securitySeries: + type: array + description: Security events over time (only with groupBy); message filter drops / flags and egress decisions + items: + type: object + required: + [ + period, + filterDrops, + filterFlags, + egressAllowed, + egressBlocked, + egressObserved, + ] + properties: + period: + type: string + format: date-time + filterDrops: + type: integer + filterFlags: + type: integer + egressAllowed: + type: integer + egressBlocked: + type: integer + egressObserved: + type: integer StatisticsChatIoDto: type: object required: @@ -7726,6 +7884,100 @@ components: type: integer offset: type: integer + StatisticsEgressEventDto: + type: object + required: + [ + id, + clientId, + kind, + decision, + count, + bytesIn, + bytesOut, + firstSeenAt, + occurredAt, + ] + properties: + id: + type: string + format: uuid + clientId: + type: string + format: uuid + agentId: + type: string + format: uuid + clientName: + type: string + agentName: + type: string + kind: + type: string + enum: [web, dns, network, guard] + decision: + type: string + enum: [allowed, blocked, observed, error] + host: + type: string + nullable: true + rule: + type: string + nullable: true + description: Guard rule (deny_host, not_allowlisted, private_network, ip_literal, deny_url, offline, quic, ...) + matchedEntry: + type: string + nullable: true + matchedSource: + type: string + nullable: true + enum: [global, workspace, environment, null] + description: Layer that contributed the matched entry + method: + type: string + nullable: true + httpStatus: + type: integer + nullable: true + sampleUrl: + type: string + nullable: true + count: + type: integer + description: Number of identical decisions aggregated within one collection cycle + bytesIn: + type: integer + bytesOut: + type: integer + mode: + type: string + nullable: true + policyRevision: + type: string + nullable: true + message: + type: string + nullable: true + firstSeenAt: + type: string + format: date-time + occurredAt: + type: string + format: date-time + StatisticsEgressEventListDto: + type: object + required: [data, total, limit, offset] + properties: + data: + type: array + items: + $ref: '#/components/schemas/StatisticsEgressEventDto' + total: + type: integer + limit: + type: integer + offset: + type: integer StatisticsFilterDropDto: type: object required: @@ -8720,145 +8972,6 @@ components: format: uuid content: type: string - FilterRuleSyncSummaryDto: - type: object - required: [pending, synced, failed] - properties: - pending: - type: integer - minimum: 0 - synced: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - FilterRuleWorkspaceSyncDto: - type: object - required: [clientId, syncStatus] - properties: - clientId: - type: string - format: uuid - syncStatus: - type: string - enum: [pending, synced, failed] - lastError: - type: string - FilterRuleResponseDto: - type: object - required: - [ - id, - pattern, - regexFlags, - direction, - filterType, - priority, - enabled, - isGlobal, - workspaceIds, - sync, - workspaceSync, - createdAt, - updatedAt, - ] - properties: - id: - type: string - format: uuid - pattern: - type: string - regexFlags: - type: string - direction: - type: string - enum: [incoming, outgoing, bidirectional] - filterType: - type: string - enum: [none, filter, drop] - replaceContent: - type: string - priority: - type: integer - enabled: - type: boolean - isGlobal: - type: boolean - workspaceIds: - type: array - items: - type: string - format: uuid - description: Empty when isGlobal is true (applies to all workspaces) - sync: - $ref: '#/components/schemas/FilterRuleSyncSummaryDto' - workspaceSync: - type: array - items: - $ref: '#/components/schemas/FilterRuleWorkspaceSyncDto' - description: Per-workspace agent-manager sync status for this rule - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - CreateFilterRuleDto: - type: object - required: [pattern, direction, filterType, isGlobal] - properties: - pattern: - type: string - regexFlags: - type: string - direction: - type: string - enum: [incoming, outgoing, bidirectional] - filterType: - type: string - enum: [none, filter, drop] - replaceContent: - type: string - priority: - type: integer - minimum: 0 - enabled: - type: boolean - isGlobal: - type: boolean - workspaceIds: - type: array - items: - type: string - format: uuid - UpdateFilterRuleDto: - type: object - properties: - pattern: - type: string - regexFlags: - type: string - direction: - type: string - enum: [incoming, outgoing, bidirectional] - filterType: - type: string - enum: [none, filter, drop] - replaceContent: - type: string - priority: - type: integer - minimum: 0 - enabled: - type: boolean - isGlobal: - type: boolean - workspaceIds: - type: array - items: - type: string - format: uuid AtlassianSiteConnectionResponseDto: type: object description: Connection metadata only; API tokens are stored encrypted at rest and are never returned. @@ -9483,6 +9596,324 @@ components: entryKind: type: string enum: [file, directory] + EffectiveEgressPolicy: + type: object + description: Fully resolved egress policy with per-entry provenance + required: + [ + mode, + allowHosts, + denyHosts, + denyUrlPatterns, + networkAllow, + blockPrivateNetworks, + blockIpLiterals, + logAllowed, + scalarSources, + ] + properties: + mode: + type: string + enum: [off, monitor, enforce, offline] + allowHosts: + type: array + items: + type: object + required: [value, source] + properties: + value: + type: string + source: + type: string + enum: [global, workspace, environment] + denyHosts: + type: array + items: + type: object + required: [value, source] + properties: + value: + type: string + source: + type: string + enum: [global, workspace, environment] + denyUrlPatterns: + type: array + items: + type: object + required: [value, source] + properties: + value: + type: string + source: + type: string + enum: [global, workspace, environment] + networkAllow: + type: array + items: + type: object + required: [protocol, ports, destinations, source] + properties: + protocol: + type: string + enum: [tcp, udp] + ports: + type: string + description: Single port (22) or range (1000-2000) + destinations: + type: array + items: + type: string + description: + type: string + source: + type: string + enum: [global, workspace, environment] + blockPrivateNetworks: + type: boolean + blockIpLiterals: + type: boolean + logAllowed: + type: boolean + scalarSources: + type: object + description: Layer that set each scalar (null = platform default) + properties: + mode: + type: string + nullable: true + enum: [global, workspace, environment, null] + blockPrivateNetworks: + type: string + nullable: true + enum: [global, workspace, environment, null] + blockIpLiterals: + type: string + nullable: true + enum: [global, workspace, environment, null] + logAllowed: + type: string + nullable: true + enum: [global, workspace, environment, null] + EffectiveMessageFilterRule: + type: object + description: Message filter rule from the layered config (`message_filters` root key) + required: [name, pattern, flags, direction, action, enabled, source] + properties: + name: + type: string + pattern: + type: string + flags: + type: string + direction: + type: string + enum: [incoming, outgoing, bidirectional] + action: + type: string + enum: [drop, replace, flag] + replacement: + type: string + enabled: + type: boolean + source: + type: string + enum: [global, workspace, environment] + EffectivePlatformSecurityPolicy: + type: object + description: > + Effective platform security policy for a scope (Security: Web traffic / Message filters), + resolved global, workspace, environment with per-entry provenance. + required: [egress, messageFilters] + properties: + egress: + $ref: '#/components/schemas/EffectiveEgressPolicy' + messageFilters: + type: array + items: + $ref: '#/components/schemas/EffectiveMessageFilterRule' + AgentEgressGuardRuntimeDto: + type: object + required: [running, startedAt, activeRevision, proxyRunning, dnsRunning] + properties: + running: + type: boolean + startedAt: + type: string + format: date-time + nullable: true + activeRevision: + type: string + nullable: true + description: Policy revision the guard reports as active + proxyRunning: + type: boolean + dnsRunning: + type: boolean + AgentEgressStatusDto: + type: object + description: Egress guard status of one agent environment + required: + [status, mode, guardContainerId, policyRevision, appliedAt, lastError] + properties: + status: + type: string + enum: [inactive, active, failed] + mode: + type: string + enum: [off, monitor, enforce, offline] + guardContainerId: + type: string + nullable: true + policyRevision: + type: string + nullable: true + appliedAt: + type: string + format: date-time + nullable: true + lastError: + type: string + nullable: true + guard: + $ref: '#/components/schemas/AgentEgressGuardRuntimeDto' + AgentEgressEventDto: + type: object + description: One aggregated guard decision + required: + [ + seq, + agentId, + kind, + decision, + host, + rule, + matchedEntry, + matchedSource, + method, + httpStatus, + sampleUrl, + count, + bytesIn, + bytesOut, + firstSeenAt, + lastSeenAt, + mode, + policyRevision, + message, + ] + properties: + seq: + type: string + description: Monotonic cursor (stringified bigint) + agentId: + type: string + format: uuid + kind: + type: string + enum: [web, dns, network, guard] + decision: + type: string + enum: [allowed, blocked, observed, error] + host: + type: string + nullable: true + rule: + type: string + nullable: true + matchedEntry: + type: string + nullable: true + matchedSource: + type: string + nullable: true + enum: [global, workspace, environment, null] + method: + type: string + nullable: true + httpStatus: + type: integer + nullable: true + sampleUrl: + type: string + nullable: true + count: + type: integer + bytesIn: + type: integer + bytesOut: + type: integer + firstSeenAt: + type: string + format: date-time + lastSeenAt: + type: string + format: date-time + mode: + type: string + nullable: true + policyRevision: + type: string + nullable: true + message: + type: string + nullable: true + AgentEgressDetailsDto: + type: object + description: Egress status plus the effective policy and message filters in force + required: [agentId, status, policy, messageFilters, recentEvents] + properties: + agentId: + type: string + format: uuid + status: + $ref: '#/components/schemas/AgentEgressStatusDto' + policy: + allOf: + - $ref: '#/components/schemas/EffectiveEgressPolicy' + nullable: true + messageFilters: + type: array + items: + $ref: '#/components/schemas/EffectiveMessageFilterRule' + recentEvents: + type: array + items: + $ref: '#/components/schemas/AgentEgressEventDto' + EvaluateEgressDestinationDto: + type: object + required: [destination] + properties: + destination: + type: string + minLength: 1 + maxLength: 2048 + description: Hostname, IP address or URL (scheme / path optional) + EgressEvaluationResponseDto: + type: object + required: [host, decision, rule, mode] + properties: + host: + type: string + decision: + type: string + enum: [allowed, blocked, observed] + rule: + type: string + nullable: true + matchedEntry: + type: string + nullable: true + source: + type: string + nullable: true + mode: + type: string + scheme: + type: string + nullable: true + enum: [http, https, null] + description: Plain-HTTP URL rules only apply to http:// URLs (HTTPS paths are not visible without TLS interception) OpencodeConfigResponseDto: type: object required: [config, overrides, locks, secretKeys] @@ -9537,6 +9968,10 @@ components: type: string items: type: array + platformPolicy: + $ref: '#/components/schemas/EffectivePlatformSecurityPolicy' + egress: + $ref: '#/components/schemas/AgentEgressStatusDto' updatedAt: type: string format: date-time diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts index 6afb0c739..fc0ad0f5e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts @@ -68,12 +68,8 @@ export { // Framework-owned exports (files that stay in this library) export * from './lib/dto/client-response.dto'; export * from './lib/dto/create-client-response.dto'; -export * from './lib/dto/filter-rules'; export * from './lib/dto/create-client.dto'; export * from './lib/dto/update-client.dto'; -export * from './lib/entities/agent-console-regex-filter-rule-client.entity'; -export * from './lib/entities/agent-console-regex-filter-rule-sync-target.entity'; -export * from './lib/entities/agent-console-regex-filter-rule.entity'; export * from './lib/entities/atlassian-site-connection.entity'; export * from './lib/entities/external-import-config.entity'; export * from './lib/entities/external-import-sync-marker.entity'; @@ -104,6 +100,8 @@ export * from './lib/dto/attachment.dto'; export * from './lib/entities/statistics-agent.entity'; export * from './lib/entities/statistics-chat-filter-drop.entity'; export * from './lib/entities/statistics-chat-filter-flag.entity'; +export * from './lib/entities/statistics-egress-event.entity'; +export * from './lib/entities/egress-event-ingest-cursor.entity'; export * from './lib/entities/statistics-chat-io.entity'; export * from './lib/entities/statistics-client-user.entity'; export * from './lib/entities/statistics-client.entity'; @@ -127,16 +125,15 @@ export * from './lib/services/opencode-mcp-servers-catalog.service'; export * from './lib/services/opencode-mcp-servers-bootstrap.service'; export * from './lib/services/opencode-effective-config-sync.service'; export * from './lib/services/opencode-config-sync-targets.service'; +export * from './lib/services/egress-events-ingest.service'; export * from './lib/services/opencode-layer-files.service'; export * from './lib/controllers/opencode-providers.controller'; export * from './lib/controllers/opencode-mcp-servers.controller'; -export * from './lib/controllers/filter-rules.controller'; export * from './lib/controllers/client-agent-autonomy.controller'; export * from './lib/controllers/clients-agent-automation-proxy.controller'; export * from './lib/controllers/ticket-automation.controller'; export * from './lib/controllers/chat-plan.controller'; export * from './lib/controllers/tickets.controller'; -export * from './lib/modules/filter-rules.module'; export * from './lib/modules/context-import.module'; export * from './lib/modules/clients.module'; export * from './lib/modules/agenstra-notifications.module'; @@ -155,15 +152,11 @@ export * from './lib/repositories/statistics.repository'; export * from './lib/dto/ticket-automation'; export * from './lib/dto/chat-plan'; export * from './lib/services/autonomous-run-orchestrator.service'; -export * from './lib/services/agent-manager-filter-rules-client.service'; -export * from './lib/services/filter-rules.service'; -export * from './lib/services/filter-rules-sync.service'; export * from './lib/services/autonomous-run-orchestrator.service'; export * from './lib/services/context-import-orchestrator.service'; export * from './lib/services/external-import-config.service'; export * from './lib/modules/clients.module'; export * from './lib/modules/context-import.module'; -export * from './lib/modules/filter-rules.module'; export * from './lib/services/client-agent-autonomy.service'; export * from './lib/services/client-agent-proxy.service'; export * from './lib/services/auto-context-resolver.service'; @@ -184,3 +177,4 @@ export * from './lib/auth/agenstra-pat.scopes'; export * from './lib/notifications/agenstra-notification.publisher'; export * from './lib/utils/mcp-oauth-callback-global-prefix'; export * from './lib/utils/client-endpoint-security'; +export * from './lib/controllers/client-agent-egress.controller'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts index 13154f678..ad9a89a0b 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts @@ -14,7 +14,6 @@ describe('AGENSTRA_PAT_SCOPES', () => { 'tickets:write', 'attachments:read', 'attachments:write', - 'filter_rules:write', 'agents:read', 'agents:write', 'agents:lifecycle', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts index 980db1d70..9e39c2883 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts @@ -13,7 +13,6 @@ export const AGENSTRA_PAT_SCOPES = [ 'knowledge:write', 'attachments:read', 'attachments:write', - 'filter_rules:write', 'agents:read', 'agents:write', 'agents:lifecycle', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/admin-opencode-config.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/admin-opencode-config.controller.ts index c74ca3f23..f6d480998 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/admin-opencode-config.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/admin-opencode-config.controller.ts @@ -1,9 +1,11 @@ import { RequireScopes, UserRole, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; -import { Body, Controller, ForbiddenException, Get, Logger, Put, Req } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, Get, Logger, Optional, Put, Req } from '@nestjs/common'; import { OpencodeConfigResponseDto, UpsertOpencodeConfigDto } from '../dto/opencode-config.dto'; import { OpencodeConfigService } from '../services/opencode-config.service'; +import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; import { OpencodeConfigSyncTargetsService } from '../services/opencode-config-sync-targets.service'; +import { hasAgentConfigChanges, summarizeAgentConfigChange } from '../utils/agent-config-change.utils'; @Controller('admin/opencode/config') @RequireScopes('clients:write') @@ -13,6 +15,7 @@ export class AdminOpencodeConfigController { constructor( private readonly opencodeConfigService: OpencodeConfigService, private readonly configSyncTargets: OpencodeConfigSyncTargetsService, + @Optional() private readonly notificationPublisher?: AgenstraNotificationPublisher, ) {} private assertAdmin(req?: RequestWithUser): void { @@ -38,7 +41,19 @@ export class AdminOpencodeConfigController { async put(@Body() dto: UpsertOpencodeConfigDto, @Req() req?: RequestWithUser): Promise { this.assertAdmin(req); + const before = await this.opencodeConfigService.getGlobal(); const response = await this.opencodeConfigService.putGlobal(dto); + const summary = summarizeAgentConfigChange(before, response, dto.secrets); + + if (hasAgentConfigChanges(summary)) { + this.notificationPublisher?.publishAgentConfigUpdated({ + layer: 'global', + clientId: null, + agentId: null, + ...summary, + updatedByUserId: getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null, + }); + } void this.configSyncTargets.markAndProcessAll().catch((error: unknown) => { const err = error as { message?: string }; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.spec.ts new file mode 100644 index 000000000..94247bd90 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.spec.ts @@ -0,0 +1,72 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ClientAgentEgressController, parseEgressDestination } from './client-agent-egress.controller'; + +jest.mock('@forepath/identity/backend', () => ({ + ...jest.requireActual('@forepath/identity/backend'), + ensureWorkspaceManagementAccess: jest.fn().mockResolvedValue(undefined), +})); + +describe('parseEgressDestination', () => { + it('parses URLs, hosts with ports and IPv6 literals', () => { + expect(parseEgressDestination('https://Api.GitHub.com/repos?x=1')).toEqual({ + host: 'api.github.com', + scheme: 'https', + url: 'https://api.github.com/repos', + }); + expect(parseEgressDestination('example.org:8080/path')).toEqual({ host: 'example.org', scheme: null }); + expect(parseEgressDestination('[2001:db8::1]:443')).toEqual({ host: '2001:db8::1', scheme: null }); + expect(parseEgressDestination('2001:db8::1')).toEqual({ host: '2001:db8::1', scheme: null }); + }); + + it('rejects unsupported schemes and empty hosts', () => { + expect(() => parseEgressDestination('ftp://example.org')).toThrow(BadRequestException); + expect(() => parseEgressDestination('/path')).toThrow(BadRequestException); + }); +}); + +describe('ClientAgentEgressController', () => { + const clientId = '11111111-1111-4111-8111-111111111111'; + const agentId = '22222222-2222-4222-8222-222222222222'; + const clientAgentProxy = { getClientAgentEgress: jest.fn().mockResolvedValue({ agentId }) }; + const agentConfigProxy = { get: jest.fn().mockResolvedValue({ config: {}, overrides: {} }) }; + const opencodeConfigService = { + getLayerConfigs: jest.fn().mockResolvedValue({ + global: { egress: { mode: 'enforce', deny_hosts: ['example.org'] } }, + workspace: {}, + }), + composeStoredLayer: jest.fn().mockReturnValue({ egress: { allow_hosts: ['github.com'] } }), + resolvePlatformPolicy: jest.requireActual('../services/opencode-config.service').OpencodeConfigService.prototype + .resolvePlatformPolicy, + }; + const controller = new ClientAgentEgressController( + clientAgentProxy as never, + agentConfigProxy as never, + opencodeConfigService as never, + {} as never, + {} as never, + ); + + it('proxies the egress details of an environment', async () => { + await expect(controller.getEgress(clientId, agentId)).resolves.toEqual({ agentId }); + expect(clientAgentProxy.getClientAgentEgress).toHaveBeenCalledWith(clientId, agentId); + }); + + it('evaluates destinations against the layered policy (deny wins, allowlist applies)', async () => { + await expect(controller.evaluate(clientId, agentId, { destination: 'https://www.example.org' })).resolves.toEqual({ + host: 'www.example.org', + decision: 'blocked', + rule: 'deny_host', + matchedEntry: 'example.org', + source: 'global', + mode: 'enforce', + scheme: 'https', + }); + await expect(controller.evaluate(clientId, agentId, { destination: 'gitlab.com' })).resolves.toEqual( + expect.objectContaining({ decision: 'blocked', rule: 'not_allowlisted' }), + ); + await expect(controller.evaluate(clientId, agentId, { destination: 'api.github.com' })).resolves.toEqual( + expect.objectContaining({ decision: 'allowed', rule: null }), + ); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts new file mode 100644 index 000000000..80204701a --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-egress.controller.ts @@ -0,0 +1,119 @@ +import type { AgentEgressDetailsDto } from '@forepath/agenstra/backend/feature-agent-manager'; +import { + ClientUsersRepository, + RequireScopes, + ensureWorkspaceManagementAccess, + type RequestWithUser, +} from '@forepath/identity/backend'; +import { evaluateEgressDestination } from '@forepath/agenstra/shared/util-opencode-config'; +import { BadRequestException, Body, Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common'; + +import { EgressEvaluationResponseDto, EvaluateEgressDestinationDto } from '../dto/opencode-config.dto'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentOpencodeConfigProxyService } from '../services/client-agent-opencode-config-proxy.service'; +import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; +import { OpencodeConfigService } from '../services/opencode-config.service'; + +/** Split a destination (host, host:port, or URL) into host and scheme. */ +export function parseEgressDestination(raw: string): { host: string; scheme: 'http' | 'https' | null; url?: string } { + const value = raw.trim(); + + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + let parsed: URL; + + try { + parsed = new URL(value); + } catch { + throw new BadRequestException('destination is not a valid URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new BadRequestException('Only http:// and https:// URLs can be evaluated'); + } + + return { + host: parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(), + scheme: parsed.protocol === 'http:' ? 'http' : 'https', + url: `${parsed.protocol}//${parsed.host}${parsed.pathname}`, + }; + } + + const withoutPath = value.split('/')[0] ?? ''; + const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(withoutPath); + const host = bracketed + ? bracketed[1] + : (withoutPath.match(/:/g) ?? []).length === 1 + ? withoutPath.slice(0, withoutPath.indexOf(':')) + : withoutPath; + + if (!host) { + throw new BadRequestException('destination must contain a hostname or IP address'); + } + + return { host: host.toLowerCase(), scheme: null }; +} + +/** + * Egress guard (web traffic filtering) status and policy evaluation for agent environments. + * Exposes layered policy details (hosts, message filter patterns), so access matches the agent + * configuration endpoints: `clients:read` plus workspace management access. + */ +@Controller('clients/:id/agents/:agentId/egress') +export class ClientAgentEgressController { + constructor( + private readonly clientAgentProxy: ClientAgentProxyService, + private readonly agentConfigProxy: ClientAgentOpencodeConfigProxyService, + private readonly opencodeConfigService: OpencodeConfigService, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + ) {} + + /** Guard status, policy in force on the manager, message filters and recent decisions. */ + @Get() + @RequireScopes('clients:read') + async getEgress( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Req() req?: RequestWithUser, + ): Promise { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + return await this.clientAgentProxy.getClientAgentEgress(clientId, agentId); + } + + /** Evaluate a destination against the environment's effective (layered) egress policy. */ + @Post('evaluate') + @HttpCode(200) + @RequireScopes('clients:read') + async evaluate( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Body() dto: EvaluateEgressDestinationDto, + @Req() req?: RequestWithUser, + ): Promise { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + const destination = parseEgressDestination(dto.destination); + const layers = await this.opencodeConfigService.getLayerConfigs(clientId); + const agentConfig = await this.agentConfigProxy.get(clientId, agentId); + const policy = this.opencodeConfigService.resolvePlatformPolicy({ + global: layers.global, + workspace: layers.workspace, + environment: this.opencodeConfigService.composeStoredLayer(agentConfig.config, agentConfig.overrides), + }).egress; + const result = evaluateEgressDestination(policy, destination.host, { + url: destination.url, + scheme: destination.scheme ?? undefined, + }); + + return { + host: destination.host, + decision: result.decision, + rule: result.rule, + matchedEntry: result.matchedEntry ?? null, + source: result.source ?? null, + mode: policy.mode, + scheme: destination.scheme, + }; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts index f49325043..b0d3c980c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-opencode-config.controller.ts @@ -2,6 +2,7 @@ import { ClientUsersRepository, RequireScopes, ensureWorkspaceManagementAccess, + getUserFromRequest, type RequestWithUser, } from '@forepath/identity/backend'; import { @@ -17,6 +18,7 @@ import { Delete, Get, Logger, + Optional, Param, ParseUUIDPipe, Post, @@ -35,10 +37,16 @@ import { OpencodeMcpServerStatusDto, OpencodeMcpStatusListResponseDto, } from '../dto/opencode-config.dto'; +import { AgenstraNotificationPublisher, type AgentConfigLayer } from '../notifications/agenstra-notification.publisher'; import { ClientsRepository } from '../repositories/clients.repository'; import { ClientAgentOpencodeConfigProxyService } from '../services/client-agent-opencode-config-proxy.service'; import { OpencodeConfigService } from '../services/opencode-config.service'; import { OpencodeConfigSyncTargetsService } from '../services/opencode-config-sync-targets.service'; +import { + hasAgentConfigChanges, + summarizeAgentConfigChange, + type AgentConfigLayerSnapshot, +} from '../utils/agent-config-change.utils'; import { assertNoCredentialKeysInConfig } from '../utils/opencode-config-credentials.utils'; import { buildMcpOAuthCallbackUrl, @@ -56,8 +64,33 @@ export class ClientOpencodeConfigController { private readonly configSyncTargets: OpencodeConfigSyncTargetsService, private readonly clientsRepository: ClientsRepository, private readonly clientUsersRepository: ClientUsersRepository, + @Optional() private readonly notificationPublisher?: AgenstraNotificationPublisher, ) {} + private notifyLayerUpdated( + layer: AgentConfigLayer, + clientId: string, + agentId: string | null, + before: AgentConfigLayerSnapshot, + after: AgentConfigLayerSnapshot, + secretsPatch: Record | null | undefined, + req?: RequestWithUser, + ): void { + const summary = summarizeAgentConfigChange(before, after, secretsPatch); + + if (!hasAgentConfigChanges(summary)) { + return; + } + + this.notificationPublisher?.publishAgentConfigUpdated({ + layer, + clientId, + agentId, + ...summary, + updatedByUserId: getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null, + }); + } + @Get('opencode/config') @RequireScopes('clients:read') async getWorkspace( @@ -78,8 +111,11 @@ export class ClientOpencodeConfigController { ): Promise { await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + const before = await this.opencodeConfigService.getClient(clientId); const response = await this.opencodeConfigService.putClient(clientId, dto); + this.notifyLayerUpdated('workspace', clientId, null, before, response, dto.secrets, req); + void this.configSyncTargets.markAndProcessClient(clientId).catch((error: unknown) => { const err = error as { message?: string }; @@ -117,6 +153,11 @@ export class ClientOpencodeConfigController { lockedPaths: heredity.lockedPaths, inheritedAdditive: heredity.inheritedAdditive, sync, + platformPolicy: this.opencodeConfigService.resolvePlatformPolicy({ + global: layers.global.overlay, + workspace: layers.workspace.overlay, + environment: agentOverlay, + }), }; } @@ -171,6 +212,8 @@ export class ClientOpencodeConfigController { ); const sync = await this.configSyncTargets.markAndProcessAgent(clientId, agentId); + this.notifyLayerUpdated('environment', clientId, agentId, existing, agentConfig, sanitized.secrets, req); + return { ...agentConfig, overrides: agentConfig.overrides ?? {}, @@ -179,6 +222,11 @@ export class ClientOpencodeConfigController { lockedPaths: heredity.lockedPaths, inheritedAdditive: heredity.inheritedAdditive, sync, + platformPolicy: this.opencodeConfigService.resolvePlatformPolicy({ + global: layers.global.overlay, + workspace: layers.workspace.overlay, + environment: agentOverlay, + }), }; } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts index dae40c8ca..97a6a4252 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts @@ -105,6 +105,37 @@ export class ClientStatisticsController { }); } + /** + * Get egress guard decisions (web traffic filtering) for a client. + * Query params: agentId, kind, decision, from, to, search, limit, offset + */ + @Get('egress-events') + async getEgressEvents( + @Param('clientId', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Query('agentId') agentId?: string, + @Query('kind') kind?: string, + @Query('decision') decision?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('search') search?: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + @Req() req?: RequestWithUser, + ) { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + return await this.statisticsQueryService.getClientEgressEvents(clientId, { + agentId, + kind, + decision, + from, + to, + search, + limit, + offset, + }); + } + /** * Get filter flags (messages flagged/modified but not dropped) for a client. * Query params: agentId, filterType, from, to, limit, offset diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index 992de39ff..9b643380d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -44,6 +44,7 @@ import { ClientAgentProxyService } from '../services/client-agent-proxy.service' import { ClientAgentVncProxyService } from '../services/client-agent-vnc-proxy.service'; import { ClientsService } from '../services/clients.service'; import { OpencodeConfigSyncTargetsService } from '../services/opencode-config-sync-targets.service'; +import { OpencodeEffectiveConfigSyncService } from '../services/opencode-effective-config-sync.service'; import { OpencodeLayerFilesService } from '../services/opencode-layer-files.service'; import { ProvisioningService } from '../services/provisioning.service'; import { WorkspaceSearchIndexService } from '../search/workspace-search-index.service'; @@ -242,6 +243,15 @@ describe('ClientsController', () => { resetFailedAndEmitAllForAgent: jest.fn().mockResolvedValue(undefined), }, }, + { + provide: OpencodeEffectiveConfigSyncService, + useValue: { + buildCreationPlatformPolicy: jest.fn().mockResolvedValue({ + egress: { mode: 'enforce', allowHosts: [], denyHosts: [] }, + messageFilters: [], + }), + }, + }, { provide: OpencodeConfigSyncTargetsService, useValue: { @@ -510,7 +520,11 @@ describe('ClientsController', () => { const result = await controller.createClientAgent('client-uuid', createDto, mockReq); expect(result).toEqual(mockCreateAgentResponse); - expect(proxyService.createClientAgent).toHaveBeenCalledWith('client-uuid', createDto, undefined); + expect(proxyService.createClientAgent).toHaveBeenCalledWith( + 'client-uuid', + { ...createDto, egressPolicy: { mode: 'enforce', allowHosts: [], denyHosts: [] } }, + undefined, + ); expect(opencodeLayerFilesService.resetFailedAndEmitAllForAgent).toHaveBeenCalledWith('client-uuid', 'agent-uuid'); expect(configSyncTargets.markAndProcessAgent).toHaveBeenCalledWith('client-uuid', 'agent-uuid'); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts index 442fb7520..35fc23766 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts @@ -72,6 +72,7 @@ import { WorkspaceSearchIndexService } from '../search/workspace-search-index.se import { ClientsService } from '../services/clients.service'; import { OpencodeLayerFilesService } from '../services/opencode-layer-files.service'; import { OpencodeConfigSyncTargetsService } from '../services/opencode-config-sync-targets.service'; +import { OpencodeEffectiveConfigSyncService } from '../services/opencode-effective-config-sync.service'; import { ProvisioningService } from '../services/provisioning.service'; /** @@ -95,6 +96,7 @@ export class ClientsController { private readonly workspaceSearchIndex: WorkspaceSearchIndexService, private readonly opencodeLayerFilesService: OpencodeLayerFilesService, private readonly configSyncTargets: OpencodeConfigSyncTargetsService, + private readonly effectiveConfigSync: OpencodeEffectiveConfigSyncService, ) {} private syncAgentRuntime(clientId: string, agentId: string): void { @@ -386,8 +388,14 @@ export class ClientsController { ): Promise { await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); - - const created = await this.clientAgentProxyService.createClientAgent(id, createAgentDto, userInfo.userId); + // The egress policy is always derived from the layered agent configuration (never from the request), + // so the manager can provision the egress guard before the environment first starts. + const { egress } = await this.effectiveConfigSync.buildCreationPlatformPolicy(id); + const created = await this.clientAgentProxyService.createClientAgent( + id, + { ...createAgentDto, egressPolicy: egress as unknown as Record }, + userInfo.userId, + ); this.syncAgentRuntime(id, created.id); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts deleted file mode 100644 index 8154f1781..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -jest.mock('@forepath/identity/backend', () => { - const actual = jest.requireActual('@forepath/identity/backend') as Record; - - return { - ...actual, - getUserFromRequest: jest.fn(), - }; -}); - -import * as identity from '@forepath/identity/backend'; -import { ForbiddenException } from '@nestjs/common'; - -import { CreateFilterRuleDto } from '../dto/filter-rules/create-filter-rule.dto'; -import { FilterRuleResponseDto } from '../dto/filter-rules/filter-rule-response.dto'; -import { UpdateFilterRuleDto } from '../dto/filter-rules/update-filter-rule.dto'; -import { FilterRulesService } from '../services/filter-rules.service'; - -import { FilterRulesController } from './filter-rules.controller'; - -const getUserFromRequestMock = identity.getUserFromRequest as jest.MockedFunction; - -describe('FilterRulesController', () => { - const mockService = { - findAll: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - } as unknown as FilterRulesService; - let controller: FilterRulesController; - const adminReq = {} as Parameters[0]; - const sampleRule: FilterRuleResponseDto = { - id: '11111111-1111-1111-1111-111111111111', - pattern: 'x', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - priority: 0, - enabled: true, - isGlobal: true, - workspaceIds: [], - sync: { pending: 0, synced: 1, failed: 0 }, - workspaceSync: [{ clientId: '22222222-2222-2222-2222-222222222222', syncStatus: 'synced' }], - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }; - - beforeEach(() => { - jest.clearAllMocks(); - getUserFromRequestMock.mockReturnValue({ - userId: 'user-1', - userRole: identity.UserRole.ADMIN, - isApiKeyAuth: false, - } as identity.UserInfoFromRequest); - controller = new FilterRulesController(mockService); - }); - - describe('authorization', () => { - it('throws ForbiddenException for non-admin session users', async () => { - getUserFromRequestMock.mockReturnValueOnce({ - userId: 'user-1', - userRole: identity.UserRole.USER, - isApiKeyAuth: false, - } as identity.UserInfoFromRequest); - await expect(controller.list(adminReq)).rejects.toThrow(ForbiddenException); - expect(mockService.findAll).not.toHaveBeenCalled(); - }); - - it('allows API key authentication without admin role', async () => { - getUserFromRequestMock.mockReturnValueOnce({ - isApiKeyAuth: true, - } as identity.UserInfoFromRequest); - (mockService.findAll as jest.Mock).mockResolvedValue([]); - await controller.list(adminReq); - expect(mockService.findAll).toHaveBeenCalled(); - }); - - it('allows PAT admin sessions through assertAdmin and relies on RequireScopes for PAT scope enforcement', async () => { - getUserFromRequestMock.mockReturnValueOnce({ - userId: 'user-1', - userRole: identity.UserRole.ADMIN, - isApiKeyAuth: false, - amr: ['pat'], - } as identity.UserInfoFromRequest); - (mockService.findAll as jest.Mock).mockResolvedValue([]); - await controller.list(adminReq); - expect(mockService.findAll).toHaveBeenCalled(); - }); - }); - - describe('list', () => { - it('uses default limit and offset', async () => { - (mockService.findAll as jest.Mock).mockResolvedValue([sampleRule]); - const result = await controller.list(adminReq, undefined, undefined); - - expect(result).toEqual([sampleRule]); - expect(mockService.findAll).toHaveBeenCalledWith(10, 0, undefined); - }); - - it('passes explicit limit, offset, and search', async () => { - (mockService.findAll as jest.Mock).mockResolvedValue([]); - await controller.list(adminReq, 25, 5, 'incoming'); - expect(mockService.findAll).toHaveBeenCalledWith(25, 5, 'incoming'); - }); - }); - - describe('getOne', () => { - it('returns a single rule', async () => { - (mockService.findOne as jest.Mock).mockResolvedValue(sampleRule); - const result = await controller.getOne(sampleRule.id, adminReq); - - expect(result).toEqual(sampleRule); - expect(mockService.findOne).toHaveBeenCalledWith(sampleRule.id); - }); - }); - - describe('create', () => { - it('returns created rule from service', async () => { - const dto: CreateFilterRuleDto = { - pattern: 'a', - direction: 'incoming', - filterType: 'none', - isGlobal: true, - }; - - (mockService.create as jest.Mock).mockResolvedValue(sampleRule); - const result = await controller.create(dto, adminReq); - - expect(result).toEqual(sampleRule); - expect(mockService.create).toHaveBeenCalledWith(dto); - }); - }); - - describe('update', () => { - it('delegates to service', async () => { - const dto: UpdateFilterRuleDto = { enabled: false }; - const updated = { ...sampleRule, enabled: false }; - - (mockService.update as jest.Mock).mockResolvedValue(updated); - const result = await controller.update(sampleRule.id, dto, adminReq); - - expect(result).toEqual(updated); - expect(mockService.update).toHaveBeenCalledWith(sampleRule.id, dto); - }); - }); - - describe('delete', () => { - it('delegates to service', async () => { - (mockService.delete as jest.Mock).mockResolvedValue(undefined); - await controller.delete(sampleRule.id, adminReq); - expect(mockService.delete).toHaveBeenCalledWith(sampleRule.id); - }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts deleted file mode 100644 index 5aaa19ab3..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { RequireScopes, UserRole, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; -import { - Body, - Controller, - Delete, - ForbiddenException, - Get, - HttpCode, - HttpStatus, - Param, - ParseIntPipe, - ParseUUIDPipe, - Post, - Put, - Query, - Req, -} from '@nestjs/common'; - -import { CreateFilterRuleDto } from '../dto/filter-rules/create-filter-rule.dto'; -import { FilterRuleResponseDto } from '../dto/filter-rules/filter-rule-response.dto'; -import { UpdateFilterRuleDto } from '../dto/filter-rules/update-filter-rule.dto'; -import { FilterRulesService } from '../services/filter-rules.service'; - -@Controller('filter-rules') -@RequireScopes('filter_rules:write') -export class FilterRulesController { - constructor(private readonly filterRulesService: FilterRulesService) {} - - private assertAdmin(req?: RequestWithUser): void { - const u = getUserFromRequest(req || ({} as RequestWithUser)); - - if (u.isApiKeyAuth) { - return; - } - - if (u.userRole !== UserRole.ADMIN) { - throw new ForbiddenException('Admin only'); - } - } - - @Get() - async list( - @Req() req: RequestWithUser, - @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, - @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, - @Query('search') search?: string, - ): Promise { - this.assertAdmin(req); - - return await this.filterRulesService.findAll(limit ?? 10, offset ?? 0, search); - } - - @Get(':id') - async getOne( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Req() req: RequestWithUser, - ): Promise { - this.assertAdmin(req); - - return await this.filterRulesService.findOne(id); - } - - @Post() - @HttpCode(HttpStatus.CREATED) - async create(@Body() dto: CreateFilterRuleDto, @Req() req: RequestWithUser): Promise { - this.assertAdmin(req); - - return await this.filterRulesService.create(dto); - } - - @Put(':id') - async update( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateFilterRuleDto, - @Req() req: RequestWithUser, - ): Promise { - this.assertAdmin(req); - - return await this.filterRulesService.update(id, dto); - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - async delete( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Req() req: RequestWithUser, - ): Promise { - this.assertAdmin(req); - await this.filterRulesService.delete(id); - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts index 075b14c2c..974edf08c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts @@ -116,6 +116,37 @@ export class StatisticsController { }); } + /** + * Get egress guard decisions (web traffic filtering), aggregate or filtered by client. + * Query params: clientId?, agentId?, kind?, decision?, from, to, search, limit, offset + */ + @Get('egress-events') + async getEgressEvents( + @Query('clientId') clientId?: string, + @Query('agentId') agentId?: string, + @Query('kind') kind?: string, + @Query('decision') decision?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('search') search?: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + @Req() req?: RequestWithUser, + ) { + const ids = await this.resolveClientIds(clientId, req); + + return await this.statisticsQueryService.getEgressEvents(ids, { + agentId, + kind, + decision, + from, + to, + search, + limit, + offset, + }); + } + /** * Get filter flags (messages flagged/modified but not dropped). * Query params: clientId?, agentId?, filterType?, from, to, limit, offset diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/create-filter-rule.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/create-filter-rule.dto.ts deleted file mode 100644 index 77df5479a..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/create-filter-rule.dto.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - IsArray, - IsBoolean, - IsIn, - IsInt, - IsNotEmpty, - IsOptional, - IsString, - IsUUID, - MaxLength, - Min, - ValidateIf, -} from 'class-validator'; - -const DIRECTIONS = ['incoming', 'outgoing', 'bidirectional'] as const; -const FILTER_TYPES = ['none', 'filter', 'drop'] as const; - -export class CreateFilterRuleDto { - @IsNotEmpty() - @IsString() - @MaxLength(20000) - pattern!: string; - - @IsOptional() - @IsString() - @MaxLength(16) - regexFlags?: string; - - @IsIn(DIRECTIONS) - direction!: (typeof DIRECTIONS)[number]; - - @IsIn(FILTER_TYPES) - filterType!: (typeof FILTER_TYPES)[number]; - - @ValidateIf((o: CreateFilterRuleDto) => o.filterType === 'filter') - @IsNotEmpty() - @IsString() - @MaxLength(20000) - replaceContent?: string; - - @IsOptional() - @IsInt() - @Min(0) - priority?: number; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsBoolean() - isGlobal!: boolean; - - @ValidateIf((o: CreateFilterRuleDto) => !o.isGlobal) - @IsArray() - @IsUUID('4', { each: true }) - workspaceIds?: string[]; -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/filter-rule-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/filter-rule-response.dto.ts deleted file mode 100644 index 4c10cb08d..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/filter-rule-response.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { FilterRuleSyncStatus } from '../../entities/agent-console-regex-filter-rule-sync-target.entity'; -import type { - ConsoleRegexFilterDirection, - ConsoleRegexFilterType, -} from '../../entities/agent-console-regex-filter-rule.entity'; - -export class FilterRuleSyncSummaryDto { - pending!: number; - synced!: number; - failed!: number; -} - -/** Per-workspace agent-manager sync state for a filter rule. */ -export class FilterRuleWorkspaceSyncDto { - clientId!: string; - syncStatus!: FilterRuleSyncStatus; - lastError?: string | null; -} - -export class FilterRuleResponseDto { - id!: string; - pattern!: string; - regexFlags!: string; - direction!: ConsoleRegexFilterDirection; - filterType!: ConsoleRegexFilterType; - replaceContent?: string | null; - priority!: number; - enabled!: boolean; - isGlobal!: boolean; - workspaceIds!: string[]; - sync!: FilterRuleSyncSummaryDto; - /** One entry per workspace sync target (global and scoped rules). */ - workspaceSync!: FilterRuleWorkspaceSyncDto[]; - createdAt!: string; - updatedAt!: string; -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/index.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/index.ts deleted file mode 100644 index 6a455f275..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './create-filter-rule.dto'; -export * from './update-filter-rule.dto'; -export * from './filter-rule-response.dto'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/update-filter-rule.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/update-filter-rule.dto.ts deleted file mode 100644 index 65ac90acb..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/filter-rules/update-filter-rule.dto.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; - -const DIRECTIONS = ['incoming', 'outgoing', 'bidirectional'] as const; -const FILTER_TYPES = ['none', 'filter', 'drop'] as const; - -export class UpdateFilterRuleDto { - @IsOptional() - @IsString() - @MaxLength(20000) - pattern?: string; - - @IsOptional() - @IsString() - @MaxLength(16) - regexFlags?: string; - - @IsOptional() - @IsIn(DIRECTIONS) - direction?: (typeof DIRECTIONS)[number]; - - @IsOptional() - @IsIn(FILTER_TYPES) - filterType?: (typeof FILTER_TYPES)[number]; - - @IsOptional() - @IsString() - @MaxLength(20000) - replaceContent?: string | null; - - @IsOptional() - @IsInt() - @Min(0) - priority?: number; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsBoolean() - isGlobal?: boolean; - - @IsOptional() - @IsArray() - @IsUUID('4', { each: true }) - workspaceIds?: string[]; -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/opencode-config.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/opencode-config.dto.ts index 98f1a5c7c..f882b895a 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/opencode-config.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/opencode-config.dto.ts @@ -1,4 +1,6 @@ -import { IsArray, IsObject, IsOptional, IsString, MinLength } from 'class-validator'; +import type { AgentEgressStatusDto } from '@forepath/agenstra/backend/feature-agent-manager'; +import type { EffectivePlatformSecurityPolicy } from '@forepath/agenstra/shared/util-opencode-config'; +import { IsArray, IsObject, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; /** * PUT body for OpenCode config overlays (global / workspace / agent). @@ -78,9 +80,37 @@ export class OpencodeConfigResponseDto { inheritedAdditive?: InheritedAdditiveDto[]; /** Per-agent effective-config sync target (agent GET/PUT only). */ sync?: OpencodeConfigSyncSummaryDto | null; + /** + * Effective platform security policy for this scope (Security → Web traffic / Message filters), + * resolved global → workspace → environment with per-entry provenance. + */ + platformPolicy?: EffectivePlatformSecurityPolicy; + /** Egress guard status of the environment (agent GET/PUT only). */ + egress?: AgentEgressStatusDto; updatedAt?: Date; } +/** Body for evaluating a destination against an environment's effective egress policy. */ +export class EvaluateEgressDestinationDto { + /** Hostname, IP address or URL (scheme / path optional). */ + @IsString() + @MinLength(1) + @MaxLength(2048) + destination!: string; +} + +/** Result of {@link EvaluateEgressDestinationDto}. */ +export class EgressEvaluationResponseDto { + host!: string; + decision!: 'allowed' | 'blocked' | 'observed'; + rule!: string | null; + matchedEntry?: string | null; + source?: string | null; + mode!: string; + /** Plain-HTTP URL rules only apply to `http://` URLs (HTTPS paths are not visible without TLS interception). */ + scheme?: 'http' | 'https' | null; +} + export class OpencodeAgentInfoDto { name!: string; description?: string; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/index.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/index.ts index 4bc4b8e80..deb474c62 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/index.ts @@ -3,3 +3,4 @@ export { StatisticsChatIoDto, StatisticsChatIoListDto } from './statistics-chat- export { StatisticsFilterDropDto, StatisticsFilterDropListDto } from './statistics-filter-drop.dto'; export { StatisticsFilterFlagDto, StatisticsFilterFlagListDto } from './statistics-filter-flag.dto'; export { StatisticsEntityEventDto, StatisticsEntityEventListDto } from './statistics-entity-event.dto'; +export { StatisticsEgressEventDto, StatisticsEgressEventListDto } from './statistics-egress-event.dto'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-egress-event.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-egress-event.dto.ts new file mode 100644 index 000000000..3d7161558 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-egress-event.dto.ts @@ -0,0 +1,41 @@ +/** + * DTO for one ingested egress guard decision (aggregate of identical decisions within one collection cycle). + */ +export class StatisticsEgressEventDto { + id!: string; + clientId!: string; + agentId?: string; + clientName?: string; + agentName?: string; + /** `web` | `dns` | `network` | `guard` */ + kind!: string; + /** `allowed` | `blocked` | `observed` | `error` */ + decision!: string; + host?: string | null; + /** Guard rule (`deny_host`, `not_allowlisted`, `private_network`, `ip_literal`, `deny_url`, `offline`, `quic`, …). */ + rule?: string | null; + matchedEntry?: string | null; + /** Layer that contributed the matched entry (`global` | `workspace` | `environment`). */ + matchedSource?: string | null; + method?: string | null; + httpStatus?: number | null; + sampleUrl?: string | null; + count!: number; + bytesIn!: number; + bytesOut!: number; + mode?: string | null; + policyRevision?: string | null; + message?: string | null; + firstSeenAt!: Date; + occurredAt!: Date; +} + +/** + * Paginated response for egress events list. + */ +export class StatisticsEgressEventListDto { + data!: StatisticsEgressEventDto[]; + total!: number; + limit!: number; + offset!: number; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts index c0556bf63..51669b1f6 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts @@ -18,4 +18,24 @@ export class StatisticsSummaryDto { filterFlagCount!: number; filterFlagsBreakdown!: { filterType: string; direction: string; count: number }[]; series?: { period: string; count: number; wordCount: number; charCount: number }[]; + /** Egress guard decisions (sum of aggregated counts) by decision. */ + egressAllowedCount!: number; + egressBlockedCount!: number; + egressObservedCount!: number; + egressGuardErrorCount!: number; + /** Decisions by kind (`web` | `dns` | `network` | `guard`) and decision. */ + egressBreakdown!: { kind: string; decision: string; count: number }[]; + /** Most frequently blocked / observed destinations (top 10). */ + egressTopBlockedHosts!: { host: string; count: number }[]; + /** Blocked / observed decisions by guard rule. */ + egressRulesBreakdown!: { rule: string; count: number }[]; + /** Security events over time (only with `groupBy`): message filter drops / flags and egress decisions. */ + securitySeries?: { + period: string; + filterDrops: number; + filterFlags: number; + egressAllowed: number; + egressBlocked: number; + egressObserved: number; + }[]; } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.spec.ts deleted file mode 100644 index 85af40a8b..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AgentConsoleRegexFilterRuleClientEntity } from './agent-console-regex-filter-rule-client.entity'; -import { AgentConsoleRegexFilterRuleEntity } from './agent-console-regex-filter-rule.entity'; - -describe('AgentConsoleRegexFilterRuleClientEntity', () => { - it('creates an instance', () => { - expect(new AgentConsoleRegexFilterRuleClientEntity()).toBeDefined(); - }); - - it('supports rule and client ids', () => { - const row = new AgentConsoleRegexFilterRuleClientEntity(); - - row.id = 'link-uuid'; - row.ruleId = 'rule-uuid'; - row.clientId = 'client-uuid'; - - const rule = new AgentConsoleRegexFilterRuleEntity(); - - rule.id = 'rule-uuid'; - row.rule = rule; - - expect(row.ruleId).toBe('rule-uuid'); - expect(row.clientId).toBe('client-uuid'); - expect(row.rule).toBe(rule); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts deleted file mode 100644 index dfce5f3cd..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-client.entity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ClientEntity } from '@forepath/identity/backend'; -import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; - -import { AgentConsoleRegexFilterRuleEntity } from './agent-console-regex-filter-rule.entity'; - -@Entity('agent_console_regex_filter_rule_clients') -export class AgentConsoleRegexFilterRuleClientEntity { - @PrimaryGeneratedColumn('uuid', { name: 'id' }) - id!: string; - - @Column({ type: 'uuid', name: 'rule_id' }) - ruleId!: string; - - @ManyToOne(() => AgentConsoleRegexFilterRuleEntity, (r) => r.clientLinks, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'rule_id' }) - rule!: AgentConsoleRegexFilterRuleEntity; - - @Column({ type: 'uuid', name: 'client_id' }) - clientId!: string; - - @ManyToOne(() => ClientEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'client_id' }) - client!: ClientEntity; -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.spec.ts deleted file mode 100644 index b7475c4e6..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from './agent-console-regex-filter-rule-sync-target.entity'; -import { AgentConsoleRegexFilterRuleEntity } from './agent-console-regex-filter-rule.entity'; - -describe('AgentConsoleRegexFilterRuleSyncTargetEntity', () => { - it('creates an instance', () => { - expect(new AgentConsoleRegexFilterRuleSyncTargetEntity()).toBeDefined(); - }); - - it('supports sync fields', () => { - const t = new AgentConsoleRegexFilterRuleSyncTargetEntity(); - - t.id = 'target-uuid'; - t.ruleId = 'rule-uuid'; - t.clientId = 'client-uuid'; - t.managerRuleId = 'manager-rule-uuid'; - t.desiredOnManager = true; - t.syncStatus = 'pending'; - t.lastError = null; - t.lastSyncedAt = null; - t.updatedAt = new Date(); - - const rule = new AgentConsoleRegexFilterRuleEntity(); - - rule.id = 'rule-uuid'; - t.rule = rule; - - expect(t.syncStatus).toBe('pending'); - expect(t.managerRuleId).toBe('manager-rule-uuid'); - expect(t.rule).toBe(rule); - }); - - it('supports failed status with error text', () => { - const t = new AgentConsoleRegexFilterRuleSyncTargetEntity(); - - t.syncStatus = 'failed'; - t.lastError = 'timeout'; - expect(t.lastError).toBe('timeout'); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.ts deleted file mode 100644 index b76196a33..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule-sync-target.entity.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ClientEntity } from '@forepath/identity/backend'; -import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; - -import { AgentConsoleRegexFilterRuleEntity } from './agent-console-regex-filter-rule.entity'; - -export type FilterRuleSyncStatus = 'pending' | 'synced' | 'failed'; - -@Entity('agent_console_regex_filter_rule_sync_targets') -export class AgentConsoleRegexFilterRuleSyncTargetEntity { - @PrimaryGeneratedColumn('uuid', { name: 'id' }) - id!: string; - - @Column({ type: 'uuid', name: 'rule_id' }) - ruleId!: string; - - @ManyToOne(() => AgentConsoleRegexFilterRuleEntity, (r) => r.syncTargets, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'rule_id' }) - rule!: AgentConsoleRegexFilterRuleEntity; - - @Column({ type: 'uuid', name: 'client_id' }) - clientId!: string; - - @ManyToOne(() => ClientEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'client_id' }) - client!: ClientEntity; - - /** Remote id on agent-manager after successful create. */ - @Column({ type: 'uuid', name: 'manager_rule_id', nullable: true }) - managerRuleId?: string | null; - - /** When false, scheduler deletes remote rule then clears manager_rule_id. */ - @Column({ type: 'boolean', name: 'desired_on_manager', default: true }) - desiredOnManager!: boolean; - - @Column({ type: 'varchar', length: 16, name: 'sync_status', default: 'pending' }) - syncStatus!: FilterRuleSyncStatus; - - @Column({ type: 'text', name: 'last_error', nullable: true }) - lastError?: string | null; - - @Column({ type: 'timestamp', name: 'last_synced_at', nullable: true }) - lastSyncedAt?: Date | null; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt!: Date; -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.spec.ts deleted file mode 100644 index 92ad278b1..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AgentConsoleRegexFilterRuleClientEntity } from './agent-console-regex-filter-rule-client.entity'; -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from './agent-console-regex-filter-rule-sync-target.entity'; -import { AgentConsoleRegexFilterRuleEntity } from './agent-console-regex-filter-rule.entity'; - -describe('AgentConsoleRegexFilterRuleEntity', () => { - it('creates an instance', () => { - expect(new AgentConsoleRegexFilterRuleEntity()).toBeDefined(); - }); - - it('supports core columns', () => { - const rule = new AgentConsoleRegexFilterRuleEntity(); - - rule.id = 'rule-uuid'; - rule.pattern = 'foo+'; - rule.regexFlags = 'gi'; - rule.direction = 'bidirectional'; - rule.filterType = 'filter'; - rule.replaceContent = '$1'; - rule.priority = 2; - rule.enabled = true; - rule.isGlobal = false; - rule.createdAt = new Date('2024-01-01'); - rule.updatedAt = new Date('2024-01-02'); - - expect(rule.pattern).toBe('foo+'); - expect(rule.regexFlags).toBe('gi'); - expect(rule.direction).toBe('bidirectional'); - expect(rule.filterType).toBe('filter'); - expect(rule.replaceContent).toBe('$1'); - expect(rule.priority).toBe(2); - expect(rule.enabled).toBe(true); - expect(rule.isGlobal).toBe(false); - }); - - it('supports optional relations', () => { - const rule = new AgentConsoleRegexFilterRuleEntity(); - const link = new AgentConsoleRegexFilterRuleClientEntity(); - const target = new AgentConsoleRegexFilterRuleSyncTargetEntity(); - - rule.clientLinks = [link]; - rule.syncTargets = [target]; - expect(rule.clientLinks).toEqual([link]); - expect(rule.syncTargets).toEqual([target]); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.ts deleted file mode 100644 index b4c71ccb7..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/agent-console-regex-filter-rule.entity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; - -import { AgentConsoleRegexFilterRuleClientEntity } from './agent-console-regex-filter-rule-client.entity'; -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from './agent-console-regex-filter-rule-sync-target.entity'; - -export type ConsoleRegexFilterDirection = 'incoming' | 'outgoing' | 'bidirectional'; -export type ConsoleRegexFilterType = 'none' | 'filter' | 'drop'; - -@Entity('agent_console_regex_filter_rules') -export class AgentConsoleRegexFilterRuleEntity { - @PrimaryGeneratedColumn('uuid', { name: 'id' }) - id!: string; - - @Column({ type: 'text', name: 'pattern' }) - pattern!: string; - - @Column({ type: 'varchar', length: 16, name: 'regex_flags', default: 'g' }) - regexFlags!: string; - - @Column({ type: 'varchar', length: 32, name: 'direction' }) - direction!: ConsoleRegexFilterDirection; - - @Column({ type: 'varchar', length: 16, name: 'filter_type' }) - filterType!: ConsoleRegexFilterType; - - @Column({ type: 'text', name: 'replace_content', nullable: true }) - replaceContent?: string | null; - - @Column({ type: 'int', name: 'priority', default: 0 }) - priority!: number; - - @Column({ type: 'boolean', name: 'enabled', default: true }) - enabled!: boolean; - - @Column({ type: 'boolean', name: 'is_global', default: false }) - isGlobal!: boolean; - - @OneToMany(() => AgentConsoleRegexFilterRuleClientEntity, (c) => c.rule, { cascade: ['insert', 'remove'] }) - clientLinks?: AgentConsoleRegexFilterRuleClientEntity[]; - - @OneToMany(() => AgentConsoleRegexFilterRuleSyncTargetEntity, (t) => t.rule, { cascade: ['insert', 'remove'] }) - syncTargets?: AgentConsoleRegexFilterRuleSyncTargetEntity[]; - - @CreateDateColumn({ name: 'created_at' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt!: Date; -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/egress-event-ingest-cursor.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/egress-event-ingest-cursor.entity.ts new file mode 100644 index 000000000..35f6b4507 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/egress-event-ingest-cursor.entity.ts @@ -0,0 +1,28 @@ +import { ClientEntity } from '@forepath/identity/backend'; +import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +/** + * Per-workspace cursor into the agent manager's egress event feed (`GET /api/egress/events?afterSeq=`). + */ +@Entity('egress_event_ingest_cursors') +export class EgressEventIngestCursorEntity { + @PrimaryColumn({ type: 'uuid', name: 'client_id' }) + clientId!: string; + + @OneToOne(() => ClientEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'client_id' }) + client?: ClientEntity; + + /** Last ingested manager `seq` (stringified bigint). */ + @Column({ type: 'bigint', name: 'last_seq', default: 0 }) + lastSeq!: string; + + @Column({ type: 'text', nullable: true, name: 'last_error' }) + lastError?: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'last_ingested_at' }) + lastIngestedAt?: Date | null; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-config-sync-target.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-config-sync-target.entity.ts index c41fa1e21..7c513f8b0 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-config-sync-target.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-config-sync-target.entity.ts @@ -3,7 +3,7 @@ import { Column, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from export type OpencodeConfigSyncStatus = 'pending' | 'synced' | 'failed'; /** - * Per-agent desired vs applied OpenCode effective config revision (filter-rules pattern). + * Per-agent desired vs applied OpenCode effective config revision. */ @Entity('opencode_config_sync_targets') @Index('IDX_opencode_config_sync_agent', ['agentId'], { unique: true }) @@ -28,6 +28,10 @@ export class OpencodeConfigSyncTargetEntity { @Column({ type: 'varchar', length: 16, name: 'sync_status', default: 'pending' }) syncStatus!: OpencodeConfigSyncStatus; + /** Hash of the effective egress policy last applied successfully (drives `egress.policy_*` notifications). */ + @Column({ type: 'varchar', length: 64, name: 'applied_egress_revision', nullable: true }) + appliedEgressRevision?: string | null; + @Column({ type: 'text', name: 'last_error', nullable: true }) lastError?: string | null; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-layer-file-sync-target.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-layer-file-sync-target.entity.ts index 2afa4d232..7e2346dd9 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-layer-file-sync-target.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/opencode-layer-file-sync-target.entity.ts @@ -5,7 +5,7 @@ import { OpencodeLayerFileEntity } from './opencode-layer-file.entity'; export type OpencodeLayerFileSyncStatus = 'pending' | 'synced' | 'failed'; /** - * Per-agent emit status for a layer file revision (filter-rules sync-target pattern). + * Per-agent emit status for a layer file revision (durable sync-target pattern). */ @Entity('opencode_layer_file_sync_targets') export class OpencodeLayerFileSyncTargetEntity { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-egress-event.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-egress-event.entity.ts new file mode 100644 index 000000000..62ffe2db2 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-egress-event.entity.ts @@ -0,0 +1,92 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; + +import { StatisticsAgentEntity } from './statistics-agent.entity'; +import { StatisticsClientEntity } from './statistics-client.entity'; + +/** + * Egress guard decisions (web traffic filtering) ingested from agent managers. + * Each row is an aggregate of identical decisions within one manager collection cycle + * (`count` = number of requests / lookups / connection attempts). + */ +@Entity('statistics_egress_events') +@Index('IDX_statistics_egress_events_client_occurred_at', ['statisticsClientId', 'occurredAt']) +@Index('IDX_statistics_egress_events_agent_occurred_at', ['statisticsAgentId', 'occurredAt']) +@Index('IDX_statistics_egress_events_decision_occurred_at', ['decision', 'occurredAt']) +@Index('UQ_statistics_egress_events_source', ['statisticsClientId', 'sourceSeq'], { unique: true }) +export class StatisticsEgressEventEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', nullable: true, name: 'statistics_agent_id' }) + statisticsAgentId?: string; + + @ManyToOne(() => StatisticsAgentEntity, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'statistics_agent_id' }) + statisticsAgent?: StatisticsAgentEntity; + + @Column({ type: 'uuid', name: 'statistics_client_id' }) + statisticsClientId!: string; + + @ManyToOne(() => StatisticsClientEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'statistics_client_id' }) + statisticsClient?: StatisticsClientEntity; + + /** Manager-side `agent_egress_events.seq` (idempotent ingestion per workspace). */ + @Column({ type: 'bigint', name: 'source_seq' }) + sourceSeq!: string; + + /** `web` | `dns` | `network` | `guard` */ + @Column({ type: 'varchar', length: 16, name: 'kind' }) + kind!: string; + + /** `allowed` | `blocked` | `observed` | `error` */ + @Column({ type: 'varchar', length: 16, name: 'decision' }) + decision!: string; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'host' }) + host?: string | null; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'rule' }) + rule?: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'matched_entry' }) + matchedEntry?: string | null; + + /** Layer that contributed the matched entry (`global` | `workspace` | `environment`). */ + @Column({ type: 'varchar', length: 16, nullable: true, name: 'matched_source' }) + matchedSource?: string | null; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'method' }) + method?: string | null; + + @Column({ type: 'int', nullable: true, name: 'http_status' }) + httpStatus?: number | null; + + @Column({ type: 'text', nullable: true, name: 'sample_url' }) + sampleUrl?: string | null; + + @Column({ type: 'int', name: 'count', default: 1 }) + count!: number; + + @Column({ type: 'bigint', name: 'bytes_in', default: 0 }) + bytesIn!: string; + + @Column({ type: 'bigint', name: 'bytes_out', default: 0 }) + bytesOut!: string; + + @Column({ type: 'varchar', length: 16, nullable: true, name: 'mode' }) + mode?: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true, name: 'policy_revision' }) + policyRevision?: string | null; + + @Column({ type: 'text', nullable: true, name: 'message' }) + message?: string | null; + + @Column({ type: 'timestamp', name: 'first_seen_at' }) + firstSeenAt!: Date; + + /** Last occurrence within the aggregate (time axis for audit charts). */ + @Column({ type: 'timestamp', name: 'occurred_at' }) + occurredAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index f701826aa..5452b9af1 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -583,7 +583,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat ) .catch(() => undefined); - this.notificationPublisher.publishFilterRuleTriggered(currentClientId, { + this.notificationPublisher.publishMessageFilterTriggered(currentClientId, { agentId: lastAgentId, direction: direction === FilterDropDirection.OUTGOING ? 'outgoing' : 'incoming', status: 'dropped', @@ -630,7 +630,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat ) .catch(() => undefined); - this.notificationPublisher.publishFilterRuleTriggered(currentClientId, { + this.notificationPublisher.publishMessageFilterTriggered(currentClientId, { agentId: lastAgentId, direction: flagDirection === FilterFlagDirection.OUTGOING ? 'outgoing' : 'incoming', status: 'filtered', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index c13ffa405..ff25eb05a 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -18,7 +18,8 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { KEYCLOAK_CONNECT_OPTIONS, KEYCLOAK_INSTANCE } from 'nest-keycloak-connect'; import { ClientsController } from '../controllers/clients.controller'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; +import { EgressEventIngestCursorEntity } from '../entities/egress-event-ingest-cursor.entity'; +import { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity'; import { AtlassianSiteConnectionEntity } from '../entities/atlassian-site-connection.entity'; import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; @@ -61,35 +62,14 @@ import { ClientAgentFileSystemProxyService } from '../services/client-agent-file import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; import { ClientsService } from '../services/clients.service'; import { ExternalImportSyncMarkerService } from '../services/external-import-sync-marker.service'; -import { FilterRulesService } from '../services/filter-rules.service'; import { ClientsModule } from './clients.module'; import { AgenstraNotificationsModule } from './agenstra-notifications.module'; import { ContextImportModule } from './context-import.module'; -import { FilterRulesModule } from './filter-rules.module'; import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; import { AgenstraSearchIndexService } from '../search/agenstra-search-index.service'; import { WorkspaceSearchIndexService } from '../search/workspace-search-index.service'; -@Module({ - providers: [ - { - provide: FilterRulesService, - useValue: { - countForMetrics: jest.fn().mockResolvedValue({ - rulesEnabled: 0, - rulesDisabled: 0, - syncPending: 0, - syncSynced: 0, - syncFailed: 0, - }), - }, - }, - ], - exports: [FilterRulesService], -}) -class StubFilterRulesModule {} - @Module({ providers: [ { @@ -111,7 +91,7 @@ class StubContextImportModule {} useValue: { publishClient: jest.fn(), publishTicket: jest.fn(), - publishFilterRule: jest.fn(), + publishAgentConfigUpdated: jest.fn(), publish: jest.fn(), }, }, @@ -170,8 +150,6 @@ describe('ClientsModule', () => { const moduleBuilder = Test.createTestingModule({ imports: [ClientsModule], }) - .overrideModule(FilterRulesModule) - .useModule(StubFilterRulesModule) .overrideModule(ContextImportModule) .useModule(StubContextImportModule) .overrideModule(AgenstraNotificationsModule) @@ -272,7 +250,9 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(OpencodeConfigSyncTargetEntity)) .useValue(mockRepository) - .overrideProvider(getRepositoryToken(AgentConsoleRegexFilterRuleEntity)) + .overrideProvider(getRepositoryToken(StatisticsEgressEventEntity)) + .useValue(mockRepository) + .overrideProvider(getRepositoryToken(EgressEventIngestCursorEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(AtlassianSiteConnectionEntity)) .useValue(mockRepository) diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts index 902783b02..fb4bd862e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -41,6 +41,7 @@ import { AdminOpencodeConfigController } from '../controllers/admin-opencode-con import { AdminOpencodeLayerFilesController } from '../controllers/admin-opencode-layer-files.controller'; import { ClientOpencodeLayerFilesController } from '../controllers/client-opencode-layer-files.controller'; import { ClientOpencodeConfigController } from '../controllers/client-opencode-config.controller'; +import { ClientAgentEgressController } from '../controllers/client-agent-egress.controller'; import { McpOAuthCallbackController } from '../controllers/mcp-oauth-callback.controller'; import { OpencodeMcpServersController } from '../controllers/opencode-mcp-servers.controller'; import { OpencodeProvidersController } from '../controllers/opencode-providers.controller'; @@ -58,6 +59,7 @@ import { AgenstraAttachmentEntity } from '../entities/agenstra-attachment.entity import { OpencodeProviderEntity } from '../entities/opencode-provider.entity'; import { OpencodeLayerFileEntity } from '../entities/opencode-layer-file.entity'; import { OpencodeLayerFileSyncTargetEntity } from '../entities/opencode-layer-file-sync-target.entity'; +import { EgressEventIngestCursorEntity } from '../entities/egress-event-ingest-cursor.entity'; import { OpencodeConfigSyncTargetEntity } from '../entities/opencode-config-sync-target.entity'; import { KnowledgeNodeEmbeddingEntity } from '../entities/knowledge-node-embedding.entity'; import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; @@ -114,6 +116,7 @@ import { OpencodeMcpServersCatalogService } from '../services/opencode-mcp-serve import { OpencodeProvidersBootstrapService } from '../services/opencode-providers-bootstrap.service'; import { OpencodeProvidersCatalogService } from '../services/opencode-providers-catalog.service'; import { OpencodeEffectiveConfigSyncService } from '../services/opencode-effective-config-sync.service'; +import { EgressEventsIngestService } from '../services/egress-events-ingest.service'; import { OpencodeConfigSyncTargetsService } from '../services/opencode-config-sync-targets.service'; import { OpencodeLayerFilesService } from '../services/opencode-layer-files.service'; import { KnowledgeEmbeddingIndexService } from '../services/embeddings/knowledge-embedding-index.service'; @@ -138,7 +141,6 @@ import { WorkspaceSearchIndexService } from '../search/workspace-search-index.se import { AgenstraNotificationsModule, AGENSTRA_CONTROLLER_QUEUE_NAME } from './agenstra-notifications.module'; import { AgenstraUpdatesModule } from './agenstra-updates.module'; import { ContextImportModule } from './context-import.module'; -import { FilterRulesModule } from './filter-rules.module'; import { StatisticsModule } from './statistics.module'; const authMethod = getAuthenticationMethod(); @@ -181,6 +183,7 @@ const authMethod = getAuthenticationMethod(); OpencodeLayerFileEntity, OpencodeLayerFileSyncTargetEntity, OpencodeConfigSyncTargetEntity, + EgressEventIngestCursorEntity, ]), BullModule.registerQueue({ name: AGENSTRA_CONTROLLER_QUEUE_NAME }), RedisCacheModule, @@ -189,7 +192,6 @@ const authMethod = getAuthenticationMethod(); AgenstraNotificationsModule, AgenstraUpdatesModule, StatisticsModule, - forwardRef(() => FilterRulesModule), forwardRef(() => ContextImportModule), // Import KeycloakConnectModule conditionally to make KEYCLOAK_INSTANCE available to SocketAuthService ...(authMethod === 'keycloak' ? [KeycloakConnectModule.registerAsync({ useExisting: KeycloakService })] : []), @@ -213,6 +215,7 @@ const authMethod = getAuthenticationMethod(); AdminOpencodeConfigController, AdminOpencodeLayerFilesController, ClientOpencodeConfigController, + ClientAgentEgressController, McpOAuthCallbackController, ClientOpencodeLayerFilesController, OpencodeProvidersController, @@ -257,6 +260,7 @@ const authMethod = getAuthenticationMethod(); OpencodeMcpServersBootstrapService, OpencodeEffectiveConfigSyncService, OpencodeConfigSyncTargetsService, + EgressEventsIngestService, OpencodeLayerFilesService, ClientAgentCredentialsRepository, ClientAgentCredentialsService, @@ -334,6 +338,7 @@ const authMethod = getAuthenticationMethod(); OpencodeProvidersCatalogService, OpencodeMcpServersCatalogService, OpencodeConfigSyncTargetsService, + EgressEventsIngestService, OpencodeLayerFilesService, ], }) diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts deleted file mode 100644 index e648285e4..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/filter-rules.module.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ClientEntity } from '@forepath/identity/backend'; -import { forwardRef, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { FilterRulesController } from '../controllers/filter-rules.controller'; -import { AgentConsoleRegexFilterRuleClientEntity } from '../entities/agent-console-regex-filter-rule-client.entity'; -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from '../entities/agent-console-regex-filter-rule-sync-target.entity'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; -import { AgentManagerFilterRulesClientService } from '../services/agent-manager-filter-rules-client.service'; -import { FilterRulesSyncService } from '../services/filter-rules-sync.service'; -import { FilterRulesService } from '../services/filter-rules.service'; - -import { AgenstraSearchModule } from '../search/agenstra-search.module'; - -import { AgenstraNotificationsModule } from './agenstra-notifications.module'; -import { ClientsModule } from './clients.module'; - -/** - * Admin filter rules + background sync to agent-manager instances. - * Imported with forwardRef from {@link ClientsModule} to avoid a provider cycle - * (AgentManagerFilterRulesClientService uses {@link ClientsService}). - */ -@Module({ - imports: [ - TypeOrmModule.forFeature([ - AgentConsoleRegexFilterRuleEntity, - AgentConsoleRegexFilterRuleClientEntity, - AgentConsoleRegexFilterRuleSyncTargetEntity, - ClientEntity, - ]), - AgenstraNotificationsModule, - AgenstraSearchModule, - forwardRef(() => ClientsModule), - ], - controllers: [FilterRulesController], - providers: [AgentManagerFilterRulesClientService, FilterRulesService, FilterRulesSyncService], - exports: [FilterRulesService, FilterRulesSyncService], -}) -export class FilterRulesModule {} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/statistics.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/statistics.module.ts index 0e3c38622..9840a69cd 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/statistics.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/statistics.module.ts @@ -8,10 +8,12 @@ import { StatisticsChatFilterFlagEntity } from '../entities/statistics-chat-filt import { StatisticsChatIoEntity } from '../entities/statistics-chat-io.entity'; import { StatisticsClientUserEntity } from '../entities/statistics-client-user.entity'; import { StatisticsClientEntity } from '../entities/statistics-client.entity'; +import { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; import { StatisticsEntityEventEntity } from '../entities/statistics-entity-event.entity'; import { StatisticsProvisioningReferenceEntity } from '../entities/statistics-provisioning-reference.entity'; import { StatisticsUserEntity } from '../entities/statistics-user.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { StatisticsSecurityRepository } from '../repositories/statistics-security.repository'; import { StatisticsRepository } from '../repositories/statistics.repository'; import { AgenstraSearchModule } from '../search/agenstra-search.module'; import { StatisticsClientSyncService } from '../services/statistics-client-sync.service'; @@ -21,7 +23,7 @@ import { StatisticsService } from '../services/statistics.service'; /** * Module for persistent statistics. Provides StatisticsService for recording - * chat I/O, filter drops, and entity lifecycle events, and StatisticsQueryService + * chat I/O, filter drops, egress guard decisions and entity lifecycle events, and StatisticsQueryService * for REST API queries. Syncs users and clients to statistics mirror tables on startup. */ @Module({ @@ -38,11 +40,13 @@ import { StatisticsService } from '../services/statistics.service'; StatisticsChatIoEntity, StatisticsChatFilterDropEntity, StatisticsChatFilterFlagEntity, + StatisticsEgressEventEntity, StatisticsEntityEventEntity, ]), ], providers: [ StatisticsRepository, + StatisticsSecurityRepository, StatisticsService, StatisticsQueryService, StatisticsUserSyncService, @@ -50,6 +54,6 @@ import { StatisticsService } from '../services/statistics.service'; ClientsRepository, UsersRepository, ], - exports: [StatisticsService, StatisticsQueryService, StatisticsRepository], + exports: [StatisticsService, StatisticsQueryService, StatisticsRepository, StatisticsSecurityRepository], }) export class StatisticsModule {} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts index be8dc11e6..b5860fbe0 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts @@ -1,6 +1,17 @@ import { AGENSTRA_NOTIFICATION_EVENTS } from './agenstra-notification.events'; describe('AGENSTRA_NOTIFICATION_EVENTS', () => { + it('no longer exposes the removed filter rule CRUD events', () => { + expect(AGENSTRA_NOTIFICATION_EVENTS).not.toEqual( + expect.arrayContaining([ + 'filter_rule.created', + 'filter_rule.updated', + 'filter_rule.deleted', + 'filter_rule.triggered', + ]), + ); + }); + it('contains unique event type strings', () => { expect(new Set(AGENSTRA_NOTIFICATION_EVENTS).size).toBe(AGENSTRA_NOTIFICATION_EVENTS.length); }); @@ -17,7 +28,13 @@ describe('AGENSTRA_NOTIFICATION_EVENTS', () => { 'agent.acp.session_failed', 'agent.acp.permission_denied', 'agent.chat.failed', - 'filter_rule.triggered', + 'message_filter.triggered', + 'agent_config.updated', + 'egress.blocked', + 'egress.observed', + 'egress.guard_failed', + 'egress.policy_applied', + 'egress.policy_failed', 'environment.created', 'environment.updated', 'environment.deleted', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts index 6984bc04c..56d8279fd 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts @@ -15,10 +15,13 @@ export const AGENSTRA_NOTIFICATION_EVENTS = [ 'agent.acp.session_failed', 'agent.acp.permission_denied', 'agent.chat.failed', - 'filter_rule.created', - 'filter_rule.updated', - 'filter_rule.deleted', - 'filter_rule.triggered', + 'message_filter.triggered', + 'agent_config.updated', + 'egress.blocked', + 'egress.observed', + 'egress.guard_failed', + 'egress.policy_applied', + 'egress.policy_failed', 'environment.created', 'environment.updated', 'environment.deleted', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts index dc0ae17af..773784e36 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts @@ -2,7 +2,6 @@ import { AuthenticationType } from '@forepath/identity/backend'; import { INSTANCE_SCOPE_KEY, NotificationDispatcherService } from '@forepath/shared/backend'; import type { ClientEntity } from '@forepath/identity/backend'; -import type { FilterRuleResponseDto } from '../dto/filter-rules/filter-rule-response.dto'; import type { TicketResponseDto } from '../dto/tickets/ticket-response.dto'; import { AgenstraNotificationPublisher } from './agenstra-notification.publisher'; @@ -70,70 +69,77 @@ describe('AgenstraNotificationPublisher', () => { ); }); - it('publishes filter rule events without client id for global rules', () => { + it('publishes agent configuration updates (instance-wide for the global layer)', () => { const dispatcher = { publishFireAndForget: jest.fn(), } as unknown as NotificationDispatcherService; const publisher = new AgenstraNotificationPublisher(dispatcher); - const rule = { - id: 'rule-1', - pattern: 'secret', - regexFlags: 'i', - direction: 'outbound', - filterType: 'redact', - priority: 0, - enabled: true, - isGlobal: true, - workspaceIds: [], - sync: { pending: 0, synced: 0, failed: 0 }, - workspaceSync: [], - createdAt: '2026-07-01T10:00:00.000Z', - updatedAt: '2026-07-01T10:00:00.000Z', - } as unknown as FilterRuleResponseDto; - - publisher.publishFilterRule('filter_rule.deleted', rule); - - expect(dispatcher.publishFireAndForget).toHaveBeenCalledTimes(1); - expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ - type: 'filter_rule.deleted', + + publisher.publishAgentConfigUpdated({ + layer: 'global', + clientId: null, + agentId: null, + changedRoots: ['egress'], + securityRootsChanged: ['egress'], + locksChanged: false, + secretKeysChanged: [], + updatedByUserId: 'user-1', + }); + publisher.publishAgentConfigUpdated({ + layer: 'workspace', + clientId: 'client-1', + changedRoots: ['message_filters'], + securityRootsChanged: ['message_filters'], + locksChanged: true, + secretKeysChanged: [], + }); + + expect(dispatcher.publishFireAndForget).toHaveBeenNthCalledWith(1, { + type: 'agent_config.updated', scopeKey: INSTANCE_SCOPE_KEY, clientId: undefined, - data: expect.objectContaining({ - id: 'rule-1', - pattern: 'secret', - }), + data: expect.objectContaining({ layer: 'global', changedRoots: ['egress'] }), }); + expect(dispatcher.publishFireAndForget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: 'agent_config.updated', clientId: 'client-1' }), + ); }); - it('publishes filter rule events once per linked workspace', () => { + it('publishes egress decision, guard and policy events with workspace client id', () => { const dispatcher = { publishFireAndForget: jest.fn(), } as unknown as NotificationDispatcherService; const publisher = new AgenstraNotificationPublisher(dispatcher); - const rule = { - id: 'rule-2', - pattern: 'token', - regexFlags: 'i', - direction: 'outbound', - filterType: 'redact', - priority: 0, - enabled: true, - isGlobal: false, - workspaceIds: ['client-a', 'client-b'], - sync: { pending: 0, synced: 0, failed: 0 }, - workspaceSync: [], - createdAt: '2026-07-01T10:00:00.000Z', - updatedAt: '2026-07-01T10:00:00.000Z', - } as unknown as FilterRuleResponseDto; - - publisher.publishFilterRule('filter_rule.created', rule); - - expect(dispatcher.publishFireAndForget).toHaveBeenCalledTimes(2); + + publisher.publishEgressDecision('egress.blocked', 'client-1', { + agentId: 'agent-1', + kind: 'web', + decision: 'blocked', + host: 'example.org', + rule: 'deny_host', + count: 3, + firstSeenAt: '2026-10-12T10:00:00.000Z', + lastSeenAt: '2026-10-12T10:00:05.000Z', + }); + publisher.publishEgressGuardFailed('client-1', { + agentId: 'agent-1', + message: 'guard_missing', + occurredAt: '2026-10-12T10:00:00.000Z', + }); + publisher.publishEgressPolicy('egress.policy_applied', 'client-1', { agentId: 'agent-1', mode: 'enforce' }); + + expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ + type: 'egress.blocked', + scopeKey: INSTANCE_SCOPE_KEY, + clientId: 'client-1', + data: expect.objectContaining({ host: 'example.org', count: 3 }), + }); expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith( - expect.objectContaining({ clientId: 'client-a', type: 'filter_rule.created' }), + expect.objectContaining({ type: 'egress.guard_failed', clientId: 'client-1' }), ); expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith( - expect.objectContaining({ clientId: 'client-b', type: 'filter_rule.created' }), + expect.objectContaining({ type: 'egress.policy_applied', data: { agentId: 'agent-1', mode: 'enforce' } }), ); }); @@ -232,13 +238,13 @@ describe('AgenstraNotificationPublisher', () => { }); }); - it('publishes filter rule triggered events with workspace client id', () => { + it('publishes message filter triggered events with workspace client id', () => { const dispatcher = { publishFireAndForget: jest.fn(), } as unknown as NotificationDispatcherService; const publisher = new AgenstraNotificationPublisher(dispatcher); - publisher.publishFilterRuleTriggered('client-1', { + publisher.publishMessageFilterTriggered('client-1', { agentId: 'agent-1', direction: 'incoming', status: 'dropped', @@ -251,7 +257,7 @@ describe('AgenstraNotificationPublisher', () => { }); expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ - type: 'filter_rule.triggered', + type: 'message_filter.triggered', scopeKey: INSTANCE_SCOPE_KEY, clientId: 'client-1', data: expect.objectContaining({ diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts index 44a7701ad..0859717f9 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts @@ -7,7 +7,6 @@ import type { EnvironmentVariableResponseDto, } from '@forepath/agenstra/backend/feature-agent-manager'; -import type { FilterRuleResponseDto } from '../dto/filter-rules/filter-rule-response.dto'; import type { ClientResponseDto } from '../dto/client-response.dto'; import type { AttachmentResponseDto } from '../dto/attachment.dto'; import type { TicketCommentResponseDto } from '../dto/tickets/ticket-comment-response.dto'; @@ -19,7 +18,7 @@ export { AGENSTRA_NOTIFICATION_EVENTS, type AgenstraNotificationEventType }; export type ChatMessageDirection = 'incoming' | 'outgoing'; export type ChatMessageSource = 'user' | 'agent'; -export type FilterRuleTriggerStatus = 'dropped' | 'filtered'; +export type MessageFilterTriggerStatus = 'dropped' | 'filtered'; export type ChatMessageNotificationPayload = { agentId: string; @@ -31,10 +30,10 @@ export type ChatMessageNotificationPayload = { chatId?: string | null; }; -export type FilterRuleTriggeredNotificationPayload = { +export type MessageFilterTriggeredNotificationPayload = { agentId: string; direction: ChatMessageDirection; - status: FilterRuleTriggerStatus; + status: MessageFilterTriggerStatus; filterType: string; filterDisplayName: string; reason?: string | null; @@ -44,6 +43,51 @@ export type FilterRuleTriggeredNotificationPayload = { messagePreview?: string | null; }; +/** Layer of the agent configuration that was saved. */ +export type AgentConfigLayer = 'global' | 'workspace' | 'environment'; + +export type AgentConfigUpdatedNotificationPayload = { + layer: AgentConfigLayer; + clientId?: string | null; + agentId?: string | null; + /** Root keys whose values (config, overrides or locks) changed. Values are never included. */ + changedRoots: string[]; + /** Subset of `changedRoots` that are security policy roots (`permissions`, `egress`, `message_filters`, …). */ + securityRootsChanged: string[]; + locksChanged: boolean; + secretKeysChanged: string[]; + updatedByUserId?: string | null; +}; + +export type EgressDecisionNotificationPayload = { + agentId: string; + kind: string; + decision: string; + host?: string | null; + rule?: string | null; + matchedEntry?: string | null; + matchedSource?: string | null; + method?: string | null; + count: number; + firstSeenAt: string; + lastSeenAt: string; + /** Number of further distinct decisions omitted from this ingestion batch (notification cap). */ + omittedDecisions?: number; +}; + +export type EgressGuardFailedNotificationPayload = { + agentId: string; + message: string; + occurredAt: string; +}; + +export type EgressPolicyNotificationPayload = { + agentId: string; + mode: string; + revision?: string | null; + error?: string | null; +}; + export type EnvironmentNotificationPayload = { id: string; agentId: string; @@ -102,23 +146,6 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli this.publish(type, this.toAttachmentPayload(attachment), attachment.clientId); } - publishFilterRule( - type: 'filter_rule.created' | 'filter_rule.updated' | 'filter_rule.deleted', - rule: FilterRuleResponseDto, - ): void { - const data = this.toFilterRulePayload(rule); - - if (rule.isGlobal || rule.workspaceIds.length === 0) { - this.publish(type, data); - - return; - } - - for (const clientId of rule.workspaceIds) { - this.publish(type, data, clientId); - } - } - publishChatMessage(clientId: string, payload: ChatMessageNotificationPayload): void { this.publish('chat_message.created', payload, clientId); } @@ -141,8 +168,33 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli this.publish('agent.chat.failed', payload, clientId); } - publishFilterRuleTriggered(clientId: string, payload: FilterRuleTriggeredNotificationPayload): void { - this.publish('filter_rule.triggered', payload, clientId); + publishMessageFilterTriggered(clientId: string, payload: MessageFilterTriggeredNotificationPayload): void { + this.publish('message_filter.triggered', payload, clientId); + } + + /** Agent configuration layer saved (global layer: no clientId → instance-wide delivery). */ + publishAgentConfigUpdated(payload: AgentConfigUpdatedNotificationPayload): void { + this.publish('agent_config.updated', payload, payload.clientId ?? undefined); + } + + publishEgressDecision( + type: 'egress.blocked' | 'egress.observed', + clientId: string, + payload: EgressDecisionNotificationPayload, + ): void { + this.publish(type, payload, clientId); + } + + publishEgressGuardFailed(clientId: string, payload: EgressGuardFailedNotificationPayload): void { + this.publish('egress.guard_failed', payload, clientId); + } + + publishEgressPolicy( + type: 'egress.policy_applied' | 'egress.policy_failed', + clientId: string, + payload: EgressPolicyNotificationPayload, + ): void { + this.publish(type, payload, clientId); } publishEnvironment( @@ -236,23 +288,6 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli }; } - private toFilterRulePayload(rule: FilterRuleResponseDto): Record { - return { - id: rule.id, - pattern: rule.pattern, - regexFlags: rule.regexFlags, - direction: rule.direction, - filterType: rule.filterType, - replaceContent: rule.replaceContent ?? null, - priority: rule.priority, - enabled: rule.enabled, - isGlobal: rule.isGlobal, - workspaceIds: rule.workspaceIds, - createdAt: rule.createdAt, - updatedAt: rule.updatedAt, - }; - } - private toEnvironmentPayload( environment: EnvironmentVariableResponseDto | EnvironmentNotificationPayload, ): Record { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.spec.ts new file mode 100644 index 000000000..900e45fd0 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.spec.ts @@ -0,0 +1,190 @@ +import { StatisticsSecurityRepository } from './statistics-security.repository'; + +function queryBuilder(results: unknown[] = []) { + const qb: Record = {}; + const chain = [ + 'where', + 'andWhere', + 'select', + 'addSelect', + 'groupBy', + 'addGroupBy', + 'orderBy', + 'limit', + 'skip', + 'take', + 'innerJoin', + 'insert', + 'into', + 'values', + 'orIgnore', + 'returning', + ]; + + for (const method of chain) { + qb[method] = jest.fn(() => qb); + } + + let call = 0; + + qb['getRawMany'] = jest.fn(async () => results[call++] ?? []); + qb['getCount'] = jest.fn(async () => 0); + qb['getMany'] = jest.fn(async () => []); + qb['execute'] = jest.fn(async () => ({ raw: results[0] ?? [] })); + + return qb; +} + +describe('StatisticsSecurityRepository', () => { + function build(egressResults: unknown[] = [], dropResults: unknown[] = [], flagResults: unknown[] = []) { + const egressQb = queryBuilder(egressResults); + const egress = { + createQueryBuilder: jest.fn(() => egressQb), + create: jest.fn((value: unknown) => value), + find: jest.fn(), + }; + const drops = { createQueryBuilder: jest.fn(() => queryBuilder(dropResults)) }; + const flags = { createQueryBuilder: jest.fn(() => queryBuilder(flagResults)) }; + const repository = new StatisticsSecurityRepository(egress as never, drops as never, flags as never, {} as never); + + return { repository, egressQb }; + } + + it('returns empty aggregates without client scope', async () => { + const { repository } = build(); + + await expect(repository.queryEgressAggregate({ statisticsClientIds: [] })).resolves.toEqual({ + egressAllowedCount: 0, + egressBlockedCount: 0, + egressObservedCount: 0, + egressGuardErrorCount: 0, + egressBreakdown: [], + egressTopBlockedHosts: [], + egressRulesBreakdown: [], + }); + await expect(repository.querySecuritySeries({ statisticsClientIds: [], groupBy: 'day' })).resolves.toEqual([]); + await expect(repository.queryEgressEvents({ statisticsClientIds: [], limit: 10, offset: 0 })).resolves.toEqual({ + rows: [], + total: 0, + }); + await expect(repository.insertEgressEvents([])).resolves.toEqual([]); + }); + + it('sums decisions per kind and exposes top hosts and rules', async () => { + const { repository, egressQb } = build([ + [ + { kind: 'web', decision: 'blocked', count: '3' }, + { kind: 'dns', decision: 'blocked', count: '2' }, + { kind: 'web', decision: 'allowed', count: '40' }, + { kind: 'guard', decision: 'error', count: '1' }, + ], + [{ host: 'example.org', count: '5' }], + [{ rule: 'deny_host', count: '5' }], + ]); + + const aggregate = await repository.queryEgressAggregate({ statisticsClientIds: ['sc1'], agentId: 'agent-1' }); + + expect(aggregate).toEqual( + expect.objectContaining({ + egressAllowedCount: 40, + egressBlockedCount: 5, + egressObservedCount: 0, + egressGuardErrorCount: 1, + egressTopBlockedHosts: [{ host: 'example.org', count: 5 }], + egressRulesBreakdown: [{ rule: 'deny_host', count: 5 }], + }), + ); + expect(egressQb['andWhere']).toHaveBeenCalledWith(expect.stringContaining('original_agent_id = :agentId'), { + agentId: 'agent-1', + ids: ['sc1'], + }); + }); + + it('merges filter and egress buckets into one ordered security series', async () => { + const { repository } = build( + [ + [ + { period: '2026-10-12T00:00:00.000Z', decision: 'blocked', count: '4' }, + { period: new Date('2026-10-11T00:00:00.000Z'), decision: 'allowed', count: '9' }, + ], + ], + [[{ period: '2026-10-12T00:00:00.000Z', count: '2' }]], + [[{ period: '2026-10-10T00:00:00.000Z', count: '1' }]], + ); + + await expect( + repository.querySecuritySeries({ statisticsClientIds: ['sc1'], groupBy: 'day', from: '2026-10-01' }), + ).resolves.toEqual([ + { + period: '2026-10-10T00:00:00.000Z', + filterDrops: 0, + filterFlags: 1, + egressAllowed: 0, + egressBlocked: 0, + egressObserved: 0, + }, + { + period: '2026-10-11T00:00:00.000Z', + filterDrops: 0, + filterFlags: 0, + egressAllowed: 9, + egressBlocked: 0, + egressObserved: 0, + }, + { + period: '2026-10-12T00:00:00.000Z', + filterDrops: 2, + filterFlags: 0, + egressAllowed: 0, + egressBlocked: 4, + egressObserved: 0, + }, + ]); + }); + + it('inserts with conflict skipping and maps returned rows', async () => { + const { repository, egressQb } = build([ + [ + { + id: 'e1', + statistics_client_id: 'sc1', + statistics_agent_id: 'sa1', + source_seq: 5, + kind: 'web', + decision: 'blocked', + count: 2, + bytes_in: '1', + bytes_out: '2', + first_seen_at: '2026-10-12T10:00:00.000Z', + occurred_at: '2026-10-12T10:00:05.000Z', + }, + ], + ]); + + const inserted = await repository.insertEgressEvents([ + { + statisticsClientId: 'sc1', + statisticsAgentId: 'sa1', + sourceSeq: '5', + kind: 'web', + decision: 'blocked', + count: 2, + bytesIn: '1', + bytesOut: '2', + firstSeenAt: new Date(), + occurredAt: new Date(), + }, + ]); + + expect(egressQb['orIgnore']).toHaveBeenCalled(); + expect(inserted).toEqual([ + expect.objectContaining({ + id: 'e1', + sourceSeq: '5', + statisticsAgentId: 'sa1', + count: 2, + occurredAt: new Date('2026-10-12T10:00:05.000Z'), + }), + ]); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts new file mode 100644 index 000000000..781b6e9d9 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/statistics-security.repository.ts @@ -0,0 +1,404 @@ +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository, type SelectQueryBuilder } from 'typeorm'; + +import { StatisticsChatFilterDropEntity } from '../entities/statistics-chat-filter-drop.entity'; +import { StatisticsChatFilterFlagEntity } from '../entities/statistics-chat-filter-flag.entity'; +import { StatisticsClientEntity } from '../entities/statistics-client.entity'; +import { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; +import { AgenstraSearchIndexService } from '../search/agenstra-search-index.service'; + +const MAX_SEARCH_LENGTH = 200; +const TOP_HOSTS_LIMIT = 10; + +function escapeIlikePattern(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); +} + +function sanitizeSearch(search: string | undefined): string | undefined { + if (!search || typeof search !== 'string') return undefined; + + const trimmed = search.trim(); + + if (trimmed.length === 0) return undefined; + + return trimmed.length > MAX_SEARCH_LENGTH ? trimmed.slice(0, MAX_SEARCH_LENGTH) : trimmed; +} + +function toInt(value: string | number | null | undefined): number { + const parsed = typeof value === 'number' ? value : parseInt(value ?? '0', 10); + + return Number.isFinite(parsed) ? parsed : 0; +} + +export type NewStatisticsEgressEvent = Omit; + +export interface EgressEventsQueryParams { + statisticsClientIds: string[]; + agentId?: string; + kind?: string; + decision?: string; + from?: string; + to?: string; + search?: string; + limit: number; + offset: number; +} + +export interface EgressAggregate { + egressAllowedCount: number; + egressBlockedCount: number; + egressObservedCount: number; + egressGuardErrorCount: number; + egressBreakdown: { kind: string; decision: string; count: number }[]; + egressTopBlockedHosts: { host: string; count: number }[]; + egressRulesBreakdown: { rule: string; count: number }[]; +} + +export interface SecuritySeriesPoint { + period: string; + filterDrops: number; + filterFlags: number; + egressAllowed: number; + egressBlocked: number; + egressObserved: number; +} + +/** + * Statistics queries for security signals: egress guard decisions plus message filter time series. + */ +@Injectable() +export class StatisticsSecurityRepository { + private readonly logger = new Logger(StatisticsSecurityRepository.name); + + constructor( + @InjectRepository(StatisticsEgressEventEntity) + private readonly egressEvents: Repository, + @InjectRepository(StatisticsChatFilterDropEntity) + private readonly filterDrops: Repository, + @InjectRepository(StatisticsChatFilterFlagEntity) + private readonly filterFlags: Repository, + @InjectRepository(StatisticsClientEntity) + private readonly statisticsClients: Repository, + @Optional() private readonly searchIndex?: AgenstraSearchIndexService, + ) {} + + /** Insert ingested events; rows already ingested (same workspace + manager seq) are skipped. */ + async insertEgressEvents(rows: readonly NewStatisticsEgressEvent[]): Promise { + if (rows.length === 0) { + return []; + } + + const result = await this.egressEvents + .createQueryBuilder() + .insert() + .into(StatisticsEgressEventEntity) + .values(rows.map((row) => ({ ...row }))) + .orIgnore() + .returning('*') + .execute(); + const inserted = (result.raw ?? []) as Array>; + + return inserted.map((raw) => + this.egressEvents.create({ + id: raw['id'] as string, + statisticsAgentId: (raw['statistics_agent_id'] as string | null) ?? undefined, + statisticsClientId: raw['statistics_client_id'] as string, + sourceSeq: String(raw['source_seq']), + kind: raw['kind'] as string, + decision: raw['decision'] as string, + host: (raw['host'] as string | null) ?? null, + rule: (raw['rule'] as string | null) ?? null, + matchedEntry: (raw['matched_entry'] as string | null) ?? null, + matchedSource: (raw['matched_source'] as string | null) ?? null, + method: (raw['method'] as string | null) ?? null, + httpStatus: (raw['http_status'] as number | null) ?? null, + sampleUrl: (raw['sample_url'] as string | null) ?? null, + count: toInt(raw['count'] as number), + bytesIn: String(raw['bytes_in'] ?? 0), + bytesOut: String(raw['bytes_out'] ?? 0), + mode: (raw['mode'] as string | null) ?? null, + policyRevision: (raw['policy_revision'] as string | null) ?? null, + message: (raw['message'] as string | null) ?? null, + firstSeenAt: new Date(raw['first_seen_at'] as string), + occurredAt: new Date(raw['occurred_at'] as string), + }), + ); + } + + private applyScope( + qb: SelectQueryBuilder, + params: Omit, + ): SelectQueryBuilder { + qb = qb.where('ee.statistics_client_id IN (:...ids)', { ids: params.statisticsClientIds }); + + if (params.agentId) { + qb = qb.andWhere( + 'ee.statistics_agent_id IN (SELECT id FROM statistics_agents WHERE original_agent_id = :agentId AND statistics_client_id IN (:...ids))', + { agentId: params.agentId, ids: params.statisticsClientIds }, + ); + } + + if (params.kind) qb = qb.andWhere('ee.kind = :kind', { kind: params.kind }); + + if (params.decision) qb = qb.andWhere('ee.decision = :decision', { decision: params.decision }); + + if (params.from) qb = qb.andWhere('ee.occurred_at >= :from', { from: params.from }); + + if (params.to) qb = qb.andWhere('ee.occurred_at <= :to', { to: params.to }); + + return qb; + } + + private async trySearchIds( + search: string, + statisticsClientIds: string[], + limit: number, + offset: number, + ): Promise<{ ids: string[]; total: number } | null> { + if (!this.searchIndex?.isEnabled()) { + return null; + } + + try { + const rows = await this.statisticsClients.find({ + where: { id: In(statisticsClientIds) }, + select: ['originalClientId'], + }); + const clientIds = rows.map((row) => row.originalClientId); + + if (clientIds.length === 0) { + return { ids: [], total: 0 }; + } + + return await this.searchIndex.searchIds({ entityType: 'egress-events', query: search, clientIds, limit, offset }); + } catch (error) { + this.logger.warn(`OpenSearch egress-events lookup failed, falling back to ILIKE: ${(error as Error).message}`); + + return null; + } + } + + async queryEgressEvents( + params: EgressEventsQueryParams, + ): Promise<{ rows: StatisticsEgressEventEntity[]; total: number }> { + if (params.statisticsClientIds.length === 0) return { rows: [], total: 0 }; + + const sanitized = sanitizeSearch(params.search); + + if (sanitized) { + const openSearchIds = await this.trySearchIds(sanitized, params.statisticsClientIds, params.limit, params.offset); + + if (openSearchIds) { + if (openSearchIds.ids.length === 0) { + return { rows: [], total: 0 }; + } + + const scoped = () => + this.applyScope(this.egressEvents.createQueryBuilder('ee'), params).andWhere('ee.id IN (:...searchIds)', { + searchIds: openSearchIds.ids, + }); + const total = await scoped().getCount(); + const plain = await scoped().getMany(); + const byId = new Map(plain.map((row) => [row.id, row])); + const orderedIds = openSearchIds.ids.filter((id) => byId.has(id)); + + return { rows: await this.loadWithRelations(orderedIds), total: total || openSearchIds.total }; + } + } + + const pattern = sanitized ? `%${escapeIlikePattern(sanitized)}%` : undefined; + const scoped = () => { + const qb = this.applyScope(this.egressEvents.createQueryBuilder('ee'), params); + + if (pattern) { + qb.andWhere( + "(COALESCE(ee.host, '') ILIKE :search OR ee.kind ILIKE :search OR ee.decision ILIKE :search OR COALESCE(ee.rule, '') ILIKE :search OR COALESCE(ee.matched_entry, '') ILIKE :search OR COALESCE(ee.method, '') ILIKE :search OR COALESCE(ee.message, '') ILIKE :search)", + { search: pattern }, + ); + } + + return qb; + }; + const total = await scoped().getCount(); + const plain = await scoped().orderBy('ee.occurred_at', 'DESC').skip(params.offset).take(params.limit).getMany(); + + return { rows: plain.length === 0 ? [] : await this.loadWithRelations(plain.map((row) => row.id)), total }; + } + + private async loadWithRelations(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + const entities = await this.egressEvents.find({ + where: { id: In(ids) }, + relations: ['statisticsClient', 'statisticsAgent'], + }); + const byId = new Map(entities.map((entity) => [entity.id, entity])); + + return ids.map((id) => byId.get(id)).filter((entity): entity is StatisticsEgressEventEntity => Boolean(entity)); + } + + async queryEgressAggregate(params: { + statisticsClientIds: string[]; + agentId?: string; + from?: string; + to?: string; + }): Promise { + const empty: EgressAggregate = { + egressAllowedCount: 0, + egressBlockedCount: 0, + egressObservedCount: 0, + egressGuardErrorCount: 0, + egressBreakdown: [], + egressTopBlockedHosts: [], + egressRulesBreakdown: [], + }; + + if (params.statisticsClientIds.length === 0) { + return empty; + } + + const breakdownRows = await this.applyScope(this.egressEvents.createQueryBuilder('ee'), params) + .select('ee.kind', 'kind') + .addSelect('ee.decision', 'decision') + .addSelect('COALESCE(SUM(ee.count), 0)', 'count') + .groupBy('ee.kind') + .addGroupBy('ee.decision') + .getRawMany<{ kind: string; decision: string; count: string }>(); + const hostRows = await this.applyScope(this.egressEvents.createQueryBuilder('ee'), params) + .select('ee.host', 'host') + .addSelect('COALESCE(SUM(ee.count), 0)', 'count') + .andWhere("ee.decision IN ('blocked', 'observed')") + .andWhere('ee.host IS NOT NULL') + .groupBy('ee.host') + .orderBy('count', 'DESC') + .limit(TOP_HOSTS_LIMIT) + .getRawMany<{ host: string; count: string }>(); + const ruleRows = await this.applyScope(this.egressEvents.createQueryBuilder('ee'), params) + .select('ee.rule', 'rule') + .addSelect('COALESCE(SUM(ee.count), 0)', 'count') + .andWhere("ee.decision IN ('blocked', 'observed')") + .andWhere('ee.rule IS NOT NULL') + .groupBy('ee.rule') + .orderBy('count', 'DESC') + .getRawMany<{ rule: string; count: string }>(); + const egressBreakdown = breakdownRows.map((row) => ({ + kind: row.kind, + decision: row.decision, + count: toInt(row.count), + })); + const sumDecision = (decision: string) => + egressBreakdown.filter((row) => row.decision === decision).reduce((sum, row) => sum + row.count, 0); + + return { + egressAllowedCount: sumDecision('allowed'), + egressBlockedCount: sumDecision('blocked'), + egressObservedCount: sumDecision('observed'), + egressGuardErrorCount: sumDecision('error'), + egressBreakdown, + egressTopBlockedHosts: hostRows.map((row) => ({ host: row.host, count: toInt(row.count) })), + egressRulesBreakdown: ruleRows.map((row) => ({ rule: row.rule, count: toInt(row.count) })), + }; + } + + /** Message filter drops / flags and egress decisions bucketed by day or hour. */ + async querySecuritySeries(params: { + statisticsClientIds: string[]; + agentId?: string; + from?: string; + to?: string; + groupBy: 'day' | 'hour'; + }): Promise { + if (params.statisticsClientIds.length === 0) { + return []; + } + + const unit = params.groupBy === 'day' ? 'day' : 'hour'; + const agentClause = (alias: string) => + `${alias}.statistics_agent_id IN (SELECT id FROM statistics_agents WHERE original_agent_id = :agentId AND statistics_client_id IN (:...ids))`; + const scopeFilters = (qb: SelectQueryBuilder, alias: string): SelectQueryBuilder => { + qb = qb.where(`${alias}.statistics_client_id IN (:...ids)`, { ids: params.statisticsClientIds }); + + if (params.agentId) + qb = qb.andWhere(agentClause(alias), { agentId: params.agentId, ids: params.statisticsClientIds }); + + if (params.from) qb = qb.andWhere(`${alias}.occurred_at >= :from`, { from: params.from }); + + if (params.to) qb = qb.andWhere(`${alias}.occurred_at <= :to`, { to: params.to }); + + return qb; + }; + const [dropRows, flagRows, egressRows] = await Promise.all([ + scopeFilters(this.filterDrops.createQueryBuilder('fd'), 'fd') + .select(`date_trunc('${unit}', fd.occurred_at)`, 'period') + .addSelect('COUNT(fd.id)', 'count') + .groupBy('period') + .getRawMany<{ period: string | Date; count: string }>(), + scopeFilters(this.filterFlags.createQueryBuilder('ff'), 'ff') + .select(`date_trunc('${unit}', ff.occurred_at)`, 'period') + .addSelect('COUNT(ff.id)', 'count') + .groupBy('period') + .getRawMany<{ period: string | Date; count: string }>(), + scopeFilters(this.egressEvents.createQueryBuilder('ee'), 'ee') + .select(`date_trunc('${unit}', ee.occurred_at)`, 'period') + .addSelect('ee.decision', 'decision') + .addSelect('COALESCE(SUM(ee.count), 0)', 'count') + .andWhere("ee.decision IN ('allowed', 'blocked', 'observed')") + .groupBy('period') + .addGroupBy('ee.decision') + .getRawMany<{ period: string | Date; decision: string; count: string }>(), + ]); + const points = new Map(); + const point = (raw: string | Date): SecuritySeriesPoint => { + const period = raw instanceof Date ? raw.toISOString() : new Date(raw).toISOString(); + let existing = points.get(period); + + if (!existing) { + existing = { period, filterDrops: 0, filterFlags: 0, egressAllowed: 0, egressBlocked: 0, egressObserved: 0 }; + points.set(period, existing); + } + + return existing; + }; + + for (const row of dropRows) point(row.period).filterDrops += toInt(row.count); + + for (const row of flagRows) point(row.period).filterFlags += toInt(row.count); + + for (const row of egressRows) { + const target = point(row.period); + + if (row.decision === 'allowed') target.egressAllowed += toInt(row.count); + else if (row.decision === 'blocked') target.egressBlocked += toInt(row.count); + else if (row.decision === 'observed') target.egressObserved += toInt(row.count); + } + + return [...points.values()].sort((left, right) => left.period.localeCompare(right.period)); + } + + /** Totals per original client / kind / decision for OTEL gauges. */ + async countEgressEventsGroupedForMetrics(): Promise< + Array<{ clientId: string; kind: string; decision: string; count: number }> + > { + const rows = await this.egressEvents + .createQueryBuilder('ee') + .innerJoin('ee.statisticsClient', 'sc') + .select('sc.original_client_id', 'clientId') + .addSelect('ee.kind', 'kind') + .addSelect('ee.decision', 'decision') + .addSelect('COALESCE(SUM(ee.count), 0)', 'count') + .groupBy('sc.original_client_id') + .addGroupBy('ee.kind') + .addGroupBy('ee.decision') + .getRawMany<{ clientId: string; kind: string; decision: string; count: string }>(); + + return rows.map((row) => ({ + clientId: row.clientId, + kind: row.kind, + decision: row.decision, + count: toInt(row.count), + })); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-document.mapper.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-document.mapper.ts index 6bcbce61e..62bd64c49 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-document.mapper.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-document.mapper.ts @@ -1,12 +1,12 @@ import type { ClientEntity } from '@forepath/identity/backend'; -import type { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; import type { AtlassianSiteConnectionEntity } from '../entities/atlassian-site-connection.entity'; import type { ExternalImportConfigEntity } from '../entities/external-import-config.entity'; import type { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; import type { StatisticsAgentEntity } from '../entities/statistics-agent.entity'; import type { StatisticsChatFilterDropEntity } from '../entities/statistics-chat-filter-drop.entity'; import type { StatisticsChatFilterFlagEntity } from '../entities/statistics-chat-filter-flag.entity'; +import type { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; import type { StatisticsChatIoEntity } from '../entities/statistics-chat-io.entity'; import type { StatisticsEntityEventEntity } from '../entities/statistics-entity-event.entity'; import type { StatisticsUserEntity } from '../entities/statistics-user.entity'; @@ -107,34 +107,6 @@ export function mapKnowledgeNodeToSearchDocument(node: KnowledgeNodeEntity): Age }; } -export function mapFilterRuleToSearchDocument( - rule: AgentConsoleRegexFilterRuleEntity, - clientIds: string[], -): AgenstraSearchDocument { - const primaryClientId = rule.isGlobal ? null : (clientIds[0] ?? null); - - return { - id: rule.id, - entityType: 'filter-rules', - clientId: primaryClientId, - clientIds: rule.isGlobal ? undefined : clientIds, - instanceScoped: rule.isGlobal, - pattern: rule.pattern, - filterType: rule.filterType, - direction: rule.direction, - createdAt: toIso(rule.createdAt), - updatedAt: toIso(rule.updatedAt), - searchableText: joinSearchable([ - rule.id, - rule.pattern, - rule.filterType, - rule.direction, - rule.replaceContent, - ...clientIds, - ]), - }; -} - export function mapDeploymentRunToSearchDocument(input: { id: string; clientId: string; @@ -217,6 +189,37 @@ export function mapFilterFlagToSearchDocument( }; } +export function mapEgressEventToSearchDocument( + row: StatisticsEgressEventEntity, + originalClientId: string, + originalAgentId?: string | null, +): AgenstraSearchDocument { + return { + id: row.id, + entityType: 'egress-events', + clientId: originalClientId, + statisticsClientId: row.statisticsClientId, + agentId: originalAgentId ?? null, + host: row.host ?? null, + decision: row.decision, + kind: row.kind, + rule: row.rule ?? null, + label: row.matchedEntry ?? null, + createdAt: toIso(row.occurredAt), + searchableText: joinSearchable([ + row.id, + row.host, + row.kind, + row.decision, + row.rule, + row.matchedEntry, + row.matchedSource, + row.method, + row.message, + ]), + }; +} + export function mapEntityEventToSearchDocument( row: StatisticsEntityEventEntity, originalClientId: string | null, @@ -381,7 +384,7 @@ export function isAgenstraSearchEntityType(value: string): value is AgenstraSear value === 'agents' || value === 'tickets' || value === 'knowledge-nodes' || - value === 'filter-rules' || + value === 'egress-events' || value === 'deployment-runs' || value === 'chat-io' || value === 'filter-drops' || diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.spec.ts index 0fcf7b486..ef2375858 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.spec.ts @@ -4,7 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ClientEntity } from '@forepath/identity/backend'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; +import { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; import { AtlassianSiteConnectionEntity } from '../entities/atlassian-site-connection.entity'; import { ExternalImportConfigEntity } from '../entities/external-import-config.entity'; import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; @@ -51,7 +51,7 @@ describe('AgenstraSearchIndexService', () => { { provide: getRepositoryToken(ClientEntity), useValue: emptyRepo }, { provide: getRepositoryToken(TicketEntity), useValue: emptyRepo }, { provide: getRepositoryToken(KnowledgeNodeEntity), useValue: emptyRepo }, - { provide: getRepositoryToken(AgentConsoleRegexFilterRuleEntity), useValue: emptyRepo }, + { provide: getRepositoryToken(StatisticsEgressEventEntity), useValue: emptyRepo }, { provide: getRepositoryToken(AtlassianSiteConnectionEntity), useValue: emptyRepo }, { provide: getRepositoryToken(ExternalImportConfigEntity), useValue: emptyRepo }, { provide: getRepositoryToken(StatisticsAgentEntity), useValue: emptyRepo }, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.ts index 6c50f9680..f20aeeaeb 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-index.service.ts @@ -4,7 +4,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; import { AtlassianSiteConnectionEntity } from '../entities/atlassian-site-connection.entity'; import { ExternalImportConfigEntity } from '../entities/external-import-config.entity'; import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; @@ -14,6 +13,7 @@ import { StatisticsAgentEntity } from '../entities/statistics-agent.entity'; import { StatisticsChatFilterDropEntity } from '../entities/statistics-chat-filter-drop.entity'; import { StatisticsChatFilterFlagEntity } from '../entities/statistics-chat-filter-flag.entity'; import { StatisticsChatIoEntity } from '../entities/statistics-chat-io.entity'; +import { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; import { StatisticsClientEntity } from '../entities/statistics-client.entity'; import { StatisticsEntityEventEntity } from '../entities/statistics-entity-event.entity'; import { StatisticsUserEntity } from '../entities/statistics-user.entity'; @@ -25,10 +25,10 @@ import { mapAtlassianConnectionToSearchDocument, mapChatIoToSearchDocument, mapClientToSearchDocument, + mapEgressEventToSearchDocument, mapEntityEventToSearchDocument, mapFilterDropToSearchDocument, mapFilterFlagToSearchDocument, - mapFilterRuleToSearchDocument, mapImportConfigToSearchDocument, mapKnowledgeNodeToSearchDocument, mapOpencodeMcpServerToSearchDocument, @@ -60,8 +60,8 @@ export class AgenstraSearchIndexService { private readonly ticketsRepo: Repository, @InjectRepository(KnowledgeNodeEntity) private readonly knowledgeNodesRepo: Repository, - @InjectRepository(AgentConsoleRegexFilterRuleEntity) - private readonly filterRulesRepo: Repository, + @InjectRepository(StatisticsEgressEventEntity) + private readonly egressEventsRepo: Repository, @InjectRepository(AtlassianSiteConnectionEntity) private readonly atlassianConnectionsRepo: Repository, @InjectRepository(ExternalImportConfigEntity) @@ -218,8 +218,8 @@ export class AgenstraSearchIndexService { return await this.reindexTickets(offset, limit); case 'knowledge-nodes': return await this.reindexKnowledgeNodes(offset, limit); - case 'filter-rules': - return await this.reindexFilterRules(offset, limit); + case 'egress-events': + return await this.reindexEgressEvents(offset, limit); case 'chat-io': return await this.reindexChatIo(offset, limit); case 'filter-drops': @@ -362,21 +362,25 @@ export class AgenstraSearchIndexService { return { indexed: rows.length, hasMore: rows.length === limit }; } - private async reindexFilterRules(offset: number, limit: number): Promise { - const rows = await this.filterRulesRepo.find({ - order: { createdAt: 'ASC' }, + private async reindexEgressEvents(offset: number, limit: number): Promise { + const rows = await this.egressEventsRepo.find({ + order: { occurredAt: 'ASC' }, skip: offset, take: limit, - relations: { clientLinks: true }, + relations: { statisticsAgent: true }, }); - const documents = rows.map((row) => - mapFilterRuleToSearchDocument( - row, - (row.clientLinks ?? []).map((link) => link.clientId), - ), - ); + const clientMap = await this.originalClientIdMap(rows.map((row) => row.statisticsClientId)); + const documents = rows + .map((row) => { + const originalClientId = clientMap.get(row.statisticsClientId); + + return originalClientId + ? mapEgressEventToSearchDocument(row, originalClientId, row.statisticsAgent?.originalAgentId ?? null) + : null; + }) + .filter((document): document is AgenstraSearchDocument => document !== null); - await this.bulkIndex('filter-rules', documents); + await this.bulkIndex('egress-events', documents); return { indexed: rows.length, hasMore: rows.length === limit }; } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-list.util.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-list.util.ts index 6881dec47..ffad3f38e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-list.util.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search-list.util.ts @@ -120,10 +120,13 @@ const SEARCH_FIELD_COLUMNS: Partial< status: (alias) => `${alias}.node_type::text`, id: (alias) => `CAST(${alias}.id AS text)`, }, - 'filter-rules': { - pattern: (alias) => `${alias}.pattern`, - filterType: (alias) => `${alias}.filter_type`, - direction: (alias) => `${alias}.direction::text`, + 'egress-events': { + host: (alias) => `${alias}.host`, + decision: (alias) => `${alias}.decision`, + kind: (alias) => `${alias}.kind`, + rule: (alias) => `${alias}.rule`, + label: (alias) => `${alias}.matched_entry`, + provider: (alias) => `${alias}.method`, id: (alias) => `CAST(${alias}.id AS text)`, }, 'atlassian-connections': { @@ -190,10 +193,6 @@ export function applyAgenstraSearchIlike( clauses.push(`${alias}.confluence_space_key ILIKE :agenstraSearchTerm`); } - if (entityType === 'filter-rules') { - clauses.push(`COALESCE(${alias}.replace_content, '') ILIKE :agenstraSearchTerm`); - } - if (clauses.length === 0) { return; } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.module.ts index 8c16ac458..595db39e9 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.module.ts @@ -3,7 +3,6 @@ import { OpenSearchModule } from '@forepath/shared/backend/util-opensearch'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; import { AtlassianSiteConnectionEntity } from '../entities/atlassian-site-connection.entity'; import { ExternalImportConfigEntity } from '../entities/external-import-config.entity'; import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; @@ -11,6 +10,7 @@ import { StatisticsAgentEntity } from '../entities/statistics-agent.entity'; import { StatisticsChatFilterDropEntity } from '../entities/statistics-chat-filter-drop.entity'; import { StatisticsChatFilterFlagEntity } from '../entities/statistics-chat-filter-flag.entity'; import { StatisticsChatIoEntity } from '../entities/statistics-chat-io.entity'; +import { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; import { StatisticsClientEntity } from '../entities/statistics-client.entity'; import { StatisticsEntityEventEntity } from '../entities/statistics-entity-event.entity'; import { StatisticsUserEntity } from '../entities/statistics-user.entity'; @@ -27,7 +27,6 @@ import { AgenstraSearchIndexService } from './agenstra-search-index.service'; ClientEntity, TicketEntity, KnowledgeNodeEntity, - AgentConsoleRegexFilterRuleEntity, AtlassianSiteConnectionEntity, ExternalImportConfigEntity, StatisticsAgentEntity, @@ -35,6 +34,7 @@ import { AgenstraSearchIndexService } from './agenstra-search-index.service'; StatisticsChatIoEntity, StatisticsChatFilterDropEntity, StatisticsChatFilterFlagEntity, + StatisticsEgressEventEntity, StatisticsEntityEventEntity, StatisticsUserEntity, OpencodeProviderEntity, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.types.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.types.ts index 9717340b9..519190acf 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.types.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/search/agenstra-search.types.ts @@ -3,7 +3,7 @@ export const AGENSTRA_SEARCH_ENTITY_TYPES = [ 'agents', 'tickets', 'knowledge-nodes', - 'filter-rules', + 'egress-events', 'deployment-runs', 'chat-io', 'filter-drops', @@ -50,6 +50,10 @@ export interface AgenstraSearchDocument { variable?: string | null; agentId?: string | null; role?: string | null; + host?: string | null; + decision?: string | null; + kind?: string | null; + rule?: string | null; wordCount?: number | null; charCount?: number | null; updatedAt?: string | null; @@ -110,6 +114,10 @@ export const AGENSTRA_SEARCH_TEXT_FIELDS = [ 'variable', 'agentId', 'role', + 'host', + 'decision', + 'kind', + 'rule', 'id', ] as const; @@ -134,6 +142,10 @@ export const AGENSTRA_SEARCH_INDEX_MAPPINGS: Record = { agentType: { type: 'keyword' }, containerType: { type: 'keyword' }, role: { type: 'keyword' }, + decision: { type: 'keyword' }, + kind: { type: 'keyword' }, + rule: { type: 'keyword' }, + host: { type: 'text' }, title: { type: 'text' }, name: { type: 'text' }, description: { type: 'text' }, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.spec.ts index 6d56dc7ea..f8fde8fb3 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.spec.ts @@ -16,8 +16,8 @@ jest.mock('../repositories/statistics.repository', () => ({ StatisticsRepository: class StatisticsRepository {}, })); -jest.mock('./filter-rules.service', () => ({ - FilterRulesService: class FilterRulesService {}, +jest.mock('../repositories/statistics-security.repository', () => ({ + StatisticsSecurityRepository: class StatisticsSecurityRepository {}, })); jest.mock('../entities/ticket.enums', () => ({ @@ -60,8 +60,8 @@ describe('AgenstraMetricsCollectorService', () => { countFilterDropsGroupedForMetrics: jest.fn(), countFilterFlagsGroupedForMetrics: jest.fn(), }; - const filterRulesService = { - countForMetrics: jest.fn(), + const statisticsSecurityRepository = { + countEgressEventsGroupedForMetrics: jest.fn(), }; beforeEach(() => { @@ -70,7 +70,7 @@ describe('AgenstraMetricsCollectorService', () => { resolveOtelRuntimeConfig.mockReturnValue({ enabled: true }); }); - it('sets workspace, ticket, chat, filter-trigger, and filter-rule gauges when OTEL is enabled', async () => { + it('sets workspace, ticket, chat, filter-trigger, and egress gauges when OTEL is enabled', async () => { clientsRepository.count.mockResolvedValue(1); clientsRepository.findAllIds.mockResolvedValue(['client-a']); createQueryBuilder.mockReturnValue({ @@ -96,19 +96,15 @@ describe('AgenstraMetricsCollectorService', () => { statisticsRepository.countFilterFlagsGroupedForMetrics.mockResolvedValue([ { clientId: 'client-a', direction: 'outgoing', filterType: 'filter', count: 2 }, ]); - filterRulesService.countForMetrics.mockResolvedValue({ - rulesEnabled: 5, - rulesDisabled: 1, - syncPending: 2, - syncSynced: 4, - syncFailed: 0, - }); + statisticsSecurityRepository.countEgressEventsGroupedForMetrics.mockResolvedValue([ + { clientId: 'client-a', kind: 'web', decision: 'blocked', count: 7 }, + ]); const service = new AgenstraMetricsCollectorService( clientsRepository as never, ticketRepository as never, statisticsRepository as never, - filterRulesService as never, + statisticsSecurityRepository as never, ); await (service as unknown as { collectMetrics: () => Promise }).collectMetrics(); @@ -143,11 +139,16 @@ describe('AgenstraMetricsCollectorService', () => { direction: 'outgoing', filter_type: 'filter', }); - expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.filter_rules', 5, { - enabled: 'true', - }); - expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.filter_rule_sync_targets', 2, { - sync_status: 'pending', + expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.egress_events', 7, { + client_id: 'client-a', + kind: 'web', + decision: 'blocked', }); + expect(setGauge).not.toHaveBeenCalledWith( + 'forepath.agenstra', + 'agenstra.filter_rules', + expect.anything(), + expect.anything(), + ); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts index dc2304a35..6a1baa2f1 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agenstra-metrics-collector.service.ts @@ -1,5 +1,5 @@ import { isOtelEffectivelyEnabled, resolveOtelRuntimeConfig, setGauge } from '@forepath/shared/backend/util-otel'; -import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit, forwardRef } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -7,10 +7,9 @@ import { ChatDirection, StatisticsInteractionKind } from '../entities/statistics import { TicketEntity } from '../entities/ticket.entity'; import { TicketStatus } from '../entities/ticket.enums'; import { ClientsRepository } from '../repositories/clients.repository'; +import { StatisticsSecurityRepository } from '../repositories/statistics-security.repository'; import { StatisticsRepository } from '../repositories/statistics.repository'; -import { FilterRulesService } from './filter-rules.service'; - const METER_NAME = 'forepath.agenstra'; const POLL_INTERVAL_MS = 60_000; @@ -24,8 +23,7 @@ export class AgenstraMetricsCollectorService implements OnModuleInit, OnModuleDe @InjectRepository(TicketEntity) private readonly ticketRepository: Repository, private readonly statisticsRepository: StatisticsRepository, - @Inject(forwardRef(() => FilterRulesService)) - private readonly filterRulesService: FilterRulesService, + private readonly statisticsSecurityRepository: StatisticsSecurityRepository, ) {} onModuleInit(): void { @@ -70,7 +68,7 @@ export class AgenstraMetricsCollectorService implements OnModuleInit, OnModuleDe } await this.collectChatAndFilterMetrics(clientIds); - await this.collectFilterRuleMetrics(); + await this.collectEgressMetrics(); } catch (error) { this.logger.warn(`Failed to collect Agenstra OTEL metrics: ${(error as Error).message}`); } @@ -120,14 +118,16 @@ export class AgenstraMetricsCollectorService implements OnModuleInit, OnModuleDe } } - private async collectFilterRuleMetrics(): Promise { - const counts = await this.filterRulesService.countForMetrics(); + private async collectEgressMetrics(): Promise { + const rows = await this.statisticsSecurityRepository.countEgressEventsGroupedForMetrics(); - setGauge(METER_NAME, 'agenstra.filter_rules', counts.rulesEnabled, { enabled: 'true' }); - setGauge(METER_NAME, 'agenstra.filter_rules', counts.rulesDisabled, { enabled: 'false' }); - setGauge(METER_NAME, 'agenstra.filter_rule_sync_targets', counts.syncPending, { sync_status: 'pending' }); - setGauge(METER_NAME, 'agenstra.filter_rule_sync_targets', counts.syncSynced, { sync_status: 'synced' }); - setGauge(METER_NAME, 'agenstra.filter_rule_sync_targets', counts.syncFailed, { sync_status: 'failed' }); + for (const row of rows) { + setGauge(METER_NAME, 'agenstra.egress_events', row.count, { + client_id: row.clientId, + kind: row.kind, + decision: row.decision, + }); + } } private async countTicketsGroupedByClientAndStatus(): Promise> { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.spec.ts deleted file mode 100644 index 50cf8c227..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { - CreateRegexFilterRuleDto, - RegexFilterRuleResponseDto, - UpdateRegexFilterRuleDto, -} from '@forepath/agenstra/backend/feature-agent-manager'; -import { AuthenticationType, ClientEntity } from '@forepath/identity/backend'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import axios, { AxiosError } from 'axios'; - -import { ClientsRepository } from '../repositories/clients.repository'; - -import { AgentManagerFilterRulesClientService } from './agent-manager-filter-rules-client.service'; -import { ClientsService } from './clients.service'; - -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -describe('AgentManagerFilterRulesClientService', () => { - let service: AgentManagerFilterRulesClientService; - const mockClientId = '11111111-1111-1111-1111-111111111111'; - const mockManagerRuleId = '22222222-2222-2222-2222-222222222222'; - const mockClientEntity: ClientEntity = { - id: mockClientId, - name: 'Test Client', - description: 'Test Description', - endpoint: 'https://example.com/api', - authenticationType: AuthenticationType.API_KEY, - apiKey: 'test-api-key', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }; - const createDto: CreateRegexFilterRuleDto = { - pattern: 'foo', - direction: 'incoming', - filterType: 'none', - }; - const mockRuleResponse: RegexFilterRuleResponseDto = { - id: mockManagerRuleId, - pattern: 'foo', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - priority: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }; - const mockClientsService = { - getAccessToken: jest.fn(), - }; - const mockClientsRepository = { - findByIdOrThrow: jest.fn(), - }; - - beforeEach(async () => { - jest.clearAllMocks(); - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AgentManagerFilterRulesClientService, - { provide: ClientsService, useValue: mockClientsService }, - { provide: ClientsRepository, useValue: mockClientsRepository }, - ], - }).compile(); - - service = module.get(AgentManagerFilterRulesClientService); - }); - - describe('createRule', () => { - it('should POST to agent-manager agents-filters with API key auth', async () => { - mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); - mockedAxios.request.mockResolvedValue({ - status: 201, - data: mockRuleResponse, - } as never); - - const result = await service.createRule(mockClientId, createDto); - - expect(result).toEqual(mockRuleResponse); - expect(mockedAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - url: 'https://example.com/api/api/agents-filters', - data: createDto, - headers: expect.objectContaining({ - Authorization: 'Bearer test-api-key', - }), - }), - ); - }); - - it('should use Keycloak token when authentication type is KEYCLOAK', async () => { - const keycloakClient: ClientEntity = { - ...mockClientEntity, - authenticationType: AuthenticationType.KEYCLOAK, - }; - - mockClientsRepository.findByIdOrThrow.mockResolvedValue(keycloakClient); - mockClientsService.getAccessToken.mockResolvedValue('kc-token'); - mockedAxios.request.mockResolvedValue({ - status: 201, - data: mockRuleResponse, - } as never); - - await service.createRule(mockClientId, createDto); - - expect(mockClientsService.getAccessToken).toHaveBeenCalledWith(mockClientId); - expect(mockedAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer kc-token', - }), - }), - ); - }); - - it('should throw NotFoundException when manager returns 404', async () => { - mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); - mockedAxios.request.mockResolvedValue({ - status: 404, - data: { message: 'Rule not found' }, - } as never); - - await expect(service.createRule(mockClientId, createDto)).rejects.toThrow(NotFoundException); - }); - - it('should throw BadRequestException when manager returns 400', async () => { - mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); - mockedAxios.request.mockResolvedValue({ - status: 400, - data: { message: 'Invalid regex' }, - } as never); - - await expect(service.createRule(mockClientId, createDto)).rejects.toThrow(BadRequestException); - }); - }); - - describe('updateRule', () => { - it('should PUT to agent-manager with rule id in path', async () => { - const updateDto: UpdateRegexFilterRuleDto = { pattern: 'bar' }; - const updated: RegexFilterRuleResponseDto = { ...mockRuleResponse, pattern: 'bar' }; - - mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); - mockedAxios.request.mockResolvedValue({ - status: 200, - data: updated, - } as never); - - const result = await service.updateRule(mockClientId, mockManagerRuleId, updateDto); - - expect(result).toEqual(updated); - expect(mockedAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'PUT', - url: `https://example.com/api/api/agents-filters/${mockManagerRuleId}`, - data: updateDto, - }), - ); - }); - }); - - describe('deleteRule', () => { - it('should DELETE rule on agent-manager', async () => { - mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); - mockedAxios.request.mockResolvedValue({ - status: 204, - data: undefined, - } as never); - - await service.deleteRule(mockClientId, mockManagerRuleId); - - expect(mockedAxios.request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'DELETE', - url: `https://example.com/api/api/agents-filters/${mockManagerRuleId}`, - }), - ); - }); - }); - - describe('authentication', () => { - it('should throw BadRequestException when API key is missing for API_KEY client', async () => { - const clientWithoutKey: ClientEntity = { - ...mockClientEntity, - apiKey: undefined, - }; - - mockClientsRepository.findByIdOrThrow.mockResolvedValue(clientWithoutKey); - - await expect(service.createRule(mockClientId, createDto)).rejects.toThrow(BadRequestException); - expect(mockedAxios.request).not.toHaveBeenCalled(); - }); - }); - - describe('error handling', () => { - it('should map axios errors without response to BadRequestException', async () => { - mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); - const axiosError = new Error('Network error') as AxiosError; - - mockedAxios.request.mockRejectedValue(axiosError); - - await expect(service.createRule(mockClientId, createDto)).rejects.toThrow(BadRequestException); - }); - - it('should map axios response errors with 404 to NotFoundException', async () => { - mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); - const axiosError = new Error('Not found') as AxiosError; - - axiosError.response = { - status: 404, - data: { message: 'Missing' }, - } as never; - mockedAxios.request.mockRejectedValue(axiosError); - - await expect(service.createRule(mockClientId, createDto)).rejects.toThrow(NotFoundException); - }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts deleted file mode 100644 index d3bdabf4c..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-manager-filter-rules-client.service.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -import type { - CreateRegexFilterRuleDto, - RegexFilterRuleResponseDto, - UpdateRegexFilterRuleDto, -} from '@forepath/agenstra/backend/feature-agent-manager'; -import { AuthenticationType } from '@forepath/identity/backend'; -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import axios, { AxiosError, AxiosRequestConfig } from 'axios'; - -import { ClientsRepository } from '../repositories/clients.repository'; -import { getClientEndpointTlsPolicy, validateClientEndpointWithDnsOrThrow } from '../utils/client-endpoint-security'; -import { buildClientProxyRequestHeaders } from '../utils/client-proxy-request-headers'; - -import { ClientsService } from './clients.service'; - -/** - * Calls agent-manager `/api/agents-filters` for a given workspace (client). - */ -@Injectable() -export class AgentManagerFilterRulesClientService { - private readonly logger = new Logger(AgentManagerFilterRulesClientService.name); - - constructor( - private readonly clientsService: ClientsService, - private readonly clientsRepository: ClientsRepository, - ) {} - - private async getAuthHeader(clientId: string): Promise { - const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); - - if (clientEntity.authenticationType === AuthenticationType.API_KEY) { - if (!clientEntity.apiKey) { - throw new BadRequestException('API key is not configured for this client'); - } - - return `Bearer ${clientEntity.apiKey}`; - } - - if (clientEntity.authenticationType === AuthenticationType.KEYCLOAK) { - const token = await this.clientsService.getAccessToken(clientId); - - return `Bearer ${token}`; - } - - throw new BadRequestException(`Unsupported authentication type: ${clientEntity.authenticationType}`); - } - - private buildBaseUrl(endpoint: string): string { - const baseUrl = endpoint.replace(/\/$/, ''); - - return `${baseUrl}/api/agents-filters`; - } - - private async request(clientId: string, config: AxiosRequestConfig): Promise { - const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); - - await validateClientEndpointWithDnsOrThrow(clientEntity.endpoint); - const authHeader = await this.getAuthHeader(clientId); - const baseUrl = this.buildBaseUrl(clientEntity.endpoint); - const tlsPolicy = getClientEndpointTlsPolicy(this.logger); - - try { - this.logger.debug(`Filter rules ${config.method} ${baseUrl}${config.url || ''} for client ${clientId}`); - const response = await axios.request({ - ...config, - url: config.url ? `${baseUrl}${config.url}` : baseUrl, - headers: buildClientProxyRequestHeaders(config.headers, authHeader), - validateStatus: (status) => status < 500, - httpsAgent: baseUrl.startsWith('https://') - ? new (require('https').Agent)({ rejectUnauthorized: tlsPolicy.rejectUnauthorized }) - : undefined, - }); - - if (response.status >= 400) { - const errorMessage = (response.data as { message?: string })?.message || 'Request failed'; - - if (response.status === 404) { - throw new NotFoundException(errorMessage); - } - - throw new BadRequestException(errorMessage); - } - - return response.data; - } catch (error) { - if (error instanceof NotFoundException || error instanceof BadRequestException) { - throw error; - } - - const axiosError = error as AxiosError; - - if (axiosError.response) { - const errorMessage = - (axiosError.response.data as { message?: string })?.message || axiosError.message || 'Request failed'; - - if (axiosError.response.status === 404) { - throw new NotFoundException(errorMessage); - } - - throw new BadRequestException(errorMessage); - } - - throw new BadRequestException(axiosError.message || 'Request failed'); - } - } - - async createRule(clientId: string, body: CreateRegexFilterRuleDto): Promise { - return await this.request(clientId, { method: 'POST', data: body }); - } - - async updateRule( - clientId: string, - managerRuleId: string, - body: UpdateRegexFilterRuleDto, - ): Promise { - return await this.request(clientId, { - method: 'PUT', - url: `/${managerRuleId}`, - data: body, - }); - } - - async deleteRule(clientId: string, managerRuleId: string): Promise { - await this.request(clientId, { method: 'DELETE', url: `/${managerRuleId}` }); - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-opencode-config-proxy.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-opencode-config-proxy.service.ts index e07bf47b1..f56a93421 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-opencode-config-proxy.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-opencode-config-proxy.service.ts @@ -1,3 +1,4 @@ +import type { EffectivePlatformSecurityPolicy } from '@forepath/agenstra/shared/util-opencode-config'; /* eslint-disable @typescript-eslint/no-var-requires */ import { AuthenticationType } from '@forepath/identity/backend'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; @@ -110,11 +111,12 @@ export class ClientAgentOpencodeConfigProxyService { agentId: string, effective: Record, secrets?: Record, + platformPolicy?: EffectivePlatformSecurityPolicy, ): Promise<{ ok: boolean; defer?: boolean; error?: string }> { const result = await this.makeRequest<{ ok: boolean; defer?: boolean; error?: string }>(clientId, agentId, { method: 'POST', url: '/sync', - data: { config: effective, secrets: secrets ?? {} }, + data: { config: effective, secrets: secrets ?? {}, ...(platformPolicy ? { platformPolicy } : {}) }, }); if (!result || typeof result.ok !== 'boolean') { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts index fe67f6b55..048357ae5 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { + AgentEgressDetailsDto, + AgentEgressEventsPageDto, AgentModelsResponseDto, AgentResponseDto, ConfigResponseDto, @@ -102,12 +104,19 @@ export class ClientAgentProxyService { * @throws NotFoundException if client is not found * @throws BadRequestException if request fails */ - private async makeRequest(clientId: string, config: AxiosRequestConfig): Promise { + private async makeRequest( + clientId: string, + config: AxiosRequestConfig, + scope: 'agents' | 'api' = 'agents', + ): Promise { const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); await validateClientEndpointWithDnsOrThrow(clientEntity.endpoint); const authHeader = await this.getAuthHeader(clientId); - const baseUrl = this.buildAgentApiUrl(clientEntity.endpoint); + const baseUrl = + scope === 'agents' + ? this.buildAgentApiUrl(clientEntity.endpoint) + : `${clientEntity.endpoint.replace(/\/$/, '')}/api`; const tlsPolicy = getClientEndpointTlsPolicy(this.logger); try { @@ -118,7 +127,7 @@ export class ClientAgentProxyService { url: config.url ? `${baseUrl}${config.url}` : baseUrl, headers: buildClientProxyRequestHeaders(config.headers, authHeader), validateStatus: (status) => status < 500, // Don't throw on 4xx errors - timeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : 600000, // 10 minutes timeout for long-running processes + timeout: config.timeout ?? (process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : 600000), // 10 minutes timeout for long-running processes httpsAgent: baseUrl.startsWith('https://') ? new (require('https').Agent)({ rejectUnauthorized: tlsPolicy.rejectUnauthorized, @@ -184,6 +193,32 @@ export class ClientAgentProxyService { } } + /** + * Egress guard status, effective policy, message filters and recent decisions of one agent. + */ + async getClientAgentEgress(clientId: string, agentId: string): Promise { + return await this.makeRequest(clientId, { + method: 'GET', + url: `/${agentId}/egress`, + }); + } + + /** + * Page the agent manager's egress decision feed (monotonic `seq` cursor; all agents of the workspace). + */ + async listClientEgressEvents(clientId: string, afterSeq: string, limit: number): Promise { + return await this.makeRequest( + clientId, + { + method: 'GET', + url: '/egress/events', + params: { afterSeq, limit }, + timeout: 60_000, + }, + 'api', + ); + } + /** * Get all agents for a specific client with pagination. * @param clientId - The UUID of the client diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.spec.ts new file mode 100644 index 000000000..164771329 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.spec.ts @@ -0,0 +1,162 @@ +import { NotFoundException } from '@nestjs/common'; + +import { EgressEventsIngestService } from './egress-events-ingest.service'; + +function event(seq: string, overrides: Record = {}) { + return { + seq, + agentId: 'agent-1', + kind: 'web', + decision: 'blocked', + host: 'example.org', + rule: 'deny_host', + matchedEntry: 'example.org', + matchedSource: 'global', + method: 'CONNECT', + httpStatus: null, + sampleUrl: null, + count: 2, + bytesIn: 0, + bytesOut: 0, + firstSeenAt: '2026-10-12T10:00:00.000Z', + lastSeenAt: '2026-10-12T10:00:05.000Z', + mode: 'enforce', + policyRevision: 'rev', + message: null, + ...overrides, + }; +} + +describe('EgressEventsIngestService', () => { + let cursors: Record; + let clients: Record; + let proxy: Record; + let statistics: Record; + let publisher: Record; + let syncTargets: Record; + let service: EgressEventsIngestService; + + beforeEach(() => { + cursors = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: unknown) => value), + find: jest.fn().mockResolvedValue([]), + }; + clients = { findAllIds: jest.fn().mockResolvedValue(['c1', 'c2', 'c3']) }; + proxy = { listClientEgressEvents: jest.fn() }; + statistics = { + recordEgressEvents: jest.fn(async (_clientId: string, events: Array<{ seq: string }>) => + events.map((entry) => ({ sourceSeq: entry.seq })), + ), + }; + publisher = { publishEgressDecision: jest.fn(), publishEgressGuardFailed: jest.fn() }; + syncTargets = { markAgentPending: jest.fn().mockResolvedValue(undefined) }; + service = new EgressEventsIngestService( + cursors as never, + clients as never, + proxy as never, + statistics as never, + publisher as never, + syncTargets as never, + ); + }); + + it('pages the manager feed from the stored cursor and advances it', async () => { + cursors.findOne.mockResolvedValue({ clientId: 'c1', lastSeq: '10' }); + proxy.listClientEgressEvents + .mockResolvedValueOnce({ + events: [event('11'), event('12', { decision: 'allowed' })], + nextSeq: '12', + hasMore: true, + }) + .mockResolvedValueOnce({ events: [], nextSeq: '12', hasMore: false }); + + await expect(service.ingestClient('c1')).resolves.toBe(2); + + expect(proxy.listClientEgressEvents).toHaveBeenNthCalledWith(1, 'c1', '10', 500); + expect(proxy.listClientEgressEvents).toHaveBeenNthCalledWith(2, 'c1', '12', 500); + expect(cursors.save).toHaveBeenCalledWith(expect.objectContaining({ lastSeq: '12', lastError: null })); + expect(publisher.publishEgressDecision).toHaveBeenCalledTimes(1); + expect(publisher.publishEgressDecision).toHaveBeenCalledWith( + 'egress.blocked', + 'c1', + expect.objectContaining({ host: 'example.org', count: 2, rule: 'deny_host' }), + ); + }); + + it('aggregates notifications per destination and caps them per run', async () => { + const many = Array.from({ length: 30 }, (_, index) => event(String(index + 1), { host: `h${index}.example` })); + + proxy.listClientEgressEvents.mockResolvedValue({ + events: [...many, event('99', { host: 'h0.example', count: 5 }), event('100', { decision: 'observed' })], + nextSeq: '100', + hasMore: false, + }); + + await service.ingestClient('c1'); + + const blocked = publisher.publishEgressDecision.mock.calls.filter((call) => call[0] === 'egress.blocked'); + + expect(blocked).toHaveLength(25); + expect(blocked[0][2]).toEqual(expect.objectContaining({ host: 'h0.example', count: 7 })); + expect(blocked[24][2]).toEqual(expect.objectContaining({ omittedDecisions: 5 })); + expect(publisher.publishEgressDecision).toHaveBeenCalledWith('egress.observed', 'c1', expect.any(Object)); + }); + + it('skips notifications for rows that were already ingested', async () => { + statistics.recordEgressEvents.mockResolvedValue([]); + proxy.listClientEgressEvents.mockResolvedValue({ events: [event('1')], nextSeq: '1', hasMore: false }); + + await service.ingestClient('c1'); + + expect(publisher.publishEgressDecision).not.toHaveBeenCalled(); + }); + + it('notifies guard failures and re-syncs recovered guards', async () => { + proxy.listClientEgressEvents.mockResolvedValue({ + events: [ + event('1', { + kind: 'guard', + decision: 'error', + host: null, + rule: 'guard', + message: 'guard_missing: recreated', + }), + event('2', { + kind: 'guard', + decision: 'error', + host: null, + rule: 'guard', + message: 'process_restarted: squid', + }), + ], + nextSeq: '2', + hasMore: false, + }); + + await service.ingestClient('c1'); + + expect(publisher.publishEgressGuardFailed).toHaveBeenCalledTimes(2); + expect(syncTargets.markAgentPending).toHaveBeenCalledTimes(1); + expect(syncTargets.markAgentPending).toHaveBeenCalledWith('c1', 'agent-1'); + }); + + it('records errors on the cursor (older managers without the feed)', async () => { + proxy.listClientEgressEvents.mockRejectedValue(new NotFoundException('Cannot GET')); + + await expect(service.ingestClient('c1')).resolves.toBe(0); + expect(cursors.save).toHaveBeenCalledWith(expect.objectContaining({ lastError: 'egress feed not available' })); + }); + + it('selects never-polled then least recently polled workspaces', async () => { + cursors.find.mockResolvedValue([ + { clientId: 'c1', updatedAt: new Date(3000) }, + { clientId: 'c2', updatedAt: new Date(1000) }, + ]); + + await expect(service.findClientIdsDue(2)).resolves.toEqual(['c3', 'c2']); + await expect(service.findClientIdsDue(5)).resolves.toEqual(['c1', 'c2', 'c3']); + await expect(service.findClientIdsDue(0)).resolves.toEqual([]); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts new file mode 100644 index 000000000..0ea63204a --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/egress-events-ingest.service.ts @@ -0,0 +1,194 @@ +import type { AgentEgressEventDto } from '@forepath/agenstra/backend/feature-agent-manager'; +import { NotFoundException, Injectable, Logger, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { EgressEventIngestCursorEntity } from '../entities/egress-event-ingest-cursor.entity'; +import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; +import { ClientsRepository } from '../repositories/clients.repository'; + +import { ClientAgentProxyService } from './client-agent-proxy.service'; +import { OpencodeConfigSyncTargetsService } from './opencode-config-sync-targets.service'; +import { StatisticsService } from './statistics.service'; + +const PAGE_SIZE = 500; +const MAX_PAGES_PER_RUN = 10; +/** Distinct blocked / observed decisions published per type and ingestion run (the rest is summarized). */ +const MAX_DECISION_NOTIFICATIONS_PER_RUN = 25; +const MAX_GUARD_NOTIFICATIONS_PER_RUN = 10; + +type IngestedEvent = AgentEgressEventDto; + +/** + * Pulls aggregated egress guard decisions from each workspace's agent manager + * (`GET /api/egress/events?afterSeq=`), stores them for /audit and publishes notifications. + */ +@Injectable() +export class EgressEventsIngestService { + private readonly logger = new Logger(EgressEventsIngestService.name); + + constructor( + @InjectRepository(EgressEventIngestCursorEntity) + private readonly cursors: Repository, + private readonly clientsRepository: ClientsRepository, + private readonly clientAgentProxy: ClientAgentProxyService, + private readonly statisticsService: StatisticsService, + @Optional() private readonly notificationPublisher?: AgenstraNotificationPublisher, + @Optional() private readonly configSyncTargets?: OpencodeConfigSyncTargetsService, + ) {} + + /** Workspaces to poll next: never-polled first, then least recently polled. */ + async findClientIdsDue(max: number): Promise { + if (max <= 0) { + return []; + } + + const clientIds = await this.clientsRepository.findAllIds(); + + if (clientIds.length <= max) { + return clientIds; + } + + const rows = await this.cursors.find({ select: ['clientId', 'updatedAt'] }); + const polledAt = new Map(rows.map((row) => [row.clientId, row.updatedAt?.getTime() ?? 0])); + + return [...clientIds].sort((left, right) => (polledAt.get(left) ?? -1) - (polledAt.get(right) ?? -1)).slice(0, max); + } + + /** Ingest every new event of one workspace (bounded per run). Returns the number of stored rows. */ + async ingestClient(clientId: string): Promise { + let cursor = await this.cursors.findOne({ where: { clientId } }); + + if (!cursor) { + cursor = this.cursors.create({ clientId, lastSeq: '0', lastError: null, lastIngestedAt: null }); + } + + let stored = 0; + const blocked: IngestedEvent[] = []; + const guardErrors: IngestedEvent[] = []; + + try { + for (let page = 0; page < MAX_PAGES_PER_RUN; page++) { + const result = await this.clientAgentProxy.listClientEgressEvents(clientId, String(cursor.lastSeq), PAGE_SIZE); + const events = Array.isArray(result?.events) ? result.events : []; + + if (events.length > 0) { + const inserted = await this.statisticsService.recordEgressEvents(clientId, events); + const insertedSeqs = new Set(inserted.map((row) => String(row.sourceSeq))); + + stored += inserted.length; + + for (const event of events) { + if (!insertedSeqs.has(String(event.seq))) { + continue; + } + + if (event.decision === 'blocked' || event.decision === 'observed') { + blocked.push(event); + } else if (event.kind === 'guard' && event.decision === 'error') { + guardErrors.push(event); + } + } + } + + if (typeof result?.nextSeq === 'string' && /^\d+$/.test(result.nextSeq)) { + cursor.lastSeq = result.nextSeq; + } + + if (!result?.hasMore) { + break; + } + } + + cursor.lastError = null; + cursor.lastIngestedAt = new Date(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + + // Managers without the egress feed (older releases) answer 404: nothing to ingest. + cursor.lastError = error instanceof NotFoundException ? 'egress feed not available' : message.slice(0, 1000); + this.logger.debug(`Egress event ingestion for client ${clientId} failed: ${message}`); + } + + await this.cursors.save(cursor); + this.publishDecisionNotifications(clientId, blocked); + await this.handleGuardErrors(clientId, guardErrors); + + return stored; + } + + private publishDecisionNotifications(clientId: string, events: readonly IngestedEvent[]): void { + if (!this.notificationPublisher || events.length === 0) { + return; + } + + for (const decision of ['blocked', 'observed'] as const) { + const grouped = new Map(); + + for (const event of events) { + if (event.decision !== decision) { + continue; + } + + const key = [event.agentId, event.kind, event.host ?? '', event.rule ?? ''].join('|'); + const existing = grouped.get(key); + + if (existing) { + existing.total += event.count; + existing.lastSeenAt = event.lastSeenAt > existing.lastSeenAt ? event.lastSeenAt : existing.lastSeenAt; + } else { + grouped.set(key, { ...event, total: event.count }); + } + } + + const ordered = [...grouped.values()].sort((left, right) => right.total - left.total); + const published = ordered.slice(0, MAX_DECISION_NOTIFICATIONS_PER_RUN); + const omitted = ordered.length - published.length; + + published.forEach((event, index) => { + this.notificationPublisher?.publishEgressDecision( + decision === 'blocked' ? 'egress.blocked' : 'egress.observed', + clientId, + { + agentId: event.agentId, + kind: event.kind, + decision: event.decision, + host: event.host ?? null, + rule: event.rule ?? null, + matchedEntry: event.matchedEntry ?? null, + matchedSource: event.matchedSource ?? null, + method: event.method ?? null, + count: event.total, + firstSeenAt: new Date(event.firstSeenAt).toISOString(), + lastSeenAt: new Date(event.lastSeenAt).toISOString(), + ...(index === published.length - 1 && omitted > 0 ? { omittedDecisions: omitted } : {}), + }, + ); + }); + } + } + + private async handleGuardErrors(clientId: string, events: readonly IngestedEvent[]): Promise { + for (const event of events.slice(0, MAX_GUARD_NOTIFICATIONS_PER_RUN)) { + this.notificationPublisher?.publishEgressGuardFailed(clientId, { + agentId: event.agentId, + message: event.message ?? 'egress guard failure', + occurredAt: new Date(event.lastSeenAt).toISOString(), + }); + } + + // A recreated guard only received the stored policy: re-sync so the upstream proxy (layered network + // secrets) and the current effective policy are applied again. + const recovered = new Set( + events.filter((event) => (event.message ?? '').startsWith('guard_missing')).map((event) => event.agentId), + ); + + for (const agentId of recovered) { + try { + await this.configSyncTargets?.markAgentPending(clientId, agentId); + } catch (error: unknown) { + this.logger.debug(`Failed to mark agent ${agentId} for re-sync: ${(error as Error).message}`); + } + } + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.spec.ts deleted file mode 100644 index adaa9edff..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.spec.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from '../entities/agent-console-regex-filter-rule-sync-target.entity'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; - -import { AgentManagerFilterRulesClientService } from './agent-manager-filter-rules-client.service'; -import { FilterRulesSyncService } from './filter-rules-sync.service'; - -describe('FilterRulesSyncService', () => { - const rule = { - id: 'rule-1', - pattern: 'foo', - regexFlags: 'i', - direction: 'inbound', - filterType: 'filter', - replaceContent: 'bar', - priority: 1, - enabled: true, - } as unknown as AgentConsoleRegexFilterRuleEntity; - const createTarget = ( - overrides: Partial = {}, - ): AgentConsoleRegexFilterRuleSyncTargetEntity & { rule: AgentConsoleRegexFilterRuleEntity } => - ({ - id: 'target-1', - clientId: 'client-1', - managerRuleId: null, - desiredOnManager: true, - syncStatus: 'pending', - lastError: null, - rule, - ...overrides, - }) as AgentConsoleRegexFilterRuleSyncTargetEntity & { rule: AgentConsoleRegexFilterRuleEntity }; - const createService = ( - deps: { - targetsRepo?: Record; - agentManagerClient?: Record; - } = {}, - ) => { - const targetsRepo = { - createQueryBuilder: jest.fn(), - save: jest.fn().mockImplementation(async (target) => target), - ...deps.targetsRepo, - }; - const agentManagerClient = { - createRule: jest.fn(), - updateRule: jest.fn(), - deleteRule: jest.fn(), - ...deps.agentManagerClient, - }; - - return Test.createTestingModule({ - providers: [ - FilterRulesSyncService, - { provide: getRepositoryToken(AgentConsoleRegexFilterRuleSyncTargetEntity), useValue: targetsRepo }, - { provide: getRepositoryToken(AgentConsoleRegexFilterRuleEntity), useValue: {} }, - { provide: AgentManagerFilterRulesClientService, useValue: agentManagerClient }, - ], - }).compile(); - }; - - it('findPendingTargetIds returns ids from pending query', async () => { - const qb = { - innerJoin: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([{ id: 'target-1' }, { id: 'target-2' }]), - }; - const targetsRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; - const moduleRef = await createService({ targetsRepo }); - const svc = moduleRef.get(FilterRulesSyncService); - - await expect(svc.findPendingTargetIds(5)).resolves.toEqual(['target-1', 'target-2']); - expect(qb.take).toHaveBeenCalledWith(5); - }); - - it('processTargetById returns early when target is missing', async () => { - const qb = { - innerJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(null), - }; - const targetsRepo = { - createQueryBuilder: jest.fn().mockReturnValue(qb), - save: jest.fn(), - }; - const agentManagerClient = { createRule: jest.fn() }; - const moduleRef = await createService({ targetsRepo, agentManagerClient }); - const svc = moduleRef.get(FilterRulesSyncService); - - await svc.processTargetById('missing'); - - expect(agentManagerClient.createRule).not.toHaveBeenCalled(); - expect(targetsRepo.save).not.toHaveBeenCalled(); - }); - - it('processTargetById creates remote rule when desired and no manager id', async () => { - const target = createTarget(); - const qb = { - innerJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(target), - }; - const targetsRepo = { - createQueryBuilder: jest.fn().mockReturnValue(qb), - save: jest.fn().mockImplementation(async (row) => row), - }; - const agentManagerClient = { - createRule: jest.fn().mockResolvedValue({ id: 'remote-1' }), - }; - const moduleRef = await createService({ targetsRepo, agentManagerClient }); - const svc = moduleRef.get(FilterRulesSyncService); - - await svc.processTargetById(target.id); - - expect(agentManagerClient.createRule).toHaveBeenCalledWith('client-1', { - pattern: 'foo', - regexFlags: 'i', - direction: 'inbound', - filterType: 'filter', - replaceContent: 'bar', - priority: 1, - }); - expect(target.managerRuleId).toBe('remote-1'); - expect(target.syncStatus).toBe('synced'); - expect(target.lastError).toBeNull(); - expect(target.lastSyncedAt).toBeInstanceOf(Date); - }); - - it('processTargetById updates remote rule when manager id exists', async () => { - const target = createTarget({ managerRuleId: 'remote-1' }); - const qb = { - innerJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(target), - }; - const targetsRepo = { - createQueryBuilder: jest.fn().mockReturnValue(qb), - save: jest.fn().mockImplementation(async (row) => row), - }; - const agentManagerClient = { - updateRule: jest.fn().mockResolvedValue(undefined), - }; - const moduleRef = await createService({ targetsRepo, agentManagerClient }); - const svc = moduleRef.get(FilterRulesSyncService); - - await svc.processTargetById(target.id); - - expect(agentManagerClient.updateRule).toHaveBeenCalledWith('client-1', 'remote-1', { - pattern: 'foo', - regexFlags: 'i', - direction: 'inbound', - filterType: 'filter', - replaceContent: 'bar', - priority: 1, - }); - expect(target.syncStatus).toBe('synced'); - }); - - it('processTargetById deletes remote rule when no longer desired', async () => { - const target = createTarget({ desiredOnManager: false, managerRuleId: 'remote-1' }); - const qb = { - innerJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(target), - }; - const targetsRepo = { - createQueryBuilder: jest.fn().mockReturnValue(qb), - save: jest.fn().mockImplementation(async (row) => row), - }; - const agentManagerClient = { - deleteRule: jest.fn().mockResolvedValue(undefined), - }; - const moduleRef = await createService({ targetsRepo, agentManagerClient }); - const svc = moduleRef.get(FilterRulesSyncService); - - await svc.processTargetById(target.id); - - expect(agentManagerClient.deleteRule).toHaveBeenCalledWith('client-1', 'remote-1'); - expect(target.managerRuleId).toBeNull(); - expect(target.syncStatus).toBe('synced'); - }); - - it('processTargetById marks target failed when sync throws', async () => { - const target = createTarget(); - const qb = { - innerJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(target), - }; - const targetsRepo = { - createQueryBuilder: jest.fn().mockReturnValue(qb), - save: jest.fn().mockImplementation(async (row) => row), - }; - const agentManagerClient = { - createRule: jest.fn().mockRejectedValue(new Error('remote down')), - }; - const moduleRef = await createService({ targetsRepo, agentManagerClient }); - const svc = moduleRef.get(FilterRulesSyncService); - const warn = jest.spyOn(svc['logger'], 'warn').mockImplementation(); - - await svc.processTargetById(target.id); - - expect(target.syncStatus).toBe('failed'); - expect(target.lastError).toBe('remote down'); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); - }); - - it('processBatch returns 0 when query empty', async () => { - const qb = { - innerJoin: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([]), - }; - const targetsRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; - const moduleRef = await createService({ targetsRepo }); - const svc = moduleRef.get(FilterRulesSyncService); - - expect(await svc.processBatch(5)).toBe(0); - }); - - it('processBatch processes each pending target id', async () => { - const pendingQb = { - innerJoin: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([{ id: 'target-1' }]), - }; - const target = createTarget(); - const targetQb = { - innerJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(target), - }; - const targetsRepo = { - createQueryBuilder: jest.fn().mockReturnValueOnce(pendingQb).mockReturnValueOnce(targetQb), - save: jest.fn().mockImplementation(async (row) => row), - }; - const agentManagerClient = { - createRule: jest.fn().mockResolvedValue({ id: 'remote-1' }), - }; - const moduleRef = await createService({ targetsRepo, agentManagerClient }); - const svc = moduleRef.get(FilterRulesSyncService); - - await expect(svc.processBatch(1)).resolves.toBe(1); - expect(agentManagerClient.createRule).toHaveBeenCalledTimes(1); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts deleted file mode 100644 index 507beefd5..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules-sync.service.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { - CreateRegexFilterRuleDto, - UpdateRegexFilterRuleDto, -} from '@forepath/agenstra/backend/feature-agent-manager'; -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, Repository } from 'typeorm'; - -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from '../entities/agent-console-regex-filter-rule-sync-target.entity'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; - -import { AgentManagerFilterRulesClientService } from './agent-manager-filter-rules-client.service'; - -/** - * Pushes filter rule changes to each workspace agent-manager (sync targets). - */ -@Injectable() -export class FilterRulesSyncService { - private readonly logger = new Logger(FilterRulesSyncService.name); - - constructor( - @InjectRepository(AgentConsoleRegexFilterRuleSyncTargetEntity) - private readonly targetsRepo: Repository, - @InjectRepository(AgentConsoleRegexFilterRuleEntity) - private readonly rulesRepo: Repository, - private readonly agentManagerClient: AgentManagerFilterRulesClientService, - ) {} - - /** - * @returns number of targets processed - */ - async findPendingTargetIds(max: number): Promise { - const rows = await this.targetsRepo - .createQueryBuilder('t') - .innerJoin('t.rule', 'rule') - .where('t.sync_status IN (:...st)', { st: ['pending', 'failed'] }) - .andWhere( - new Brackets((qb) => { - qb.where('(t.desired_on_manager = true AND rule.enabled = true)').orWhere( - '(t.desired_on_manager = false AND t.manager_rule_id IS NOT NULL)', - ); - }), - ) - .orderBy('t.updatedAt', 'ASC') - .take(max) - .select(['t.id', 't.updatedAt']) - .getMany(); - - return rows.map((row) => row.id); - } - - async processTargetById(targetId: string): Promise { - const target = await this.targetsRepo - .createQueryBuilder('t') - .innerJoinAndSelect('t.rule', 'rule') - .where('t.id = :targetId', { targetId }) - .getOne(); - - if (!target) { - return; - } - - await this.processOne(target); - } - - async processBatch(max: number): Promise { - const ids = await this.findPendingTargetIds(max); - - for (const targetId of ids) { - await this.processTargetById(targetId); - } - - return ids.length; - } - - private toCreateDto(rule: AgentConsoleRegexFilterRuleEntity): CreateRegexFilterRuleDto { - return { - pattern: rule.pattern, - regexFlags: rule.regexFlags, - direction: rule.direction, - filterType: rule.filterType, - replaceContent: rule.filterType === 'filter' ? (rule.replaceContent ?? undefined) : undefined, - priority: rule.priority, - }; - } - - private toUpdateDto(rule: AgentConsoleRegexFilterRuleEntity): UpdateRegexFilterRuleDto { - return { - pattern: rule.pattern, - regexFlags: rule.regexFlags, - direction: rule.direction, - filterType: rule.filterType, - replaceContent: rule.filterType === 'filter' ? (rule.replaceContent ?? undefined) : null, - priority: rule.priority, - }; - } - - private async processOne( - target: AgentConsoleRegexFilterRuleSyncTargetEntity & { rule: AgentConsoleRegexFilterRuleEntity }, - ): Promise { - const rule = target.rule; - - try { - if (!target.desiredOnManager) { - if (target.managerRuleId) { - await this.agentManagerClient.deleteRule(target.clientId, target.managerRuleId); - } - - target.managerRuleId = null; - target.syncStatus = 'synced'; - target.lastError = null; - target.lastSyncedAt = new Date(); - await this.targetsRepo.save(target); - - return; - } - - if (!target.managerRuleId) { - const created = await this.agentManagerClient.createRule(target.clientId, this.toCreateDto(rule)); - - target.managerRuleId = created.id; - } else { - await this.agentManagerClient.updateRule(target.clientId, target.managerRuleId, this.toUpdateDto(rule)); - } - - target.syncStatus = 'synced'; - target.lastError = null; - target.lastSyncedAt = new Date(); - await this.targetsRepo.save(target); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - - this.logger.warn(`Sync failed for target ${target.id} client ${target.clientId}: ${msg}`); - target.syncStatus = 'failed'; - target.lastError = msg; - await this.targetsRepo.save(target); - } - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.spec.ts deleted file mode 100644 index 3b2ad8d4e..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { AgentConsoleRegexFilterRuleClientEntity } from '../entities/agent-console-regex-filter-rule-client.entity'; -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from '../entities/agent-console-regex-filter-rule-sync-target.entity'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; -import { ClientsRepository } from '../repositories/clients.repository'; - -import { AgentManagerFilterRulesClientService } from './agent-manager-filter-rules-client.service'; -import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; -import { AgenstraSearchIndexService } from '../search/agenstra-search-index.service'; - -import { FilterRulesService } from './filter-rules.service'; - -describe('FilterRulesService', () => { - const rulesRepo = { - find: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - remove: jest.fn(), - createQueryBuilder: jest.fn(), - }; - const linksRepo = { save: jest.fn(), delete: jest.fn() }; - const targetsRepo = { - find: jest.fn().mockResolvedValue([]), - save: jest.fn(), - createQueryBuilder: jest.fn().mockReturnValue({ - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue([]), - }), - }; - const clientsRepository = { findAllIds: jest.fn().mockResolvedValue(['c1']), findByIdOrThrow: jest.fn() }; - const amClient = { deleteRule: jest.fn().mockResolvedValue(undefined) }; - const searchIndex = { - upsertSafe: jest.fn().mockResolvedValue(undefined), - deleteSafe: jest.fn().mockResolvedValue(undefined), - isEnabled: jest.fn().mockReturnValue(false), - searchIds: jest.fn(), - }; - let service: FilterRulesService; - - beforeEach(async () => { - jest.clearAllMocks(); - searchIndex.isEnabled.mockReturnValue(false); - const m = await Test.createTestingModule({ - providers: [ - FilterRulesService, - { provide: getRepositoryToken(AgentConsoleRegexFilterRuleEntity), useValue: rulesRepo }, - { provide: getRepositoryToken(AgentConsoleRegexFilterRuleClientEntity), useValue: linksRepo }, - { provide: getRepositoryToken(AgentConsoleRegexFilterRuleSyncTargetEntity), useValue: targetsRepo }, - { provide: ClientsRepository, useValue: clientsRepository }, - { provide: AgentManagerFilterRulesClientService, useValue: amClient }, - { - provide: AgenstraNotificationPublisher, - useValue: { - publishFilterRule: jest.fn(), - publishClient: jest.fn(), - publishTicket: jest.fn(), - publish: jest.fn(), - }, - }, - { - provide: AgenstraSearchIndexService, - useValue: searchIndex, - }, - ], - }).compile(); - - service = m.get(FilterRulesService); - }); - - it('create rejects non-global without workspaceIds', async () => { - await expect( - service.create({ - pattern: 'a', - direction: 'incoming', - filterType: 'none', - isGlobal: false, - } as never), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('findAll requests pagination from the repository', async () => { - rulesRepo.find.mockResolvedValue([]); - await service.findAll(25, 5); - expect(rulesRepo.find).toHaveBeenCalledWith( - expect.objectContaining({ - take: 25, - skip: 5, - }), - ); - }); - - it('findAll uses searchIds when OpenSearch is enabled', async () => { - searchIndex.isEnabled.mockReturnValue(true); - searchIndex.searchIds - .mockResolvedValueOnce({ ids: ['rule-1'], total: 1 }) - .mockResolvedValueOnce({ ids: [], total: 0 }); - rulesRepo.find.mockResolvedValue([ - { - id: 'rule-1', - pattern: 'foo', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - replaceContent: null, - priority: 0, - enabled: true, - isGlobal: true, - clientLinks: [], - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }, - ]); - targetsRepo.find.mockResolvedValue([]); - - await service.findAll(10, 0, 'foo'); - - expect(searchIndex.searchIds).toHaveBeenCalledWith( - expect.objectContaining({ - entityType: 'filter-rules', - query: 'foo', - clientIds: ['c1'], - }), - ); - expect(searchIndex.searchIds).toHaveBeenCalledWith( - expect.objectContaining({ - entityType: 'filter-rules', - query: 'foo', - instanceScoped: true, - }), - ); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts deleted file mode 100644 index 98bf03380..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/filter-rules.service.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { - assertReplaceContentForFilterType, - compileRegexOrThrow, - normalizeRegexFlags, -} from '@forepath/agenstra/backend/feature-agent-manager'; -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; - -import { CreateFilterRuleDto } from '../dto/filter-rules/create-filter-rule.dto'; -import { - FilterRuleResponseDto, - FilterRuleSyncSummaryDto, - FilterRuleWorkspaceSyncDto, -} from '../dto/filter-rules/filter-rule-response.dto'; -import { UpdateFilterRuleDto } from '../dto/filter-rules/update-filter-rule.dto'; -import { AgentConsoleRegexFilterRuleClientEntity } from '../entities/agent-console-regex-filter-rule-client.entity'; -import { AgentConsoleRegexFilterRuleSyncTargetEntity } from '../entities/agent-console-regex-filter-rule-sync-target.entity'; -import { AgentConsoleRegexFilterRuleEntity } from '../entities/agent-console-regex-filter-rule.entity'; -import { ClientsRepository } from '../repositories/clients.repository'; -import { mapFilterRuleToSearchDocument } from '../search/agenstra-search-document.mapper'; -import { AgenstraSearchIndexService } from '../search/agenstra-search-index.service'; -import { - applyAgenstraSearchIlike, - sanitizeListSearch, - tryAgenstraSearchIds, -} from '../search/agenstra-search-list.util'; - -import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; -import { AgentManagerFilterRulesClientService } from './agent-manager-filter-rules-client.service'; - -@Injectable() -export class FilterRulesService { - private readonly logger = new Logger(FilterRulesService.name); - - constructor( - @InjectRepository(AgentConsoleRegexFilterRuleEntity) - private readonly rulesRepo: Repository, - @InjectRepository(AgentConsoleRegexFilterRuleClientEntity) - private readonly linksRepo: Repository, - @InjectRepository(AgentConsoleRegexFilterRuleSyncTargetEntity) - private readonly targetsRepo: Repository, - private readonly clientsRepository: ClientsRepository, - private readonly agentManagerFilterRulesClient: AgentManagerFilterRulesClientService, - private readonly notificationPublisher: AgenstraNotificationPublisher, - private readonly searchIndex: AgenstraSearchIndexService, - ) {} - - async findAll(limit = 10, offset = 0, search?: string): Promise { - const sanitized = sanitizeListSearch(search); - let rules: AgentConsoleRegexFilterRuleEntity[]; - - if (sanitized) { - const allClientIds = await this.clientsRepository.findAllIds(); - const [workspaceLookup, globalLookup] = await Promise.all([ - tryAgenstraSearchIds( - this.searchIndex, - { - entityType: 'filter-rules', - query: sanitized, - clientIds: allClientIds, - limit: 10_000, - offset: 0, - }, - this.logger, - ), - tryAgenstraSearchIds( - this.searchIndex, - { - entityType: 'filter-rules', - query: sanitized, - instanceScoped: true, - limit: 10_000, - offset: 0, - }, - this.logger, - ), - ]); - - if (workspaceLookup !== null || globalLookup !== null) { - const mergedIds = [...new Set([...(workspaceLookup?.ids ?? []), ...(globalLookup?.ids ?? [])])]; - const paginatedIds = mergedIds.slice(offset, offset + limit); - - if (paginatedIds.length === 0) { - return []; - } - - const found = await this.rulesRepo.find({ - where: { id: In(paginatedIds) }, - relations: { clientLinks: true }, - }); - const byId = new Map(found.map((rule) => [rule.id, rule])); - - rules = paginatedIds - .map((id) => byId.get(id)) - .filter((rule): rule is AgentConsoleRegexFilterRuleEntity => rule != null); - } else { - rules = await this.loadFilterRulesWithSqlSearch(limit, offset, sanitized); - } - } else { - rules = await this.rulesRepo.find({ - order: { priority: 'ASC', createdAt: 'ASC' }, - relations: { clientLinks: true }, - take: limit, - skip: offset, - }); - } - - const out: FilterRuleResponseDto[] = []; - - for (const r of rules) { - out.push(await this.toResponse(r)); - } - - return out; - } - - private async loadFilterRulesWithSqlSearch( - limit: number, - offset: number, - search: string, - ): Promise { - const qb = this.rulesRepo - .createQueryBuilder('r') - .leftJoinAndSelect('r.clientLinks', 'clientLinks') - .orderBy('r.priority', 'ASC') - .addOrderBy('r.created_at', 'ASC'); - - applyAgenstraSearchIlike(qb, 'filter-rules', 'r', search); - qb.take(limit).skip(offset); - - return await qb.getMany(); - } - - /** Counts for OTEL gauges: rules by enabled flag and sync targets by status. */ - async countForMetrics(): Promise<{ - rulesEnabled: number; - rulesDisabled: number; - syncPending: number; - syncSynced: number; - syncFailed: number; - }> { - const [rulesEnabled, rulesDisabled, syncPending, syncSynced, syncFailed] = await Promise.all([ - this.rulesRepo.count({ where: { enabled: true } }), - this.rulesRepo.count({ where: { enabled: false } }), - this.targetsRepo.count({ where: { syncStatus: 'pending' } }), - this.targetsRepo.count({ where: { syncStatus: 'synced' } }), - this.targetsRepo.count({ where: { syncStatus: 'failed' } }), - ]); - - return { rulesEnabled, rulesDisabled, syncPending, syncSynced, syncFailed }; - } - - async findOne(id: string): Promise { - const rule = await this.rulesRepo.findOne({ where: { id }, relations: { clientLinks: true } }); - - if (!rule) { - throw new NotFoundException(`Filter rule ${id} not found`); - } - - return await this.toResponse(rule); - } - - async create(dto: CreateFilterRuleDto): Promise { - this.validateWorkspaceIds(dto); - const flags = normalizeRegexFlags(dto.regexFlags); - - compileRegexOrThrow(dto.pattern, flags); - assertReplaceContentForFilterType(dto.filterType, dto.replaceContent); - - const rule = this.rulesRepo.create({ - pattern: dto.pattern, - regexFlags: flags, - direction: dto.direction, - filterType: dto.filterType, - replaceContent: dto.filterType === 'filter' ? dto.replaceContent : null, - priority: dto.priority ?? 0, - enabled: dto.enabled ?? true, - isGlobal: dto.isGlobal, - }); - const saved = await this.rulesRepo.save(rule); - - if (!dto.isGlobal) { - for (const clientId of dto.workspaceIds!) { - await this.clientsRepository.findByIdOrThrow(clientId); - await this.linksRepo.save(this.linksRepo.create({ ruleId: saved.id, clientId })); - } - } - - await this.reconcileSyncTargets(saved.id, true); - - const response = await this.findOne(saved.id); - - this.notificationPublisher.publishFilterRule('filter_rule.created', response); - void this.indexFilterRule(saved.id); - - return response; - } - - async update(id: string, dto: UpdateFilterRuleDto): Promise { - const rule = await this.rulesRepo.findOne({ where: { id }, relations: { clientLinks: true } }); - - if (!rule) { - throw new NotFoundException(`Filter rule ${id} not found`); - } - - if (dto.pattern !== undefined) { - rule.pattern = dto.pattern; - } - - if (dto.regexFlags !== undefined) { - rule.regexFlags = normalizeRegexFlags(dto.regexFlags); - } - - if (dto.direction !== undefined) { - rule.direction = dto.direction; - } - - if (dto.filterType !== undefined) { - rule.filterType = dto.filterType; - } - - if (dto.replaceContent !== undefined) { - rule.replaceContent = dto.replaceContent; - } - - if (dto.priority !== undefined) { - rule.priority = dto.priority; - } - - if (dto.enabled !== undefined) { - rule.enabled = dto.enabled; - } - - if (dto.isGlobal !== undefined) { - rule.isGlobal = dto.isGlobal; - } - - if (rule.filterType !== 'filter') { - rule.replaceContent = null; - } - - assertReplaceContentForFilterType(rule.filterType, rule.replaceContent); - compileRegexOrThrow(rule.pattern, rule.regexFlags); - - await this.rulesRepo.save(rule); - - if (dto.isGlobal === true) { - await this.linksRepo.delete({ ruleId: id }); - } else if (dto.isGlobal === false) { - if (!dto.workspaceIds?.length) { - throw new BadRequestException('workspaceIds required when isGlobal is false'); - } - - await this.linksRepo.delete({ ruleId: id }); - - for (const clientId of dto.workspaceIds) { - await this.clientsRepository.findByIdOrThrow(clientId); - await this.linksRepo.save(this.linksRepo.create({ ruleId: id, clientId })); - } - } else if (dto.workspaceIds !== undefined && rule.isGlobal) { - throw new BadRequestException('Set isGlobal to false before assigning workspaceIds'); - } else if (dto.workspaceIds !== undefined && !rule.isGlobal) { - await this.linksRepo.delete({ ruleId: id }); - - for (const clientId of dto.workspaceIds) { - await this.clientsRepository.findByIdOrThrow(clientId); - await this.linksRepo.save(this.linksRepo.create({ ruleId: id, clientId })); - } - } - - await this.reconcileSyncTargets(id, true); - - const response = await this.findOne(id); - - this.notificationPublisher.publishFilterRule('filter_rule.updated', response); - void this.indexFilterRule(id); - - return response; - } - - async delete(id: string): Promise { - const rule = await this.rulesRepo.findOne({ where: { id } }); - - if (!rule) { - throw new NotFoundException(`Filter rule ${id} not found`); - } - - const response = await this.findOne(id); - - const targets = await this.targetsRepo.find({ where: { ruleId: id } }); - - for (const t of targets) { - if (t.managerRuleId) { - try { - await this.agentManagerFilterRulesClient.deleteRule(t.clientId, t.managerRuleId); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - - this.logger.warn(`Could not delete remote filter ${t.managerRuleId} for client ${t.clientId}: ${msg}`); - } - } - } - - await this.rulesRepo.remove(rule); - this.notificationPublisher.publishFilterRule('filter_rule.deleted', response); - void this.searchIndex.deleteSafe('filter-rules', id); - } - - private async indexFilterRule(ruleId: string): Promise { - const rule = await this.rulesRepo.findOne({ - where: { id: ruleId }, - relations: { clientLinks: true }, - }); - - if (!rule) { - return; - } - - await this.searchIndex.upsertSafe( - 'filter-rules', - mapFilterRuleToSearchDocument( - rule, - (rule.clientLinks ?? []).map((link) => link.clientId), - ), - ); - } - - private validateWorkspaceIds(dto: CreateFilterRuleDto): void { - if (!dto.isGlobal && (!dto.workspaceIds || dto.workspaceIds.length === 0)) { - throw new BadRequestException('workspaceIds required when isGlobal is false'); - } - } - - async reconcileSyncTargets(ruleId: string, bump: boolean): Promise { - const rule = await this.rulesRepo.findOne({ - where: { id: ruleId }, - relations: { clientLinks: true }, - }); - - if (!rule) { - return; - } - - const scopeIds = new Set( - rule.isGlobal ? await this.clientsRepository.findAllIds() : (rule.clientLinks ?? []).map((l) => l.clientId), - ); - const existing = await this.targetsRepo.find({ where: { ruleId } }); - const byClient = new Map(existing.map((t) => [t.clientId, t])); - - for (const cid of scopeIds) { - const want = rule.enabled; - const row = byClient.get(cid); - - if (row) { - const desiredChanged = row.desiredOnManager !== want; - - row.desiredOnManager = want; - - if (bump || desiredChanged) { - row.syncStatus = 'pending'; - } - - await this.targetsRepo.save(row); - byClient.delete(cid); - } else { - await this.targetsRepo.save( - this.targetsRepo.create({ - ruleId, - clientId: cid, - desiredOnManager: want, - syncStatus: want ? 'pending' : 'synced', - }), - ); - } - } - - for (const row of byClient.values()) { - if (!row.desiredOnManager && !row.managerRuleId) { - row.syncStatus = 'synced'; - await this.targetsRepo.save(row); - } else { - row.desiredOnManager = false; - row.syncStatus = 'pending'; - await this.targetsRepo.save(row); - } - } - } - - private async loadSyncState(ruleId: string): Promise<{ - sync: FilterRuleSyncSummaryDto; - workspaceSync: FilterRuleWorkspaceSyncDto[]; - }> { - const targets = await this.targetsRepo.find({ - where: { ruleId }, - order: { clientId: 'ASC' }, - }); - const sync: FilterRuleSyncSummaryDto = { pending: 0, synced: 0, failed: 0 }; - const workspaceSync: FilterRuleWorkspaceSyncDto[] = []; - - for (const t of targets) { - if (t.syncStatus === 'pending') { - sync.pending += 1; - } else if (t.syncStatus === 'synced') { - sync.synced += 1; - } else if (t.syncStatus === 'failed') { - sync.failed += 1; - } - - workspaceSync.push({ - clientId: t.clientId, - syncStatus: t.syncStatus, - lastError: t.lastError ?? undefined, - }); - } - - return { sync, workspaceSync }; - } - - /** Ensures new clients get sync rows for global rules (no bump). */ - async reconcileAllGlobalRules(): Promise { - const globals = await this.rulesRepo.find({ where: { isGlobal: true }, select: ['id'] }); - - for (const g of globals) { - await this.reconcileSyncTargets(g.id, false); - } - } - - private async toResponse(rule: AgentConsoleRegexFilterRuleEntity): Promise { - const workspaceIds = rule.isGlobal ? [] : (rule.clientLinks ?? []).map((l) => l.clientId); - const { sync, workspaceSync } = await this.loadSyncState(rule.id); - - return { - id: rule.id, - pattern: rule.pattern, - regexFlags: rule.regexFlags, - direction: rule.direction, - filterType: rule.filterType, - replaceContent: rule.replaceContent, - priority: rule.priority, - enabled: rule.enabled, - isGlobal: rule.isGlobal, - workspaceIds, - sync, - workspaceSync, - createdAt: rule.createdAt.toISOString(), - updatedAt: rule.updatedAt.toISOString(), - }; - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts index 1d177bf18..48990f6b7 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts @@ -28,3 +28,103 @@ describe('OpencodeConfigSyncTargetsService.hashRevision', () => { expect(a).toBe(b); }); }); + +describe('OpencodeConfigSyncTargetsService platform policy', () => { + const policy = (mode: string, deny: string[] = []) => + ({ + egress: { + mode, + allowHosts: [], + denyHosts: deny.map((value) => ({ value, source: 'global' })), + denyUrlPatterns: [], + networkAllow: [], + blockPrivateNetworks: true, + blockIpLiterals: false, + logAllowed: true, + scalarSources: { mode: 'global', blockPrivateNetworks: null, blockIpLiterals: null, logAllowed: null }, + }, + messageFilters: [], + }) as never; + + it('includes the platform policy in the revision hash', () => { + const config = { model: 'x/y' }; + + expect(OpencodeConfigSyncTargetsService.hashRevision(config, {}, policy('off'))).not.toBe( + OpencodeConfigSyncTargetsService.hashRevision(config, {}, policy('enforce')), + ); + expect(OpencodeConfigSyncTargetsService.hashEgressRevision(policy('enforce', ['a.example']))).toBe( + OpencodeConfigSyncTargetsService.hashEgressRevision(policy('enforce', ['a.example'])), + ); + expect(OpencodeConfigSyncTargetsService.hashEgressRevision(undefined)).toBeNull(); + }); + + function buildService(syncResult: Record, target: Record) { + const saved: Array> = []; + const targetsRepo = { + findOne: jest.fn().mockResolvedValue(target), + save: jest.fn(async (value: Record) => { + saved.push({ ...value }); + + return value; + }), + }; + const effectiveConfigSync = { syncAgent: jest.fn().mockResolvedValue(syncResult) }; + const publisher = { publishEgressPolicy: jest.fn() }; + const service = new OpencodeConfigSyncTargetsService( + targetsRepo as never, + effectiveConfigSync as never, + {} as never, + publisher as never, + ); + + return { service, publisher, saved }; + } + + it('publishes egress.policy_applied once per new egress revision', async () => { + const target = { id: 't1', clientId: 'c1', agentId: 'a1', syncStatus: 'pending', desiredRevision: 'r1' }; + const { service, publisher, saved } = buildService({ ok: true, platformPolicy: policy('enforce') }, target); + + await service.processTargetById('t1'); + + expect(publisher.publishEgressPolicy).toHaveBeenCalledWith( + 'egress.policy_applied', + 'c1', + expect.objectContaining({ agentId: 'a1', mode: 'enforce' }), + ); + expect(saved[0]['appliedEgressRevision']).toEqual(expect.any(String)); + + publisher.publishEgressPolicy.mockClear(); + Object.assign(target, { syncStatus: 'pending', appliedEgressRevision: saved[0]['appliedEgressRevision'] }); + await service.processTargetById('t1'); + expect(publisher.publishEgressPolicy).not.toHaveBeenCalled(); + }); + + it('does not notify the initial default (off) policy', async () => { + const { service, publisher } = buildService( + { ok: true, platformPolicy: policy('off') }, + { id: 't1', clientId: 'c1', agentId: 'a1', syncStatus: 'pending', desiredRevision: 'r1' }, + ); + + await service.processTargetById('t1'); + + expect(publisher.publishEgressPolicy).not.toHaveBeenCalled(); + }); + + it('publishes egress.policy_failed for new egress errors only', async () => { + const target = { id: 't1', clientId: 'c1', agentId: 'a1', syncStatus: 'pending', desiredRevision: 'r1' }; + const error = 'Egress policy not applied: squid configuration rejected'; + const { service, publisher } = buildService({ ok: false, error, platformPolicy: policy('enforce') }, target); + + await service.processTargetById('t1'); + expect(publisher.publishEgressPolicy).toHaveBeenCalledWith( + 'egress.policy_failed', + 'c1', + expect.objectContaining({ error }), + ); + + publisher.publishEgressPolicy.mockClear(); + Object.assign(target, { syncStatus: 'failed', lastError: error }); + await service.processTargetById('t1'); + expect(publisher.publishEgressPolicy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts index 8694134cb..b702d7f16 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts @@ -3,9 +3,11 @@ import { createHash } from 'crypto'; import { AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION, prepareConfigForSync, + stablePlatformPolicyJson, + type EffectivePlatformSecurityPolicy, type JsonObject, } from '@forepath/agenstra/shared/util-opencode-config'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -14,6 +16,8 @@ import { OpencodeConfigSyncTargetEntity, } from '../entities/opencode-config-sync-target.entity'; +import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; + import { ClientAgentProxyService } from './client-agent-proxy.service'; import { OpencodeEffectiveConfigSyncService } from './opencode-effective-config-sync.service'; @@ -26,7 +30,7 @@ export interface OpencodeConfigSyncSummaryDto { } /** - * Durable per-agent OpenCode config sync targets (filter-rules pattern). + * Durable per-agent OpenCode config sync targets. * Marks pending on config changes; coordinator/unit jobs retry pending+failed. */ @Injectable() @@ -38,14 +42,32 @@ export class OpencodeConfigSyncTargetsService { private readonly targetsRepo: Repository, private readonly effectiveConfigSync: OpencodeEffectiveConfigSyncService, private readonly clientAgentProxy: ClientAgentProxyService, + @Optional() + private readonly notificationPublisher?: AgenstraNotificationPublisher, ) {} + /** Hash of the effective egress policy alone (provenance excluded). */ + static hashEgressRevision(platformPolicy: EffectivePlatformSecurityPolicy | null | undefined): string | null { + if (!platformPolicy) { + return null; + } + + return createHash('sha256') + .update(stablePlatformPolicyJson({ ...platformPolicy.egress, scalarSources: undefined })) + .digest('hex'); + } + /** - * Hash desired worker wire + secrets. + * Hash desired worker wire + secrets + platform security policy. * Includes platform wire version and prepareConfigForSync output so inject/materialize - * deploys rematch even when stored overlays are unchanged. + * deploys rematch even when stored overlays are unchanged. The platform policy (egress guard, + * message filters) is stripped from the wire, so it is hashed separately. */ - static hashRevision(config: Record, secrets: Record): string { + static hashRevision( + config: Record, + secrets: Record, + platformPolicy?: EffectivePlatformSecurityPolicy | null, + ): string { const sortedSecrets: Record = {}; for (const key of Object.keys(secrets).sort()) { @@ -60,6 +82,7 @@ export class OpencodeConfigSyncTargetsService { platformWireVersion: AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION, config: prepared, secrets: sortedSecrets, + ...(platformPolicy ? { platformPolicy: stablePlatformPolicyJson(platformPolicy) } : {}), }), ) .digest('hex'); @@ -83,8 +106,11 @@ export class OpencodeConfigSyncTargetsService { /** Recompute desired revision and set pending for one agent. */ async markAgentPending(clientId: string, agentId: string): Promise { - const { effective, secrets } = await this.effectiveConfigSync.buildEffectivePayload(clientId, agentId); - const desiredRevision = OpencodeConfigSyncTargetsService.hashRevision(effective, secrets); + const { effective, secrets, platformPolicy } = await this.effectiveConfigSync.buildEffectivePayload( + clientId, + agentId, + ); + const desiredRevision = OpencodeConfigSyncTargetsService.hashRevision(effective, secrets, platformPolicy); let target = await this.targetsRepo.findOne({ where: { agentId } }); @@ -225,11 +251,11 @@ export class OpencodeConfigSyncTargetsService { } try { - const { effective, secrets } = await this.effectiveConfigSync.buildEffectivePayload( + const { effective, secrets, platformPolicy } = await this.effectiveConfigSync.buildEffectivePayload( target.clientId, target.agentId, ); - const desiredRevision = OpencodeConfigSyncTargetsService.hashRevision(effective, secrets); + const desiredRevision = OpencodeConfigSyncTargetsService.hashRevision(effective, secrets, platformPolicy); if (desiredRevision === target.appliedRevision && desiredRevision === target.desiredRevision) { continue; @@ -274,18 +300,21 @@ export class OpencodeConfigSyncTargetsService { try { const result = await this.effectiveConfigSync.syncAgent(target.clientId, target.agentId); + const previousError = target.lastError ?? null; if (result.ok) { target.syncStatus = 'synced'; target.appliedRevision = target.desiredRevision; target.lastError = null; target.lastSyncedAt = new Date(); + this.notifyEgressApplied(target, result.platformPolicy); } else { // No container / not healthy → keep pending for retry; hard apply errors → failed. const defer = result.defer === true; target.syncStatus = defer ? 'pending' : 'failed'; target.lastError = result.error ?? 'sync failed'; + this.notifyEgressFailed(target, result.platformPolicy, previousError); } await this.targetsRepo.save(target); @@ -298,4 +327,50 @@ export class OpencodeConfigSyncTargetsService { await this.targetsRepo.save(target); } } + + /** `egress.policy_applied` once per new effective egress policy (not on every unrelated config sync). */ + private notifyEgressApplied( + target: OpencodeConfigSyncTargetEntity, + platformPolicy: EffectivePlatformSecurityPolicy | undefined, + ): void { + const egressRevision = OpencodeConfigSyncTargetsService.hashEgressRevision(platformPolicy); + + if (!egressRevision || egressRevision === target.appliedEgressRevision) { + return; + } + + const firstApplyOfDefault = !target.appliedEgressRevision && platformPolicy?.egress.mode === 'off'; + + target.appliedEgressRevision = egressRevision; + + if (firstApplyOfDefault) { + return; + } + + this.notificationPublisher?.publishEgressPolicy('egress.policy_applied', target.clientId, { + agentId: target.agentId, + mode: platformPolicy?.egress.mode ?? 'off', + revision: egressRevision, + }); + } + + /** `egress.policy_failed` when the manager could not apply the egress policy (deduplicated per error). */ + private notifyEgressFailed( + target: OpencodeConfigSyncTargetEntity, + platformPolicy: EffectivePlatformSecurityPolicy | undefined, + previousError: string | null, + ): void { + const error = target.lastError ?? ''; + + if (!error.startsWith('Egress policy not applied') || error === previousError) { + return; + } + + this.notificationPublisher?.publishEgressPolicy('egress.policy_failed', target.clientId, { + agentId: target.agentId, + mode: platformPolicy?.egress.mode ?? 'off', + revision: OpencodeConfigSyncTargetsService.hashEgressRevision(platformPolicy), + error, + }); + } } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.spec.ts index d91f97435..6a86f296b 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.spec.ts @@ -298,4 +298,65 @@ describe('OpencodeConfigService locks', () => { }, }); }); + + it('putGlobal_persistsPlatformSecurityConfig_andReturnsEffectivePolicy', async () => { + const saved = await service.putGlobal({ + config: { + egress: { mode: 'enforce', deny_hosts: ['Bad.Example'] }, + message_filters: [{ name: 'Keys', pattern: 'BEGIN PRIVATE KEY', action: 'drop' }], + }, + locks: ['/tabs/egress'], + }); + + expect(globalRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ egress: { mode: 'enforce', deny_hosts: ['Bad.Example'] } }), + }), + ); + expect(saved.platformPolicy?.egress).toEqual( + expect.objectContaining({ mode: 'enforce', denyHosts: [{ value: 'bad.example', source: 'global' }] }), + ); + expect(saved.platformPolicy?.messageFilters).toEqual([expect.objectContaining({ name: 'Keys', source: 'global' })]); + }); + + it('putGlobal_rejectsInvalidPlatformSecurityConfig', async () => { + await expect(service.putGlobal({ config: { egress: { mode: 'strict' } } })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect( + service.putGlobal({ config: { message_filters: [{ name: 'x', pattern: '(a+)+', action: 'drop' }] } }), + ).rejects.toThrow(/nested quantifiers/); + }); + + it('putClient_respectsLockedEgressScalars_butAllowsAppendingLists', async () => { + globalRepo.find.mockResolvedValue([ + { + config: { egress: { mode: 'enforce', deny_hosts: ['bad.example'] } }, + overrides: {}, + locks: [], + secrets: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]); + + await expect(service.putClient('client-1', { config: { egress: { mode: 'off' } } })).rejects.toThrow(/locked/); + + const saved = await service.putClient('client-1', { config: { egress: { deny_hosts: ['more.example'] } } }); + + expect(saved.lockedPaths).toEqual(expect.arrayContaining(['/egress/mode'])); + expect(saved.platformPolicy?.egress.denyHosts).toEqual([ + { value: 'bad.example', source: 'global' }, + { value: 'more.example', source: 'workspace' }, + ]); + }); + + it('putClient_rejectsMessageFiltersUnderTabLock', async () => { + globalRepo.find.mockResolvedValue([ + { config: {}, overrides: {}, locks: ['/tabs/message_filters'], secrets: null, updatedAt: new Date() }, + ]); + + await expect( + service.putClient('client-1', { config: { message_filters: [{ name: 'x', pattern: 'x', action: 'flag' }] } }), + ).rejects.toThrow(/locked/); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.ts index 4be41b176..cd72e3216 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config.service.ts @@ -14,6 +14,8 @@ import { normalizeStoredLocks, OpencodeConfigValidationError, prepareConfigForSync, + resolvePlatformSecurityPolicy, + type EffectivePlatformSecurityPolicy, type HeredityParentLayer, type InheritedAdditiveEntry, type JsonObject, @@ -103,6 +105,7 @@ export class OpencodeConfigService { const response = this.toResponse(row?.config, row?.overrides, row?.locks, row?.secrets, row?.updatedAt); // Owning (global) layer still needs `effective` for draft-lock display fallbacks. response.effective = this.composeStoredLayer(row?.config, row?.overrides); + response.platformPolicy = this.resolvePlatformPolicy({ global: response.effective }); return response; } @@ -146,6 +149,7 @@ export class OpencodeConfigService { const response = this.toResponse(saved.config, saved.overrides, saved.locks, saved.secrets, saved.updatedAt); response.effective = this.composeStoredLayer(saved.config, saved.overrides); + response.platformPolicy = this.resolvePlatformPolicy({ global: response.effective }); return response; } @@ -161,6 +165,7 @@ export class OpencodeConfigService { response.effective = this.mergeEffective(null, overlay, globalLayer.overlay); response.lockedPaths = heredity.lockedPaths; response.inheritedAdditive = heredity.inheritedAdditive; + response.platformPolicy = this.resolvePlatformPolicy({ global: globalLayer.overlay, workspace: overlay }); return response; } @@ -212,6 +217,7 @@ export class OpencodeConfigService { response.effective = this.mergeEffective(null, overlay, globalLayer.overlay); response.lockedPaths = heredity.lockedPaths; response.inheritedAdditive = heredity.inheritedAdditive; + response.platformPolicy = this.resolvePlatformPolicy({ global: globalLayer.overlay, workspace: overlay }); return response; } @@ -259,6 +265,22 @@ export class OpencodeConfigService { return mergeSecretMaps(agentSecrets, workspaceSecrets, globalSecrets); } + /** + * Effective platform security policy (egress + message filters) with provenance for the given layers + * (composed overlays). Omitted layers do not contribute. + */ + resolvePlatformPolicy(layers: { + global?: Record | null; + workspace?: Record | null; + environment?: Record | null; + }): EffectivePlatformSecurityPolicy { + return resolvePlatformSecurityPolicy([ + { source: 'global', overlay: layers.global ?? null }, + { source: 'workspace', overlay: layers.workspace ?? null }, + { source: 'environment', overlay: layers.environment ?? null }, + ]); + } + computeHeredity(...parents: Array): { lockedPaths: string[]; inheritedAdditive: InheritedAdditiveEntry[]; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.spec.ts index 1a277765f..1fb699d49 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.spec.ts @@ -53,7 +53,7 @@ describe('OpencodeEffectiveConfigSyncService', () => { const result = await svc.syncAgent(clientId, agentId); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, platformPolicy: expect.objectContaining({ messageFilters: [] }) }); expect(opencodeConfigService.composeStoredLayer).toHaveBeenCalledWith({ c: 3 }, { d: 4 }); expect(opencodeConfigService.mergeEffectiveForSync).toHaveBeenCalledWith({ c: 3, d: 4 }, { b: 2 }, { a: 1 }); expect(agentConfigProxy.syncEffective).toHaveBeenCalledWith( @@ -64,9 +64,35 @@ describe('OpencodeEffectiveConfigSyncService', () => { G: 'g', W: 'w', }, + expect.objectContaining({ egress: expect.objectContaining({ mode: 'off' }), messageFilters: [] }), ); }); + it('resolves the platform security policy with layer provenance', async () => { + const { svc, opencodeConfigService, agentConfigProxy } = await createService(); + + opencodeConfigService.getLayerConfigs.mockResolvedValue({ + global: { egress: { mode: 'enforce', deny_hosts: ['bad.example'] } }, + workspace: { message_filters: [{ name: 'W', pattern: 'w', action: 'drop' }] }, + }); + opencodeConfigService.composeStoredLayer.mockReturnValue({ egress: { mode: 'off', allow_hosts: ['ok.example'] } }); + + const payload = await svc.buildEffectivePayload(clientId, agentId); + + expect(payload.platformPolicy.egress.mode).toBe('enforce'); + expect(payload.platformPolicy.egress.scalarSources.mode).toBe('global'); + expect(payload.platformPolicy.egress.allowHosts).toEqual([{ value: 'ok.example', source: 'environment' }]); + expect(payload.platformPolicy.messageFilters).toEqual([ + expect.objectContaining({ name: 'W', source: 'workspace' }), + ]); + expect(agentConfigProxy.get).toHaveBeenCalledWith(clientId, agentId); + + const creation = await svc.buildCreationPlatformPolicy(clientId); + + expect(creation.egress.allowHosts).toEqual([]); + expect(creation.egress.denyHosts).toEqual([{ value: 'bad.example', source: 'global' }]); + }); + it('syncAllClients fans out to every client agent (paged)', async () => { const { svc, clientsRepository, clientAgentProxy, agentConfigProxy } = await createService(); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.ts index 4b1203e0d..ce82b1761 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-effective-config-sync.service.ts @@ -1,3 +1,7 @@ +import { + resolvePlatformSecurityPolicy, + type EffectivePlatformSecurityPolicy, +} from '@forepath/agenstra/shared/util-opencode-config'; import { Injectable, Logger } from '@nestjs/common'; import { ClientsRepository } from '../repositories/clients.repository'; @@ -10,6 +14,8 @@ export interface OpencodeAgentSyncResult { /** When true, keep target `pending` (e.g. container not running) instead of `failed`. */ defer?: boolean; error?: string; + /** Platform security policy that was sent with this sync. */ + platformPolicy?: EffectivePlatformSecurityPolicy; } /** @@ -33,7 +39,11 @@ export class OpencodeEffectiveConfigSyncService { async buildEffectivePayload( clientId: string, agentId: string, - ): Promise<{ effective: Record; secrets: Record }> { + ): Promise<{ + effective: Record; + secrets: Record; + platformPolicy: EffectivePlatformSecurityPolicy; + }> { const layers = await this.opencodeConfigService.getLayerConfigs(clientId); const secrets = await this.opencodeConfigService.getLayerSecrets(clientId); const agentConfig = await this.agentConfigProxy.get(clientId, agentId); @@ -45,15 +55,33 @@ export class OpencodeEffectiveConfigSyncService { ); // Agent-layer secrets are merged inside manager syncEffective; pass workspace + global only. const workerSecrets = this.opencodeConfigService.mergeSecrets({}, secrets.workspace, secrets.global); + // Platform security roots never reach the OpenCode wire: resolve them separately (with provenance). + const platformPolicy = resolvePlatformSecurityPolicy([ + { source: 'global', overlay: layers.global }, + { source: 'workspace', overlay: layers.workspace }, + { source: 'environment', overlay: agentOverlay }, + ]); + + return { effective, secrets: workerSecrets, platformPolicy }; + } + + /** Effective platform security policy for a new agent (workspace + global; no environment layer yet). */ + async buildCreationPlatformPolicy(clientId: string): Promise { + const layers = await this.opencodeConfigService.getLayerConfigs(clientId); - return { effective, secrets: workerSecrets }; + return resolvePlatformSecurityPolicy([ + { source: 'global', overlay: layers.global }, + { source: 'workspace', overlay: layers.workspace }, + ]); } /** Sync one agent with the full layered effective config. */ async syncAgent(clientId: string, agentId: string): Promise { - const { effective, secrets } = await this.buildEffectivePayload(clientId, agentId); + const { effective, secrets, platformPolicy } = await this.buildEffectivePayload(clientId, agentId); + + const result = await this.agentConfigProxy.syncEffective(clientId, agentId, effective, secrets, platformPolicy); - return await this.agentConfigProxy.syncEffective(clientId, agentId, effective, secrets); + return { ...result, platformPolicy }; } /** Sync every agent for one workspace/client (paged). Prefer sync targets + BullMQ for durability. */ diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts index cf5238a42..2091f5f16 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { StatisticsEntityEventType, StatisticsEntityType } from '../entities/statistics-entity-event.entity'; +import { StatisticsSecurityRepository } from '../repositories/statistics-security.repository'; import { StatisticsRepository } from '../repositories/statistics.repository'; import { StatisticsQueryService } from './statistics-query.service'; @@ -20,14 +21,33 @@ describe('StatisticsQueryService', () => { queryFilterFlagsAggregate: jest.fn(), queryEntityEvents: jest.fn(), }; + const mockSecurityRepository = { + queryEgressAggregate: jest.fn(), + querySecuritySeries: jest.fn(), + queryEgressEvents: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [StatisticsQueryService, { provide: StatisticsRepository, useValue: mockRepository }], + providers: [ + StatisticsQueryService, + { provide: StatisticsRepository, useValue: mockRepository }, + { provide: StatisticsSecurityRepository, useValue: mockSecurityRepository }, + ], }).compile(); service = module.get(StatisticsQueryService); repository = module.get(StatisticsRepository); + mockSecurityRepository.queryEgressAggregate.mockResolvedValue({ + egressAllowedCount: 0, + egressBlockedCount: 0, + egressObservedCount: 0, + egressGuardErrorCount: 0, + egressBreakdown: [], + egressTopBlockedHosts: [], + egressRulesBreakdown: [], + }); + mockSecurityRepository.querySecuritySeries.mockResolvedValue([]); jest.clearAllMocks(); }); @@ -71,6 +91,103 @@ describe('StatisticsQueryService', () => { }); }); + describe('security summary and egress events', () => { + it('adds egress aggregates and the security series to summaries', async () => { + mockRepository.findStatisticsClientIdsByOriginalIds.mockResolvedValue(['sc1']); + mockRepository.queryChatIoAggregate.mockResolvedValue({ totalMessages: 1, totalWords: 1, totalChars: 1 }); + mockRepository.queryFilterDropsAggregate.mockResolvedValue({ filterDropCount: 0, filterTypesBreakdown: [] }); + mockRepository.queryFilterFlagsAggregate.mockResolvedValue({ filterFlagCount: 0, filterTypesBreakdown: [] }); + mockSecurityRepository.queryEgressAggregate.mockResolvedValue({ + egressAllowedCount: 10, + egressBlockedCount: 3, + egressObservedCount: 0, + egressGuardErrorCount: 1, + egressBreakdown: [{ kind: 'web', decision: 'blocked', count: 3 }], + egressTopBlockedHosts: [{ host: 'example.org', count: 3 }], + egressRulesBreakdown: [{ rule: 'deny_host', count: 3 }], + }); + mockSecurityRepository.querySecuritySeries.mockResolvedValue([ + { + period: '2026-10-12T00:00:00.000Z', + filterDrops: 1, + filterFlags: 0, + egressAllowed: 10, + egressBlocked: 3, + egressObserved: 0, + }, + ]); + + const result = await service.getSummary(['client-1'], { from: '2026-10-01', to: '2026-10-12', groupBy: 'day' }); + + expect(mockSecurityRepository.queryEgressAggregate).toHaveBeenCalledWith({ + statisticsClientIds: ['sc1'], + from: '2026-10-01', + to: '2026-10-12T23:59:59.999Z', + }); + expect(mockSecurityRepository.querySecuritySeries).toHaveBeenCalledWith( + expect.objectContaining({ statisticsClientIds: ['sc1'], groupBy: 'day' }), + ); + expect(result.egressBlockedCount).toBe(3); + expect(result.egressTopBlockedHosts).toEqual([{ host: 'example.org', count: 3 }]); + expect(result.securitySeries).toHaveLength(1); + }); + + it('omits the security series without groupBy and returns zeros for unknown clients', async () => { + mockRepository.findStatisticsClientIdsByOriginalIds.mockResolvedValue([]); + + const empty = await service.getClientSummary('client-unknown', {}); + + expect(empty.egressBlockedCount).toBe(0); + expect(empty.egressBreakdown).toEqual([]); + expect(empty.securitySeries).toBeUndefined(); + }); + + it('lists egress events with validated filters and maps original ids', async () => { + mockRepository.findStatisticsClientIdsByOriginalIds.mockResolvedValue(['sc1']); + mockSecurityRepository.queryEgressEvents.mockResolvedValue({ + rows: [ + { + id: 'e1', + statisticsClientId: 'sc1', + statisticsClient: { originalClientId: 'client-1', name: 'WS' }, + statisticsAgent: { originalAgentId: 'agent-1', name: 'Agent' }, + kind: 'web', + decision: 'blocked', + host: 'example.org', + rule: 'deny_host', + matchedEntry: 'example.org', + matchedSource: 'global', + count: 2, + bytesIn: '0', + bytesOut: '12', + firstSeenAt: new Date(1), + occurredAt: new Date(2), + }, + ], + total: 1, + }); + + const result = await service.getClientEgressEvents('client-1', { + decision: 'blocked', + kind: 'web', + search: 'example', + }); + + expect(mockSecurityRepository.queryEgressEvents).toHaveBeenCalledWith( + expect.objectContaining({ statisticsClientIds: ['sc1'], decision: 'blocked', kind: 'web', search: 'example' }), + ); + expect(result.data[0]).toEqual( + expect.objectContaining({ clientId: 'client-1', agentId: 'agent-1', host: 'example.org', bytesOut: 12 }), + ); + expect(result.total).toBe(1); + }); + + it('rejects unknown egress filters', async () => { + await expect(service.getEgressEvents(['client-1'], { decision: 'maybe' })).rejects.toThrow(BadRequestException); + await expect(service.getEgressEvents(['client-1'], { kind: 'smtp' })).rejects.toThrow(BadRequestException); + }); + }); + describe('getClientChatIo', () => { it('should return paginated chat I/O', async () => { mockRepository.findStatisticsClientIdsByOriginalIds.mockResolvedValue(['sc1']); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts index af762c55a..16d002a2c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts @@ -3,6 +3,8 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { StatisticsChatIoListDto, StatisticsChatIoDto, + StatisticsEgressEventDto, + StatisticsEgressEventListDto, StatisticsEntityEventListDto, StatisticsEntityEventDto, StatisticsFilterDropListDto, @@ -13,6 +15,8 @@ import { } from '../dto/statistics'; import { ChatDirection, StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { StatisticsEntityEventType, StatisticsEntityType } from '../entities/statistics-entity-event.entity'; +import type { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; +import { StatisticsSecurityRepository } from '../repositories/statistics-security.repository'; import { StatisticsRepository } from '../repositories/statistics.repository'; const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/; @@ -59,9 +63,151 @@ function parseStatisticsInteractionKind(value: string | undefined): StatisticsIn throw new BadRequestException(`interactionKind must be one of: ${[...ALLOWED_INTERACTION_KINDS].join(', ')}`); } +const ALLOWED_EGRESS_KINDS: ReadonlySet = new Set(['web', 'dns', 'network', 'guard']); +const ALLOWED_EGRESS_DECISIONS: ReadonlySet = new Set(['allowed', 'blocked', 'observed', 'error']); + +function parseEgressFilter( + value: string | undefined, + allowed: ReadonlySet, + paramName: string, +): string | undefined { + if (!value) return undefined; + + if (allowed.has(value)) { + return value; + } + + throw new BadRequestException(`${paramName} must be one of: ${[...allowed].join(', ')}`); +} + +const EMPTY_SECURITY_SUMMARY = { + egressAllowedCount: 0, + egressBlockedCount: 0, + egressObservedCount: 0, + egressGuardErrorCount: 0, + egressBreakdown: [] as StatisticsSummaryDto['egressBreakdown'], + egressTopBlockedHosts: [] as StatisticsSummaryDto['egressTopBlockedHosts'], + egressRulesBreakdown: [] as StatisticsSummaryDto['egressRulesBreakdown'], +}; + @Injectable() export class StatisticsQueryService { - constructor(private readonly statisticsRepository: StatisticsRepository) {} + constructor( + private readonly statisticsRepository: StatisticsRepository, + private readonly securityRepository: StatisticsSecurityRepository, + ) {} + + /** Egress aggregates + (optional) security time series for summary responses. */ + private async buildSecuritySummary( + ids: string[], + from: string | undefined, + to: string | undefined, + groupBy: 'day' | 'hour' | undefined, + ): Promise< + Pick< + StatisticsSummaryDto, + | 'egressAllowedCount' + | 'egressBlockedCount' + | 'egressObservedCount' + | 'egressGuardErrorCount' + | 'egressBreakdown' + | 'egressTopBlockedHosts' + | 'egressRulesBreakdown' + | 'securitySeries' + > + > { + const [aggregate, securitySeries] = await Promise.all([ + this.securityRepository.queryEgressAggregate({ statisticsClientIds: ids, from, to }), + groupBy + ? this.securityRepository.querySecuritySeries({ statisticsClientIds: ids, from, to, groupBy }) + : Promise.resolve(undefined), + ]); + + return { ...aggregate, ...(securitySeries ? { securitySeries } : {}) }; + } + + private toEgressEventDto(row: StatisticsEgressEventEntity): StatisticsEgressEventDto { + return { + id: row.id, + clientId: row.statisticsClient?.originalClientId ?? row.statisticsClientId, + agentId: row.statisticsAgent?.originalAgentId, + clientName: row.statisticsClient?.name, + agentName: row.statisticsAgent?.name, + kind: row.kind, + decision: row.decision, + host: row.host ?? null, + rule: row.rule ?? null, + matchedEntry: row.matchedEntry ?? null, + matchedSource: row.matchedSource ?? null, + method: row.method ?? null, + httpStatus: row.httpStatus ?? null, + sampleUrl: row.sampleUrl ?? null, + count: row.count, + bytesIn: Number(row.bytesIn ?? 0), + bytesOut: Number(row.bytesOut ?? 0), + mode: row.mode ?? null, + policyRevision: row.policyRevision ?? null, + message: row.message ?? null, + firstSeenAt: row.firstSeenAt, + occurredAt: row.occurredAt, + }; + } + + async getClientEgressEvents( + clientId: string, + params: { + agentId?: string; + kind?: string; + decision?: string; + from?: string; + to?: string; + search?: string; + limit?: number; + offset?: number; + }, + ): Promise { + return await this.getEgressEvents([clientId], params); + } + + async getEgressEvents( + accessibleClientIds: string[], + params: { + agentId?: string; + kind?: string; + decision?: string; + from?: string; + to?: string; + search?: string; + limit?: number; + offset?: number; + }, + ): Promise { + const from = validateIsoDate(params.from, 'from'); + const to = normalizeToEndOfDay(validateIsoDate(params.to, 'to')); + const kind = parseEgressFilter(params.kind, ALLOWED_EGRESS_KINDS, 'kind'); + const decision = parseEgressFilter(params.decision, ALLOWED_EGRESS_DECISIONS, 'decision'); + const limit = params.limit ?? 10; + const offset = params.offset ?? 0; + const ids = await this.resolveStatisticsClientIds(accessibleClientIds); + + if (ids.length === 0) { + return { data: [], total: 0, limit, offset }; + } + + const { rows, total } = await this.securityRepository.queryEgressEvents({ + statisticsClientIds: ids, + agentId: params.agentId, + kind, + decision, + from, + to, + search: params.search, + limit, + offset, + }); + + return { data: rows.map((row) => this.toEgressEventDto(row)), total, limit, offset }; + } private async resolveStatisticsClientIds(originalClientIds: string[]): Promise { if (originalClientIds.length === 0) return []; @@ -94,6 +240,7 @@ export class StatisticsQueryService { filterTypesBreakdown: [], filterFlagCount: 0, filterFlagsBreakdown: [], + ...EMPTY_SECURITY_SUMMARY, }; } @@ -113,6 +260,7 @@ export class StatisticsQueryService { this.statisticsRepository.queryFilterDropsAggregate({ statisticsClientIds: ids, from, to }), this.statisticsRepository.queryFilterFlagsAggregate({ statisticsClientIds: ids, from, to }), ]); + const security = await this.buildSecuritySummary(ids, from, to, params.groupBy); return { totalMessages: chatAgg.totalMessages, @@ -131,6 +279,7 @@ export class StatisticsQueryService { filterFlagCount: filterFlagAgg.filterFlagCount, filterFlagsBreakdown: filterFlagAgg.filterTypesBreakdown, series: chatAgg.series, + ...security, }; } @@ -159,6 +308,7 @@ export class StatisticsQueryService { filterTypesBreakdown: [], filterFlagCount: 0, filterFlagsBreakdown: [], + ...EMPTY_SECURITY_SUMMARY, }; } @@ -178,6 +328,7 @@ export class StatisticsQueryService { this.statisticsRepository.queryFilterDropsAggregate({ statisticsClientIds: ids, from, to }), this.statisticsRepository.queryFilterFlagsAggregate({ statisticsClientIds: ids, from, to }), ]); + const security = await this.buildSecuritySummary(ids, from, to, params.groupBy); return { totalMessages: chatAgg.totalMessages, @@ -196,6 +347,7 @@ export class StatisticsQueryService { filterFlagCount: filterFlagAgg.filterFlagCount, filterFlagsBreakdown: filterFlagAgg.filterTypesBreakdown, series: chatAgg.series, + ...security, }; } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.spec.ts index f84ead027..1a8076106 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.spec.ts @@ -4,6 +4,7 @@ import { FilterDropDirection } from '../entities/statistics-chat-filter-drop.ent import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { StatisticsEntityEventType, StatisticsEntityType } from '../entities/statistics-entity-event.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { StatisticsSecurityRepository } from '../repositories/statistics-security.repository'; import { StatisticsRepository } from '../repositories/statistics.repository'; import { AgenstraSearchIndexService } from '../search/agenstra-search-index.service'; @@ -31,6 +32,11 @@ describe('StatisticsService', () => { updatedAt: new Date(), }; + const securityRepository = { + insertEgressEvents: jest.fn(), + }; + const upsertSafe = jest.fn().mockResolvedValue(undefined); + beforeEach(async () => { statisticsRepository = { upsertStatisticsClient: jest.fn().mockResolvedValue(mockStatisticsClient), @@ -55,10 +61,11 @@ describe('StatisticsService', () => { StatisticsService, { provide: StatisticsRepository, useValue: statisticsRepository }, { provide: ClientsRepository, useValue: clientsRepository }, + { provide: StatisticsSecurityRepository, useValue: securityRepository }, { provide: AgenstraSearchIndexService, useValue: { - upsertSafe: jest.fn().mockResolvedValue(undefined), + upsertSafe, deleteSafe: jest.fn().mockResolvedValue(undefined), isEnabled: jest.fn().mockReturnValue(false), }, @@ -74,6 +81,75 @@ describe('StatisticsService', () => { jest.clearAllMocks(); }); + describe('recordEgressEvents', () => { + it('maps manager events to shadow ids, stores them idempotently and indexes new rows', async () => { + securityRepository.insertEgressEvents.mockImplementation(async (rows: Array>) => + rows.slice(0, 1).map((row) => ({ ...row, id: 'row-1' })), + ); + + const inserted = await service.recordEgressEvents('client-uuid', [ + { + seq: '5', + agentId: 'agent-uuid', + kind: 'web', + decision: 'blocked', + host: 'example.org', + rule: 'deny_host', + count: 0, + bytesIn: -1, + firstSeenAt: '2026-10-12T10:00:00.000Z', + lastSeenAt: '2026-10-12T10:00:05.000Z', + }, + { + seq: '6', + agentId: 'agent-uuid', + kind: 'dns', + decision: 'blocked', + count: 2, + firstSeenAt: '2026-10-12T10:00:00.000Z', + lastSeenAt: '2026-10-12T10:00:05.000Z', + }, + ]); + + expect(statisticsRepository.upsertStatisticsAgent).toHaveBeenCalledTimes(1); + expect(securityRepository.insertEgressEvents).toHaveBeenCalledWith([ + expect.objectContaining({ + statisticsClientId: 'stats-client-uuid', + statisticsAgentId: 'stats-agent-uuid', + sourceSeq: '5', + count: 1, + bytesIn: '0', + occurredAt: new Date('2026-10-12T10:00:05.000Z'), + }), + expect.objectContaining({ sourceSeq: '6', kind: 'dns', count: 2 }), + ]); + expect(inserted).toEqual([expect.objectContaining({ id: 'row-1', originalAgentId: 'agent-uuid' })]); + expect(upsertSafe).toHaveBeenCalledWith( + 'egress-events', + expect.objectContaining({ id: 'row-1', agentId: 'agent-uuid' }), + ); + }); + + it('is a no-op for empty batches and fails for unknown clients', async () => { + await expect(service.recordEgressEvents('client-uuid', [])).resolves.toEqual([]); + + (clientsRepository.findById as jest.Mock).mockResolvedValueOnce(null); + await expect( + service.recordEgressEvents('missing', [ + { + seq: '1', + agentId: 'a', + kind: 'web', + decision: 'allowed', + count: 1, + firstSeenAt: new Date(), + lastSeenAt: new Date(), + }, + ]), + ).rejects.toThrow(/not found/); + }); + }); + describe('recordChatInput', () => { it('should record chat input and create shadow entries', async () => { await service.recordChatInput('client-uuid', 'agent-uuid', 10, 50, 'user-uuid'); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.ts index 0ffc76065..7fd974d74 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/statistics.service.ts @@ -6,11 +6,17 @@ import { FilterDropDirection } from '../entities/statistics-chat-filter-drop.ent import { FilterFlagDirection } from '../entities/statistics-chat-filter-flag.entity'; import { ChatDirection, StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { StatisticsEntityEventType, StatisticsEntityType } from '../entities/statistics-entity-event.entity'; +import type { StatisticsEgressEventEntity } from '../entities/statistics-egress-event.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { + StatisticsSecurityRepository, + type NewStatisticsEgressEvent, +} from '../repositories/statistics-security.repository'; import { StatisticsRepository } from '../repositories/statistics.repository'; import { mapAgentToSearchDocument, mapChatIoToSearchDocument, + mapEgressEventToSearchDocument, mapFilterDropToSearchDocument, mapFilterFlagToSearchDocument, } from '../search/agenstra-search-document.mapper'; @@ -30,8 +36,106 @@ export class StatisticsService { private readonly statisticsRepository: StatisticsRepository, private readonly clientsRepository: ClientsRepository, private readonly searchIndex: AgenstraSearchIndexService, + private readonly securityRepository: StatisticsSecurityRepository, ) {} + /** + * Record egress guard decisions ingested from an agent manager (idempotent per manager `seq`). + * Returns the rows that were newly stored (already ingested rows are skipped). + */ + async recordEgressEvents( + clientId: string, + events: ReadonlyArray<{ + seq: string; + agentId: string; + kind: string; + decision: string; + host?: string | null; + rule?: string | null; + matchedEntry?: string | null; + matchedSource?: string | null; + method?: string | null; + httpStatus?: number | null; + sampleUrl?: string | null; + count: number; + bytesIn?: number; + bytesOut?: number; + mode?: string | null; + policyRevision?: string | null; + message?: string | null; + firstSeenAt: Date | string; + lastSeenAt: Date | string; + }>, + ): Promise> { + if (events.length === 0) { + return []; + } + + const client = await this.clientsRepository.findById(clientId); + + if (!client) { + throw new Error(`Client ${clientId} not found`); + } + + const statisticsClient = await this.statisticsRepository.upsertStatisticsClient(clientId, { + name: client.name, + endpoint: client.endpoint, + authenticationType: client.authenticationType, + }); + const agentShadowIds = new Map(); + + for (const agentId of new Set(events.map((event) => event.agentId))) { + const shadow = await this.statisticsRepository.upsertStatisticsAgent(agentId, statisticsClient.id, {}); + + agentShadowIds.set(agentId, shadow.id); + } + + const text = (value: string | null | undefined, max: number) => (value ? value.slice(0, max) : null); + const rows: NewStatisticsEgressEvent[] = events.map((event) => ({ + statisticsAgentId: agentShadowIds.get(event.agentId), + statisticsClientId: statisticsClient.id, + sourceSeq: String(event.seq), + kind: String(event.kind).slice(0, 16), + decision: String(event.decision).slice(0, 16), + host: text(event.host, 255), + rule: text(event.rule, 32), + matchedEntry: text(event.matchedEntry, 255), + matchedSource: text(event.matchedSource, 16), + method: text(event.method, 32), + httpStatus: typeof event.httpStatus === 'number' ? event.httpStatus : null, + sampleUrl: text(event.sampleUrl, 2048), + count: Math.max(1, Math.min(Number(event.count) || 1, 2_147_483_647)), + bytesIn: String(Math.max(0, Number(event.bytesIn) || 0)), + bytesOut: String(Math.max(0, Number(event.bytesOut) || 0)), + mode: text(event.mode, 16), + policyRevision: text(event.policyRevision, 64), + message: text(event.message, 2000), + firstSeenAt: new Date(event.firstSeenAt), + occurredAt: new Date(event.lastSeenAt), + })); + const inserted = await this.securityRepository.insertEgressEvents(rows); + const originalAgentByShadow = new Map( + [...agentShadowIds.entries()].map(([original, shadow]) => [shadow, original]), + ); + + for (const row of inserted) { + const originalAgentId = row.statisticsAgentId ? originalAgentByShadow.get(row.statisticsAgentId) : undefined; + + void this.searchIndex.upsertSafe('egress-events', mapEgressEventToSearchDocument(row, clientId, originalAgentId)); + incrementCounter( + this.otelMeterName, + 'agenstra.egress.decisions', + { client_id: clientId, kind: row.kind, decision: row.decision, rule: row.rule ?? 'none' }, + row.count, + ); + } + + return inserted.map((row) => ({ + ...row, + originalAgentId: (row.statisticsAgentId && originalAgentByShadow.get(row.statisticsAgentId)) || '', + })); + } + /** * Record chat input (user message). Ensures shadow entries exist. */ diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.spec.ts new file mode 100644 index 000000000..51a47ddfd --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.spec.ts @@ -0,0 +1,38 @@ +import { hasAgentConfigChanges, summarizeAgentConfigChange } from './agent-config-change.utils'; + +describe('summarizeAgentConfigChange', () => { + it('reports changed root keys, security roots, locks and secret keys (never values)', () => { + const summary = summarizeAgentConfigChange( + { config: { model: 'a/b', egress: { mode: 'off' } }, overrides: {}, locks: ['/model'] }, + { + config: { model: 'a/b', egress: { mode: 'enforce' }, share: 'manual' }, + overrides: { message_filters: [] }, + locks: ['/model', '/tabs/egress'], + }, + { HTTP_PROXY: 'http://proxy:8080' }, + ); + + expect(summary).toEqual({ + changedRoots: ['egress', 'message_filters', 'share'], + securityRootsChanged: ['egress', 'message_filters'], + locksChanged: true, + secretKeysChanged: ['HTTP_PROXY'], + }); + expect(hasAgentConfigChanges(summary)).toBe(true); + }); + + it('ignores key order and detects no-op saves', () => { + const summary = summarizeAgentConfigChange( + { config: { egress: { mode: 'enforce', deny_hosts: ['a'] } }, locks: ['/b', '/a'] }, + { config: { egress: { deny_hosts: ['a'], mode: 'enforce' } }, locks: ['/a', '/b'] }, + undefined, + ); + + expect(summary.changedRoots).toEqual([]); + expect(hasAgentConfigChanges(summary)).toBe(false); + }); + + it('flags clearing all secrets', () => { + expect(summarizeAgentConfigChange({}, {}, null).secretKeysChanged).toEqual(['*']); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.ts new file mode 100644 index 000000000..ce4448f8c --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/agent-config-change.utils.ts @@ -0,0 +1,65 @@ +import { stablePlatformPolicyJson } from '@forepath/agenstra/shared/util-opencode-config'; + +/** Root keys whose changes are security relevant (policy, allow / deny lists, egress, message filters). */ +export const AGENT_CONFIG_SECURITY_ROOT_KEYS = [ + 'permissions', + 'experimental', + 'egress', + 'message_filters', + 'enabled_providers', + 'disabled_providers', + 'model_allow', + 'model_deny', + 'mcp_allow', + 'mcp_deny', +] as const; + +export interface AgentConfigLayerSnapshot { + config?: Record | null; + overrides?: Record | null; + locks?: readonly string[] | null; +} + +export interface AgentConfigChangeSummary { + changedRoots: string[]; + securityRootsChanged: string[]; + locksChanged: boolean; + secretKeysChanged: string[]; +} + +function rootValues(snapshot: AgentConfigLayerSnapshot, key: string): string { + return stablePlatformPolicyJson([snapshot.config?.[key] ?? null, snapshot.overrides?.[key] ?? null]); +} + +/** + * Summarize what a layer PUT changed (root keys only — values are never reported, they may hold + * internal hostnames or patterns). Secret keys are reported by name for keys present in the patch. + */ +export function summarizeAgentConfigChange( + before: AgentConfigLayerSnapshot, + after: AgentConfigLayerSnapshot, + secretsPatch?: Record | null, +): AgentConfigChangeSummary { + const keys = new Set([ + ...Object.keys(before.config ?? {}), + ...Object.keys(before.overrides ?? {}), + ...Object.keys(after.config ?? {}), + ...Object.keys(after.overrides ?? {}), + ]); + const changedRoots = [...keys].filter((key) => rootValues(before, key) !== rootValues(after, key)).sort(); + const security = new Set(AGENT_CONFIG_SECURITY_ROOT_KEYS); + const locksBefore = [...(before.locks ?? [])].sort().join('\n'); + const locksAfter = [...(after.locks ?? [])].sort().join('\n'); + + return { + changedRoots, + securityRootsChanged: changedRoots.filter((key) => security.has(key)), + locksChanged: locksBefore !== locksAfter, + secretKeysChanged: secretsPatch === null ? ['*'] : Object.keys(secretsPatch ?? {}).sort(), + }; +} + +/** Whether a summary contains any change worth notifying about. */ +export function hasAgentConfigChanges(summary: AgentConfigChangeSummary): boolean { + return summary.changedRoots.length > 0 || summary.locksChanged || summary.secretKeysChanged.length > 0; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/README.md b/libs/domains/agenstra/backend/feature-agent-manager/README.md index d1df11c0a..875fd585b 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/README.md +++ b/libs/domains/agenstra/backend/feature-agent-manager/README.md @@ -25,6 +25,7 @@ Agents are entities that can be created, authenticated, and interacted with thro - ✅ Support for UUID or name-based agent identification - ✅ **Plugin-based agent provider system** - OpenCode via [Agent Client Protocol (ACP)](../../../../docs/agenstra/ai-agents/agent-client-protocol.md) over stdio; additional types through the unified `AgentProvider` interface - ✅ **Plugin-based chat filter system** - Support for multiple message filtering implementations (profanity, PII, content policy, etc.) through a unified interface +- ✅ **Egress guard** - Per-agent sidecar (Squid, dnsmasq, iptables) that filters outbound web traffic and reports decision events; see [Egress Filtering](../../../../docs/agenstra/features/egress-filtering.md). Message filters (`message_filters`) arrive with the layered agent configuration; see [Message Filters](../../../../docs/agenstra/features/message-filters.md) - ✅ **Extensible architecture** - Easy to add new agent providers and chat filters by implementing the respective interfaces ## Architecture @@ -87,6 +88,7 @@ All diagrams are available in the [`docs/`](./docs/) directory: - **[WebSocket Auth & Logs Diagram](./docs/sequence-ws-auth-logs.mmd)** - Sequence diagram for WebSocket connection, authentication flow, and container log streaming - **[WebSocket Chat Diagram](./docs/sequence-ws-chat.mmd)** - Sequence diagram for WebSocket chat message flow and disconnection handling - **[WebSocket Container Stats Diagram](./docs/sequence-ws-container-stats.mmd)** - Periodic `containerStats` broadcasting after login (default interval 15s via `CONTAINER_STATS_SCHEDULER_INTERVAL`) +- **[Egress Guard Diagram](./docs/egress-guard.mmd)** - Guard provisioning, policy apply, agent network namespace join and event collection - **[Lifecycle Diagram](./docs/lifecycle.mmd)** - End-to-end sequence diagram showing the complete agent lifecycle from creation through deletion These diagrams provide comprehensive visual documentation of: diff --git a/libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd b/libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd new file mode 100644 index 000000000..68ee6ccf4 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/docs/egress-guard.mmd @@ -0,0 +1,64 @@ +sequenceDiagram + participant Ctrl as Agent Controller + participant Mgr as AgentEgressService + participant Docker as DockerService + participant Guard as Egress guard
(agenstra-egress-agentId) + participant Agent as Agent container + participant Coll as AgentEgressEventsCollectorService + participant DB as Database + + Note over Ctrl,DB: Egress guard - provisioning and event collection + + rect rgb(230, 240, 255) + Note over Ctrl,Guard: Step 1 - Provision guard before the agent exists (fail closed) + Ctrl->>Mgr: create agent + effective egress policy (layered: global, workspace, environment) + Mgr->>Docker: create + start guard (CAP_NET_ADMIN, memory limit) + Guard->>Guard: install lockdown netfilter rules (no policy = no egress) + Mgr->>Guard: waitForGuardReady (squid + dnsmasq running, 30s timeout) + Mgr->>Guard: agenstra-egress prepare + Mgr->>Guard: upload bundle to staged/ (squid.conf, dnsmasq.conf, rules.v4/v6, allow/deny lists) + Mgr->>Guard: agenstra-egress apply + Guard->>Guard: validate (squid -k parse, dnsmasq --test, iptables-restore --test) + alt Bundle valid + Guard->>Guard: activate bundle, SIGHUP supervisor, reload squid + dnsmasq + Guard-->>Mgr: applied revision + else Bundle invalid + Guard-->>Mgr: error (last known good bundle stays active) + Mgr->>Docker: remove guard + Mgr-->>Ctrl: provisioning fails (egressStatus failed) + end + end + + rect rgb(240, 255, 240) + Note over Mgr,Agent: Step 2 - Agent joins the guard network namespace + Mgr->>Docker: create agent (NetworkMode container:guard, HTTP_PROXY / HTTPS_PROXY = 127.0.0.1:3128) + Docker->>Agent: start + Agent->>Guard: DNS -> dnsmasq 5353, TCP 80 -> squid 3129, TCP 443 -> squid 3130 (peek / splice / terminate) + Note over Guard: QUIC, DNS over TLS and undeclared protocols are rejected + end + + rect rgb(255, 248, 232) + Note over Ctrl,Guard: Step 3 - Policy change (config sync) + Ctrl->>Mgr: effective policy update + Mgr->>Mgr: compute revision (skip when guard already applied it) + Mgr->>Guard: prepare, upload, apply + Mgr->>Agent: recreate once only when it still runs on the legacy network + end + + rect rgb(245, 235, 255) + Note over Coll,DB: Step 4 - Event collection (every AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS) + loop Every poll interval + Coll->>Guard: read logs (AGENSTRA_EGRESS web lines, AGENSTRA_GUARD events) + Coll->>Guard: agenstra-egress counters (netfilter counted categories) + Guard-->>Coll: log lines + counters + Coll->>Coll: aggregate decisions (allowed, blocked, observed, error) + Coll->>DB: INSERT agent egress events (monotonic seq) + Coll->>DB: DELETE events older than AGENT_EGRESS_EVENTS_RETENTION_HOURS + end + end + + rect rgb(255, 240, 240) + Note over Ctrl,DB: Step 5 - Controller ingestion + Ctrl->>Mgr: GET /api/egress/events?afterSeq=&limit= + Mgr-->>Ctrl: events, nextSeq, hasMore + end diff --git a/libs/domains/agenstra/backend/feature-agent-manager/docs/regex-filters.mmd b/libs/domains/agenstra/backend/feature-agent-manager/docs/regex-filters.mmd deleted file mode 100644 index 81347b1c5..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/docs/regex-filters.mmd +++ /dev/null @@ -1,20 +0,0 @@ -flowchart LR - subgraph persist [Persistence] - API["HTTP /api/agents-filters"] - DB[(regex_filter_rules)] - API --> DB - end - - subgraph runtime [WebSocket chat] - GW[AgentsGateway.applyFilters] - FIn[database-regex-incoming] - FOut[database-regex-outgoing] - Cache[RegexFilterRulesCacheService] - GW --> FIn - GW --> FOut - FIn --> Cache - FOut --> Cache - Cache --> DB - end - - API -.->|invalidate cache on mut| Cache diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml index 73e0988cb..e5d2e1c1a 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml @@ -237,6 +237,15 @@ channels: and secrets have successfully been applied to the worker. Includes config-only updates as well as container recreation and changes inherited from workspace/global layers. Clients refresh cached model and slash-command catalogs for the affected agent. Failed/deferred syncs do not emit. + agents/egressStatusChanged: + address: agents/egressStatusChanged + messages: + egressStatusChangedEvent: + $ref: '#/components/messages/EgressStatusChanged' + description: | + Emitted to sockets logged in to the affected agent whenever the egress guard (web traffic filtering) + status of that environment changes (guard applied, failed, or deactivated). Payload is the egress status + of the agent; use `GET /api/agents/{id}/egress` for the full policy and recent events. agents/workspaceIndexChanged: address: agents/workspaceIndexChanged messages: @@ -434,6 +443,12 @@ operations: $ref: '#/channels/agents~1opencodeConfigSynced' messages: - $ref: '#/channels/agents~1opencodeConfigSynced/messages/opencodeConfigSyncedEvent' + serverBroadcastsEgressStatusChanged: + action: receive + channel: + $ref: '#/channels/agents~1egressStatusChanged' + messages: + - $ref: '#/channels/agents~1egressStatusChanged/messages/egressStatusChangedEvent' serverBroadcastsWorkspaceIndexChanged: action: receive channel: @@ -1519,6 +1534,52 @@ components: timestamp: type: string format: date-time + EgressStatusChanged: + name: EgressStatusChanged + title: Egress guard status changed + contentType: application/json + payload: + type: object + required: [success, data, timestamp] + properties: + success: + type: boolean + const: true + data: + type: object + required: + [ + agentId, + status, + mode, + guardContainerId, + policyRevision, + appliedAt, + lastError, + ] + properties: + agentId: + type: string + format: uuid + status: + type: string + enum: [inactive, active, failed] + mode: + type: string + enum: [off, monitor, enforce, offline] + guardContainerId: + type: [string, 'null'] + description: Short guard container id (null when the agent never required egress filtering) + policyRevision: + type: [string, 'null'] + appliedAt: + type: [string, 'null'] + format: date-time + lastError: + type: [string, 'null'] + timestamp: + type: string + format: date-time EnvironmentProgress: name: EnvironmentProgress title: Environment provisioning progress diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml index 8d74ce550..fa55b5dce 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml @@ -360,6 +360,8 @@ paths: updatedAt: type: string format: date-time + egress: + $ref: '#/components/schemas/AgentEgressStatusDto' put: summary: Update agent OpenCode user config operationId: putAgentOpencodeConfig @@ -393,7 +395,7 @@ paths: type: string responses: '200': - description: Updated agent OpenCode config + description: Updated agent OpenCode config (same shape as the GET response, including `egress`) /agents/{id}/opencode/config/agents: get: summary: List effective OpenCode agents (primary + subagents) from the worker @@ -499,6 +501,13 @@ paths: config: type: object additionalProperties: true + secrets: + type: object + additionalProperties: + type: string + platformPolicy: + $ref: '#/components/schemas/SyncAgentPlatformPolicyDto' + description: Omitted by older controllers; the stored platform policy then stays in force responses: '200': description: Sync attempted @@ -634,85 +643,49 @@ paths: description: Invalid code or OAuth failed '404': description: Agent or MCP server not found - /agents-filters: + /egress/events: get: - summary: List regex filter rules - operationId: listRegexFilterRules + summary: List egress guard decision events (ingestion feed) + description: | + Cursor-based feed of aggregated egress guard decisions, ordered by the monotonic `seq`. + The agent controller polls this endpoint, passing the previous `nextSeq` as `afterSeq`. + operationId: listEgressEvents parameters: - in: query - name: limit + name: afterSeq schema: - type: integer - minimum: 0 + type: string + pattern: '^\d{1,19}$' + default: '0' + description: Return only events with a `seq` greater than this cursor (non-negative integer) - in: query - name: offset + name: limit schema: type: integer - minimum: 0 - responses: - '200': - description: Array of regex filter rules (ordered by priority) - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/RegexFilterRuleResponseDto' - post: - summary: Create regex filter rule - operationId: createRegexFilterRule - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateRegexFilterRuleDto' - responses: - '201': - description: Created - content: - application/json: - schema: - $ref: '#/components/schemas/RegexFilterRuleResponseDto' - /agents-filters/count: - get: - summary: Count regex filter rules - operationId: countRegexFilterRules - responses: - '200': - description: Count - content: - application/json: - schema: - type: object - required: [count] - properties: - count: - type: integer - minimum: 0 - /agents-filters/{id}: - get: - summary: Get regex filter rule by id - operationId: getRegexFilterRule - parameters: - - in: path - name: id - required: true + minimum: 1 + description: Maximum number of events to return (default 500, capped at the server maximum page size) + - in: query + name: agentId schema: type: string format: uuid + description: Restrict events to one agent responses: '200': - description: Rule + description: Page of egress events content: application/json: schema: - $ref: '#/components/schemas/RegexFilterRuleResponseDto' - '404': - description: Not found - put: - summary: Update regex filter rule - operationId: updateRegexFilterRule + $ref: '#/components/schemas/AgentEgressEventsPageDto' + '400': + description: Invalid afterSeq or agentId + /agents/{id}/egress: + get: + summary: Get egress guard details of an agent + description: | + Returns the live guard status, the effective egress policy and message filters in force, + and the most recent (up to 50) egress events of the agent. + operationId: getAgentEgress parameters: - in: path name: id @@ -720,36 +693,15 @@ paths: schema: type: string format: uuid - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateRegexFilterRuleDto' responses: '200': - description: Updated + description: Egress details content: application/json: schema: - $ref: '#/components/schemas/RegexFilterRuleResponseDto' - '404': - description: Not found - delete: - summary: Delete regex filter rule - operationId: deleteRegexFilterRule - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - responses: - '204': - description: Deleted + $ref: '#/components/schemas/AgentEgressDetailsDto' '404': - description: Not found + description: Agent not found /instance-status: get: summary: Get agent-manager instance status @@ -2397,6 +2349,12 @@ components: type: string enum: [opencode] description: Agent provider type + egressPolicy: + $ref: '#/components/schemas/EffectiveEgressPolicy' + description: | + Effective egress policy (set by the agent controller from the layered agent configuration). + When it requires filtering, the egress guard is provisioned and its policy applied before the + agent container starts. deploymentConfiguration: type: object description: CI/CD deployment configuration (optional). If provided, the deployment configuration for this agent will be updated or created. @@ -2462,6 +2420,9 @@ components: type: string format: uuid description: Id of the primary chat session for this environment + egress: + $ref: '#/components/schemas/AgentEgressStatusDto' + description: Egress guard (web traffic filtering) status of this environment createdAt: type: string format: date-time @@ -3207,85 +3168,301 @@ components: enum: [override, default_env, unset] hasOverride: type: boolean - RegexFilterRuleResponseDto: + EgressMode: + type: string + enum: [off, monitor, enforce, offline] + description: | + `off` no enforcement; `monitor` all traffic allowed, would-be blocks recorded as `observed`; + `enforce` allow/deny lists apply (deny wins; empty allowlist = unrestricted); `offline` all egress blocked. + PlatformPolicyLayer: + type: string + enum: [global, workspace, environment] + description: Configuration layer that contributed a policy entry + EgressRule: + type: string + enum: + [ + offline, + deny_host, + private_network, + ip_literal, + deny_url, + not_allowlisted, + quic, + dns_over_tls, + undeclared_protocol, + guard, + ] + EffectiveEgressEntry: + type: object + required: [value, source] + properties: + value: + type: string + source: + $ref: '#/components/schemas/PlatformPolicyLayer' + EffectiveEgressNetworkRule: type: object + required: [protocol, ports, destinations, source] + properties: + protocol: + type: string + enum: [tcp, udp] + ports: + type: string + description: Port or port range, e.g. `22` or `1000-2000` + destinations: + type: array + items: + type: string + description: IP addresses or CIDRs + description: + type: string + source: + $ref: '#/components/schemas/PlatformPolicyLayer' + EffectiveEgressPolicy: + type: object + description: Fully resolved egress (web traffic) policy (global -> workspace -> environment) required: [ - id, - pattern, - regexFlags, - direction, - filterType, - priority, - createdAt, - updatedAt, + mode, + allowHosts, + denyHosts, + denyUrlPatterns, + networkAllow, + blockPrivateNetworks, + blockIpLiterals, + logAllowed, + scalarSources, ] properties: - id: + mode: + $ref: '#/components/schemas/EgressMode' + allowHosts: + type: array + items: + $ref: '#/components/schemas/EffectiveEgressEntry' + denyHosts: + type: array + items: + $ref: '#/components/schemas/EffectiveEgressEntry' + denyUrlPatterns: + type: array + items: + $ref: '#/components/schemas/EffectiveEgressEntry' + networkAllow: + type: array + items: + $ref: '#/components/schemas/EffectiveEgressNetworkRule' + blockPrivateNetworks: + type: boolean + blockIpLiterals: + type: boolean + logAllowed: + type: boolean + scalarSources: + type: object + description: Layer that set each scalar setting (null = platform default) + required: [mode, blockPrivateNetworks, blockIpLiterals, logAllowed] + properties: + mode: + oneOf: + - $ref: '#/components/schemas/PlatformPolicyLayer' + - type: 'null' + blockPrivateNetworks: + oneOf: + - $ref: '#/components/schemas/PlatformPolicyLayer' + - type: 'null' + blockIpLiterals: + oneOf: + - $ref: '#/components/schemas/PlatformPolicyLayer' + - type: 'null' + logAllowed: + oneOf: + - $ref: '#/components/schemas/PlatformPolicyLayer' + - type: 'null' + EffectiveMessageFilterRule: + type: object + required: [name, pattern, flags, direction, action, enabled, source] + properties: + name: type: string - format: uuid pattern: type: string - regexFlags: + flags: type: string - description: Flags passed to JavaScript RegExp (subset g,i,m,s,u) + description: Flags passed to JavaScript RegExp (subset g,i,m,s,u,y,d) direction: type: string enum: [incoming, outgoing, bidirectional] - filterType: - type: string - enum: [none, filter, drop] - replaceContent: + action: type: string - description: Replacement template when filterType is filter ($1-style backrefs supported) - priority: - type: integer - description: Lower values run first - createdAt: + enum: [drop, replace, flag] + replacement: type: string + description: Replacement template when action is replace + enabled: + type: boolean + source: + $ref: '#/components/schemas/PlatformPolicyLayer' + SyncAgentPlatformPolicyDto: + type: object + description: | + Effective platform security policy resolved by the controller (global -> workspace -> environment). + Re-normalized on receipt. + properties: + egress: + $ref: '#/components/schemas/EffectiveEgressPolicy' + messageFilters: + type: array + items: + $ref: '#/components/schemas/EffectiveMessageFilterRule' + AgentEgressGuardRuntimeDto: + type: object + required: [running, startedAt, activeRevision, proxyRunning, dnsRunning] + properties: + running: + type: boolean + startedAt: + type: [string, 'null'] format: date-time - updatedAt: + activeRevision: + type: [string, 'null'] + description: Policy revision the guard reports as active + proxyRunning: + type: boolean + dnsRunning: + type: boolean + AgentEgressStatusDto: + type: object + required: + [status, mode, guardContainerId, policyRevision, appliedAt, lastError] + properties: + status: type: string + enum: [inactive, active, failed] + description: '`inactive` (no enforcement), `active` (guard applied), `failed` (last apply failed; previous bundle kept)' + mode: + $ref: '#/components/schemas/EgressMode' + guardContainerId: + type: [string, 'null'] + description: Short guard container id (null when the agent never required egress filtering) + policyRevision: + type: [string, 'null'] + appliedAt: + type: [string, 'null'] format: date-time - CreateRegexFilterRuleDto: + lastError: + type: [string, 'null'] + guard: + $ref: '#/components/schemas/AgentEgressGuardRuntimeDto' + AgentEgressEventDto: type: object - required: [pattern, direction, filterType] + required: + [ + seq, + agentId, + kind, + decision, + host, + rule, + matchedEntry, + matchedSource, + method, + httpStatus, + sampleUrl, + count, + bytesIn, + bytesOut, + firstSeenAt, + lastSeenAt, + mode, + policyRevision, + message, + ] properties: - pattern: - type: string - regexFlags: + seq: type: string - default: g - direction: + description: Monotonic cursor (stringified bigint) + agentId: type: string - enum: [incoming, outgoing, bidirectional] - filterType: + format: uuid + kind: type: string - enum: [none, filter, drop] - replaceContent: + enum: [web, dns, network, guard] + decision: type: string - description: Required when filterType is filter - priority: + enum: [allowed, blocked, observed, error] + host: + type: [string, 'null'] + rule: + oneOf: + - $ref: '#/components/schemas/EgressRule' + - type: 'null' + matchedEntry: + type: [string, 'null'] + matchedSource: + oneOf: + - $ref: '#/components/schemas/PlatformPolicyLayer' + - type: 'null' + method: + type: [string, 'null'] + httpStatus: + type: [integer, 'null'] + sampleUrl: + type: [string, 'null'] + count: type: integer - minimum: 0 - UpdateRegexFilterRuleDto: - type: object - properties: - pattern: - type: string - regexFlags: + bytesIn: + type: integer + bytesOut: + type: integer + firstSeenAt: type: string - direction: + format: date-time + lastSeenAt: type: string - enum: [incoming, outgoing, bidirectional] - filterType: + format: date-time + mode: + type: [string, 'null'] + policyRevision: + type: [string, 'null'] + message: + type: [string, 'null'] + AgentEgressEventsPageDto: + type: object + required: [events, nextSeq, hasMore] + properties: + events: + type: array + items: + $ref: '#/components/schemas/AgentEgressEventDto' + nextSeq: type: string - enum: [none, filter, drop] - replaceContent: + description: Cursor to pass as `afterSeq` for the next page (unchanged when no events were returned) + hasMore: + type: boolean + AgentEgressDetailsDto: + type: object + required: [agentId, status, policy, messageFilters, recentEvents] + properties: + agentId: type: string - priority: - type: integer - minimum: 0 + format: uuid + status: + $ref: '#/components/schemas/AgentEgressStatusDto' + policy: + oneOf: + - $ref: '#/components/schemas/EffectiveEgressPolicy' + - type: 'null' + messageFilters: + type: array + items: + $ref: '#/components/schemas/EffectiveMessageFilterRule' + recentEvents: + type: array + items: + $ref: '#/components/schemas/AgentEgressEventDto' OpencodeMcpServerStatusDto: type: object required: [name, status] diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts index a3b5b7208..60f11c637 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/dto/agent-egress.dto'; export * from './lib/dto/agent-models-response.dto'; export * from './lib/dto/agent-response.dto'; export * from './lib/dto/commit.dto'; @@ -5,7 +6,6 @@ export * from './lib/dto/config-response.dto'; export * from './lib/dto/create-agent-response.dto'; export * from './lib/dto/create-agent.dto'; export * from './lib/dto/create-branch.dto'; -export * from './lib/dto/create-regex-filter-rule.dto'; export * from './lib/dto/create-chat-session.dto'; export * from './lib/dto/create-environment-variable.dto'; export * from './lib/dto/create-file.dto'; @@ -30,15 +30,14 @@ export * from './lib/dto/resolve-conflict.dto'; export * from './lib/dto/stage-files.dto'; export * from './lib/dto/unstage-files.dto'; export * from './lib/dto/update-agent.dto'; -export * from './lib/dto/update-regex-filter-rule.dto'; export * from './lib/dto/update-chat-session.dto'; export * from './lib/dto/update-environment-variable.dto'; export * from './lib/dto/create-vnc-session-response.dto'; export * from './lib/dto/upsert-workspace-configuration-override.dto'; export * from './lib/dto/workspace-configuration-setting-response.dto'; -export * from './lib/dto/regex-filter-rule-response.dto'; export * from './lib/utils/agent-file-type'; export * from './lib/entities/agent-chat-session.entity'; +export * from './lib/entities/agent-egress-event.entity'; export * from './lib/entities/agent-environment-variable.entity'; export * from './lib/entities/agent-message.entity'; export * from './lib/entities/agent-message-event.entity'; @@ -46,7 +45,6 @@ export * from './lib/entities/agent.entity'; export * from './lib/entities/agent-directory-index.entity'; export * from './lib/entities/deployment-configuration.entity'; export * from './lib/entities/deployment-run.entity'; -export * from './lib/entities/regex-filter-rule.entity'; export * from './lib/entities/workspace-configuration-override.entity'; export * from './lib/gateways/agents.gateway'; export * from './lib/gateways/vnc.gateway'; @@ -68,8 +66,8 @@ export * from './lib/services/vnc-sessions.service'; export * from './lib/services/vnc-ticket.service'; export * from './lib/services/workspace-configuration-overrides.service'; export * from './lib/utils/agent-file-manager-context'; -export * from './lib/utils/regex-filter-rule.utils'; export * from './lib/constants/chat-session.constants'; +export * from './lib/constants/egress-guard.constants'; export * from './lib/constants/environment-progress.constants'; export * from './lib/constants/git-repository-setup-mode'; export * from './lib/constants/vnc.constants'; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/egress-guard.constants.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/egress-guard.constants.ts new file mode 100644 index 000000000..a5ee4716a --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/egress-guard.constants.ts @@ -0,0 +1,94 @@ +/** + * Egress guard sidecar contract (must match `apps/agenstra/backend-agent-manager/egress-guard/*`). + * + * The guard owns the network namespace shared with its agent (`NetworkMode: container:`). + * Only the guard has CAP_NET_ADMIN. Squid marks its upstream sockets (SO_MARK, which requires + * CAP_NET_ADMIN and therefore cannot be forged by the agent) and netfilter exempts only marked + * traffic from redirection. dnsmasq runs as a dedicated UID that may only reach the upstream + * resolvers on port 53 (UIDs alone are not trusted: a root agent can switch to any UID). + */ +export const EGRESS_GUARD_DEFAULT_IMAGE = 'ghcr.io/forepath/agenstra-manager-egress-guard:latest'; + +/** Container label marking guard sidecars (value `1`). */ +export const EGRESS_GUARD_LABEL = 'io.agenstra.egress-guard'; +/** Container label carrying the owning agent id. */ +export const EGRESS_GUARD_AGENT_LABEL = 'io.agenstra.agent-id'; +export const EGRESS_GUARD_NAME_PREFIX = 'agenstra-egress-'; + +export const EGRESS_GUARD_PROXY_UID = 10901; +export const EGRESS_GUARD_DNS_UID = 10902; +/** Netfilter mark set by Squid on its upstream connections (`tcp_outgoing_mark`). */ +export const EGRESS_GUARD_PROXY_MARK = '0x4147'; +/** Guard-managed chain listing the upstream DNS resolvers dnsmasq may reach (filled at guard boot). */ +export const EGRESS_GUARD_RESOLVERS_CHAIN = 'AGENSTRA_EGRESS_RESOLVERS'; + +/** Explicit forward proxy (HTTP_PROXY / HTTPS_PROXY), loopback only. */ +export const EGRESS_GUARD_EXPLICIT_PROXY_PORT = 3128; +/** Transparent plaintext HTTP interception listener. */ +export const EGRESS_GUARD_HTTP_INTERCEPT_PORT = 3129; +/** Transparent TLS peek / splice / terminate listener (never decrypts). */ +export const EGRESS_GUARD_HTTPS_INTERCEPT_PORT = 3130; +/** DNS policy proxy (dnsmasq), loopback only. */ +export const EGRESS_GUARD_DNS_PORT = 5353; + +/** Guard state directory (root-only): staged / active / previous policy bundles. */ +export const EGRESS_GUARD_STATE_DIR = '/var/lib/agenstra-egress'; +export const EGRESS_GUARD_ACTIVE_DIR = `${EGRESS_GUARD_STATE_DIR}/active`; +export const EGRESS_GUARD_STAGED_DIR_NAME = 'staged'; +export const EGRESS_GUARD_CLI = '/usr/local/bin/agenstra-egress'; + +/** Log line markers emitted on the guard's stdout (parsed by the egress events collector). */ +export const EGRESS_GUARD_WEB_LOG_MARKER = 'AGENSTRA_EGRESS'; +export const EGRESS_GUARD_EVENT_LOG_MARKER = 'AGENSTRA_GUARD'; + +/** Netfilter rule comment prefix for counted (blocked / observed) non-web categories. */ +export const EGRESS_GUARD_COUNTER_COMMENT_PREFIX = 'agenstra:'; + +/** Explicit proxy URL injected into the agent environment while a guard is active. */ +export const EGRESS_GUARD_PROXY_URL = `http://127.0.0.1:${EGRESS_GUARD_EXPLICIT_PROXY_PORT}`; +export const EGRESS_GUARD_NO_PROXY_DEFAULTS = ['localhost', '127.0.0.1', '::1'] as const; + +/** Proxy env keys managed while a guard is active (upper- and lower-case variants). */ +export const EGRESS_GUARD_PROXY_ENV_KEYS = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', +] as const; + +/** Agent ports published / reached through the guard (OpenCode serve, VNC websockify). */ +export const EGRESS_GUARD_FORWARDED_AGENT_PORTS = [4096, 6080] as const; + +export const EGRESS_GUARD_READY_TIMEOUT_MS = 30_000; +export const EGRESS_GUARD_DEFAULT_MEMORY_MB = 256; +export const EGRESS_EVENTS_DEFAULT_POLL_INTERVAL_MS = 15_000; +export const EGRESS_EVENTS_DEFAULT_RETENTION_HOURS = 72; +export const EGRESS_EVENTS_MAX_PAGE_SIZE = 1000; + +export function resolveEgressGuardImage(env: NodeJS.ProcessEnv = process.env): string { + return env['AGENT_EGRESS_GUARD_IMAGE']?.trim() || EGRESS_GUARD_DEFAULT_IMAGE; +} + +export function egressGuardContainerName(agentId: string): string { + return `${EGRESS_GUARD_NAME_PREFIX}${agentId}`; +} + +function readPositiveInt(raw: string | undefined, fallback: number): number { + const parsed = raw ? Number.parseInt(raw, 10) : NaN; + + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function resolveEgressGuardMemoryBytes(env: NodeJS.ProcessEnv = process.env): number { + return readPositiveInt(env['AGENT_EGRESS_GUARD_MEMORY_MB'], EGRESS_GUARD_DEFAULT_MEMORY_MB) * 1024 * 1024; +} + +export function resolveEgressEventsPollIntervalMs(env: NodeJS.ProcessEnv = process.env): number { + return readPositiveInt(env['AGENT_EGRESS_EVENTS_POLL_INTERVAL_MS'], EGRESS_EVENTS_DEFAULT_POLL_INTERVAL_MS); +} + +export function resolveEgressEventsRetentionHours(env: NodeJS.ProcessEnv = process.env): number { + return readPositiveInt(env['AGENT_EGRESS_EVENTS_RETENTION_HOURS'], EGRESS_EVENTS_DEFAULT_RETENTION_HOURS); +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.spec.ts new file mode 100644 index 000000000..b445b91db --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.spec.ts @@ -0,0 +1,87 @@ +import { BadRequestException } from '@nestjs/common'; + +import { AgentsEgressController } from './agents-egress.controller'; + +describe('AgentsEgressController', () => { + const row = { + seq: '7', + agentId: 'agent-1', + kind: 'web', + decision: 'blocked', + host: 'example.org', + rule: 'deny_host', + matchedEntry: 'example.org', + matchedSource: 'global', + method: 'CONNECT', + httpStatus: null, + sampleUrl: null, + count: 2, + bytesIn: '10', + bytesOut: '20', + firstSeenAt: new Date(1), + lastSeenAt: new Date(2), + mode: 'enforce', + policyRevision: 'rev', + message: null, + }; + let agents: Record; + let events: Record; + let egress: Record; + let controller: AgentsEgressController; + + beforeEach(() => { + agents = { + findByIdOrThrow: jest.fn().mockResolvedValue({ + id: 'agent-1', + egressPolicy: { mode: 'enforce' }, + messageFilters: [{ name: 'A', pattern: 'a', action: 'drop', source: 'global' }], + }), + }; + events = { + findAfter: jest.fn().mockResolvedValue([row, { ...row, seq: '8' }]), + findRecentForAgent: jest.fn().mockResolvedValue([row]), + }; + egress = { describe: jest.fn().mockResolvedValue({ status: 'active', mode: 'enforce' }) }; + controller = new AgentsEgressController(agents as never, events as never, egress as never); + }); + + it('pages events after a cursor', async () => { + const page = await controller.listEvents('5', 1); + + expect(events.findAfter).toHaveBeenCalledWith('5', 2, undefined); + expect(page).toEqual({ + events: [expect.objectContaining({ seq: '7', bytesIn: 10, bytesOut: 20, count: 2 })], + nextSeq: '7', + hasMore: true, + }); + }); + + it('defaults and clamps paging parameters', async () => { + events.findAfter.mockResolvedValue([]); + + await expect(controller.listEvents(undefined, 999999)).resolves.toEqual({ + events: [], + nextSeq: '0', + hasMore: false, + }); + expect(events.findAfter).toHaveBeenCalledWith('0', 1001, undefined); + }); + + it('rejects invalid cursors and agent ids', async () => { + await expect(controller.listEvents('-1')).rejects.toBeInstanceOf(BadRequestException); + await expect(controller.listEvents('1', 10, 'nope')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('returns the agent egress details with live status', async () => { + const details = await controller.getAgentEgress('agent-1'); + + expect(egress.describe).toHaveBeenCalledWith(expect.objectContaining({ id: 'agent-1' }), true); + expect(details).toEqual({ + agentId: 'agent-1', + status: { status: 'active', mode: 'enforce' }, + policy: { mode: 'enforce' }, + messageFilters: [expect.objectContaining({ name: 'A', source: 'global' })], + recentEvents: [expect.objectContaining({ seq: '7' })], + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts new file mode 100644 index 000000000..35db81e92 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-egress.controller.ts @@ -0,0 +1,94 @@ +import { normalizeEffectiveMessageFilters } from '@forepath/agenstra/shared/util-opencode-config'; +import { BadRequestException, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Query } from '@nestjs/common'; + +import { EGRESS_EVENTS_MAX_PAGE_SIZE } from '../constants/egress-guard.constants'; +import { AgentEgressDetailsDto, AgentEgressEventDto, AgentEgressEventsPageDto } from '../dto/agent-egress.dto'; +import { AgentEgressEventEntity } from '../entities/agent-egress-event.entity'; +import { AgentEgressEventsRepository } from '../repositories/agent-egress-events.repository'; +import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentEgressService } from '../services/agent-egress.service'; + +const RECENT_EVENTS_LIMIT = 50; + +function toEventDto(event: AgentEgressEventEntity): AgentEgressEventDto { + return { + seq: String(event.seq), + agentId: event.agentId, + kind: event.kind, + decision: event.decision, + host: event.host ?? null, + rule: event.rule ?? null, + matchedEntry: event.matchedEntry ?? null, + matchedSource: event.matchedSource ?? null, + method: event.method ?? null, + httpStatus: event.httpStatus ?? null, + sampleUrl: event.sampleUrl ?? null, + count: event.count, + bytesIn: Number(event.bytesIn ?? 0), + bytesOut: Number(event.bytesOut ?? 0), + firstSeenAt: event.firstSeenAt, + lastSeenAt: event.lastSeenAt, + mode: event.mode ?? null, + policyRevision: event.policyRevision ?? null, + message: event.message ?? null, + }; +} + +/** + * Egress guard (web traffic filtering) status and decision events. + * `GET /egress/events` is the controller's ingestion feed (monotonic `seq` cursor). + */ +@Controller() +export class AgentsEgressController { + constructor( + private readonly agentsRepository: AgentsRepository, + private readonly eventsRepository: AgentEgressEventsRepository, + private readonly agentEgress: AgentEgressService, + ) {} + + @Get('egress/events') + async listEvents( + @Query('afterSeq') afterSeqRaw?: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('agentId') agentId?: string, + ): Promise { + const afterSeq = afterSeqRaw?.trim() || '0'; + + if (!/^\d{1,19}$/.test(afterSeq)) { + throw new BadRequestException('afterSeq must be a non-negative integer'); + } + + if (agentId !== undefined && !/^[0-9a-f-]{36}$/i.test(agentId)) { + throw new BadRequestException('agentId must be a UUID'); + } + + const take = Math.min(Math.max(limit ?? 500, 1), EGRESS_EVENTS_MAX_PAGE_SIZE); + const rows = await this.eventsRepository.findAfter(afterSeq, take + 1, agentId); + const page = rows.slice(0, take); + + return { + events: page.map(toEventDto), + nextSeq: page.length > 0 ? String(page[page.length - 1].seq) : afterSeq, + hasMore: rows.length > take, + }; + } + + @Get('agents/:id/egress') + async getAgentEgress( + @Param('id', new ParseUUIDPipe({ version: '4' })) agentId: string, + ): Promise { + const agent = await this.agentsRepository.findByIdOrThrow(agentId); + const [status, recent] = await Promise.all([ + this.agentEgress.describe(agent, true), + this.eventsRepository.findRecentForAgent(agentId, RECENT_EVENTS_LIMIT), + ]); + + return { + agentId, + status, + policy: agent.egressPolicy ?? null, + messageFilters: normalizeEffectiveMessageFilters(agent.messageFilters ?? []), + recentEvents: recent.map(toEventDto), + }; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.spec.ts deleted file mode 100644 index 4ee94279e..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { CreateRegexFilterRuleDto } from '../dto/create-regex-filter-rule.dto'; -import { UpdateRegexFilterRuleDto } from '../dto/update-regex-filter-rule.dto'; -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { AgentsFiltersService } from '../services/agents-filters.service'; - -import { AgentsFiltersController } from './agents-filters.controller'; - -describe('AgentsFiltersController', () => { - let controller: AgentsFiltersController; - const mockService = { - list: jest.fn(), - count: jest.fn(), - getById: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - }; - const mockRow: RegexFilterRuleEntity = Object.assign(new RegexFilterRuleEntity(), { - id: '11111111-1111-1111-1111-111111111111', - pattern: 'foo', - regexFlags: 'g', - direction: 'incoming' as const, - filterType: 'none' as const, - replaceContent: null, - priority: 0, - createdAt: new Date('2024-01-01T00:00:00.000Z'), - updatedAt: new Date('2024-01-01T00:00:00.000Z'), - }); - const expectedDto = { - id: mockRow.id, - pattern: mockRow.pattern, - regexFlags: mockRow.regexFlags, - direction: mockRow.direction, - filterType: mockRow.filterType, - replaceContent: mockRow.replaceContent, - priority: mockRow.priority, - createdAt: mockRow.createdAt.toISOString(), - updatedAt: mockRow.updatedAt.toISOString(), - }; - - beforeEach(async () => { - jest.clearAllMocks(); - const module: TestingModule = await Test.createTestingModule({ - controllers: [AgentsFiltersController], - providers: [{ provide: AgentsFiltersService, useValue: mockService }], - }).compile(); - - controller = module.get(AgentsFiltersController); - }); - - describe('list', () => { - it('maps entities to DTOs with default pagination', async () => { - mockService.list.mockResolvedValue([mockRow]); - const result = await controller.list(undefined, undefined); - - expect(mockService.list).toHaveBeenCalledWith(100, 0); - expect(result).toEqual([expectedDto]); - }); - - it('passes custom limit and offset', async () => { - mockService.list.mockResolvedValue([]); - await controller.list(5, 15); - expect(mockService.list).toHaveBeenCalledWith(5, 15); - }); - }); - - describe('count', () => { - it('returns count from service', async () => { - mockService.count.mockResolvedValue(12); - await expect(controller.count()).resolves.toEqual({ count: 12 }); - }); - }); - - describe('getOne', () => { - it('maps entity to DTO', async () => { - mockService.getById.mockResolvedValue(mockRow); - const result = await controller.getOne(mockRow.id); - - expect(result).toEqual(expectedDto); - expect(mockService.getById).toHaveBeenCalledWith(mockRow.id); - }); - }); - - describe('create', () => { - it('maps created entity to DTO', async () => { - const dto: CreateRegexFilterRuleDto = { - pattern: 'a', - direction: 'incoming', - filterType: 'none', - }; - - mockService.create.mockResolvedValue(mockRow); - const result = await controller.create(dto); - - expect(mockService.create).toHaveBeenCalledWith(dto); - expect(result).toEqual(expectedDto); - }); - }); - - describe('update', () => { - it('maps updated entity to DTO', async () => { - const dto: UpdateRegexFilterRuleDto = { priority: 3 }; - - mockService.update.mockResolvedValue(mockRow); - const result = await controller.update(mockRow.id, dto); - - expect(mockService.update).toHaveBeenCalledWith(mockRow.id, dto); - expect(result).toEqual(expectedDto); - }); - }); - - describe('delete', () => { - it('delegates to service', async () => { - mockService.delete.mockResolvedValue(undefined); - await controller.delete(mockRow.id); - expect(mockService.delete).toHaveBeenCalledWith(mockRow.id); - }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts deleted file mode 100644 index 9a85ecfbe..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseIntPipe, - ParseUUIDPipe, - Post, - Put, - Query, -} from '@nestjs/common'; - -import { CreateRegexFilterRuleDto } from '../dto/create-regex-filter-rule.dto'; -import { RegexFilterRuleResponseDto } from '../dto/regex-filter-rule-response.dto'; -import { UpdateRegexFilterRuleDto } from '../dto/update-regex-filter-rule.dto'; -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { AgentsFiltersService } from '../services/agents-filters.service'; - -/** - * HTTP CRUD for regex-based chat filter rules. - */ -@Controller('agents-filters') -export class AgentsFiltersController { - constructor(private readonly agentsFiltersService: AgentsFiltersService) {} - - @Get() - async list( - @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, - @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, - ): Promise { - const rows = await this.agentsFiltersService.list(limit ?? 100, offset ?? 0); - - return rows.map((r) => this.toDto(r)); - } - - @Get('count') - async count(): Promise<{ count: number }> { - const count = await this.agentsFiltersService.count(); - - return { count }; - } - - @Get(':id') - async getOne(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { - const row = await this.agentsFiltersService.getById(id); - - return this.toDto(row); - } - - @Post() - @HttpCode(HttpStatus.CREATED) - async create(@Body() dto: CreateRegexFilterRuleDto): Promise { - const row = await this.agentsFiltersService.create(dto); - - return this.toDto(row); - } - - @Put(':id') - async update( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateRegexFilterRuleDto, - ): Promise { - const row = await this.agentsFiltersService.update(id, dto); - - return this.toDto(row); - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - async delete(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { - await this.agentsFiltersService.delete(id); - } - - private toDto(row: RegexFilterRuleEntity): RegexFilterRuleResponseDto { - return { - id: row.id, - pattern: row.pattern, - regexFlags: row.regexFlags, - direction: row.direction, - filterType: row.filterType, - replaceContent: row.replaceContent, - priority: row.priority, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-opencode-config.controller.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-opencode-config.controller.ts index aee428d6f..a19856487 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-opencode-config.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-opencode-config.controller.ts @@ -33,7 +33,7 @@ export class AgentsOpencodeConfigController { @Param('id', new ParseUUIDPipe({ version: '4' })) agentId: string, @Body() dto: SyncAgentOpencodeConfigDto, ): Promise<{ ok: boolean; defer?: boolean; error?: string }> { - return await this.syncService.syncEffective(agentId, dto.config, dto.secrets ?? {}); + return await this.syncService.syncEffective(agentId, dto.config, dto.secrets ?? {}, dto.platformPolicy); } @Get('agents') diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-egress.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-egress.dto.ts new file mode 100644 index 000000000..bec40eab1 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-egress.dto.ts @@ -0,0 +1,72 @@ +import type { + EffectiveEgressPolicy, + EffectiveMessageFilterRule, + EgressDecision, + EgressEventKind, + EgressRule, + PlatformPolicyLayer, +} from '@forepath/agenstra/shared/util-opencode-config'; + +import type { AgentEgressStatus } from '../entities/agent.entity'; + +/** Live guard container details (only on the dedicated egress endpoint). */ +export class AgentEgressGuardRuntimeDto { + running!: boolean; + startedAt!: Date | null; + /** Policy revision the guard reports as active. */ + activeRevision!: string | null; + proxyRunning!: boolean; + dnsRunning!: boolean; +} + +/** Egress guard status of one agent environment. */ +export class AgentEgressStatusDto { + status!: AgentEgressStatus; + mode!: EffectiveEgressPolicy['mode']; + /** Short guard container id (null when the agent never required egress filtering). */ + guardContainerId!: string | null; + policyRevision!: string | null; + appliedAt!: Date | null; + lastError!: string | null; + guard?: AgentEgressGuardRuntimeDto; +} + +/** Full egress view of one agent: status plus the effective policy and message filters in force. */ +export class AgentEgressDetailsDto { + agentId!: string; + status!: AgentEgressStatusDto; + policy!: EffectiveEgressPolicy | null; + messageFilters!: EffectiveMessageFilterRule[]; + recentEvents!: AgentEgressEventDto[]; +} + +/** One aggregated guard decision (see `agent_egress_events`). */ +export class AgentEgressEventDto { + /** Monotonic cursor (stringified bigint). */ + seq!: string; + agentId!: string; + kind!: EgressEventKind; + decision!: EgressDecision; + host!: string | null; + rule!: EgressRule | null; + matchedEntry!: string | null; + matchedSource!: PlatformPolicyLayer | null; + method!: string | null; + httpStatus!: number | null; + sampleUrl!: string | null; + count!: number; + bytesIn!: number; + bytesOut!: number; + firstSeenAt!: Date; + lastSeenAt!: Date; + mode!: string | null; + policyRevision!: string | null; + message!: string | null; +} + +export class AgentEgressEventsPageDto { + events!: AgentEgressEventDto[]; + /** Cursor to pass as `afterSeq` for the next page (unchanged when no events were returned). */ + nextSeq!: string; + hasMore!: boolean; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-opencode-config.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-opencode-config.dto.ts index c5d747b74..cdd27eaba 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-opencode-config.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-opencode-config.dto.ts @@ -1,4 +1,7 @@ -import { IsObject, IsOptional, IsString, MinLength } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsObject, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; + +import type { AgentEgressStatusDto } from './agent-egress.dto'; export class UpsertAgentOpencodeConfigDto { @IsOptional() @@ -19,6 +22,22 @@ export class AgentOpencodeConfigResponseDto { overrides!: Record; secretKeys!: string[]; updatedAt?: Date; + /** Egress guard status of this environment (platform security policy). */ + egress?: AgentEgressStatusDto; +} + +/** + * Effective platform security policy resolved by the controller (global → workspace → environment). + * Re-normalized on receipt (see `normalizeEffectiveEgressPolicy` / `normalizeEffectiveMessageFilters`). + */ +export class SyncAgentPlatformPolicyDto { + @IsOptional() + @IsObject() + egress?: Record; + + @IsOptional() + @IsArray() + messageFilters?: unknown[]; } export class SyncAgentOpencodeConfigDto { @@ -28,6 +47,12 @@ export class SyncAgentOpencodeConfigDto { @IsOptional() @IsObject() secrets?: Record; + + /** Omitted by older controllers: the stored platform policy stays in force. */ + @IsOptional() + @ValidateNested() + @Type(() => SyncAgentPlatformPolicyDto) + platformPolicy?: SyncAgentPlatformPolicyDto; } export class OpencodeAgentInfoDto { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts index 83210d15d..f87bb1ee2 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts @@ -1,6 +1,7 @@ import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { ContainerType } from '../entities/agent.entity'; +import type { AgentEgressStatusDto } from './agent-egress.dto'; import type { AgentChatSessionSummaryDto } from './chat-session-response.dto'; import type { AgentTypeCapabilities } from './config-response.dto'; @@ -30,6 +31,10 @@ export class AgentResponseDto { * Id of the primary chat session for this environment. */ primaryChatId!: string; + /** + * Egress guard (web traffic filtering) status of this environment. + */ + egress?: AgentEgressStatusDto; createdAt!: Date; updatedAt!: Date; } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts index b58b7c51b..5beb57150 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsIn, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { ContainerType } from '../entities/agent.entity'; @@ -35,6 +35,15 @@ export class CreateAgentDto { @IsString({ message: 'Git repository URL must be a string' }) gitRepositoryUrl?: string; + /** + * Effective egress policy (set by the agent controller from the layered agent configuration). + * When it requires filtering, the egress guard is provisioned and its policy applied before the + * agent container starts, so the environment never runs without enforcement. + */ + @IsOptional() + @IsObject({ message: 'Egress policy must be an object' }) + egressPolicy?: Record; + /** * CI/CD deployment configuration (optional). * If provided, a deployment configuration will be created for this agent. diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-regex-filter-rule.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-regex-filter-rule.dto.ts deleted file mode 100644 index e73169724..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-regex-filter-rule.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min, ValidateIf } from 'class-validator'; - -const DIRECTIONS = ['incoming', 'outgoing', 'bidirectional'] as const; -const FILTER_TYPES = ['none', 'filter', 'drop'] as const; - -/** - * DTO for creating a regex filter rule. - */ -export class CreateRegexFilterRuleDto { - @IsNotEmpty({ message: 'pattern is required' }) - @IsString() - @MaxLength(20000, { message: 'pattern is too long' }) - pattern!: string; - - @IsOptional() - @IsString() - @MaxLength(16) - regexFlags?: string; - - @IsIn(DIRECTIONS, { message: 'direction must be incoming, outgoing, or bidirectional' }) - direction!: (typeof DIRECTIONS)[number]; - - @IsIn(FILTER_TYPES, { message: 'filterType must be none, filter, or drop' }) - filterType!: (typeof FILTER_TYPES)[number]; - - @ValidateIf((o: CreateRegexFilterRuleDto) => o.filterType === 'filter') - @IsNotEmpty({ message: 'replaceContent is required when filterType is filter' }) - @IsString() - @MaxLength(20000) - replaceContent?: string; - - @IsOptional() - @IsInt() - @Min(0) - priority?: number; -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/regex-filter-rule-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/regex-filter-rule-response.dto.ts deleted file mode 100644 index eb342cb28..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/regex-filter-rule-response.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { RegexFilterRuleDirection, RegexFilterRuleType } from '../entities/regex-filter-rule.entity'; - -/** - * API response for a regex filter rule. - */ -export class RegexFilterRuleResponseDto { - id!: string; - pattern!: string; - regexFlags!: string; - direction!: RegexFilterRuleDirection; - filterType!: RegexFilterRuleType; - replaceContent?: string | null; - priority!: number; - createdAt!: string; - updatedAt!: string; -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-regex-filter-rule.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-regex-filter-rule.dto.ts deleted file mode 100644 index 0963348e9..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-regex-filter-rule.dto.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; - -const DIRECTIONS = ['incoming', 'outgoing', 'bidirectional'] as const; -const FILTER_TYPES = ['none', 'filter', 'drop'] as const; - -/** - * DTO for updating a regex filter rule (all fields optional). - */ -export class UpdateRegexFilterRuleDto { - @IsOptional() - @IsString() - @MaxLength(20000) - pattern?: string; - - @IsOptional() - @IsString() - @MaxLength(16) - regexFlags?: string; - - @IsOptional() - @IsIn(DIRECTIONS) - direction?: (typeof DIRECTIONS)[number]; - - @IsOptional() - @IsIn(FILTER_TYPES) - filterType?: (typeof FILTER_TYPES)[number]; - - @IsOptional() - @IsString() - @MaxLength(20000) - replaceContent?: string | null; - - @IsOptional() - @IsInt() - @Min(0) - priority?: number; -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-egress-event.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-egress-event.entity.ts new file mode 100644 index 000000000..18bc90825 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-egress-event.entity.ts @@ -0,0 +1,80 @@ +import type { + EgressDecision, + EgressEventKind, + EgressRule, + PlatformPolicyLayer, +} from '@forepath/agenstra/shared/util-opencode-config'; +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * Aggregated egress guard decisions (one row per distinct decision per collection cycle). + * Rows are pulled by the agent controller via a monotonic `seq` cursor and pruned after retention. + * No foreign key to `agents`: decisions of deleted environments must remain collectable for audit. + */ +@Entity('agent_egress_events') +@Index('IDX_agent_egress_events_agent_last_seen', ['agentId', 'lastSeenAt']) +@Index('IDX_agent_egress_events_created_at', ['createdAt']) +export class AgentEgressEventEntity { + /** Monotonic cursor (bigint; TypeORM returns it as a string). */ + @PrimaryGeneratedColumn('increment', { type: 'bigint', name: 'seq' }) + seq!: string; + + @Column({ type: 'uuid', name: 'agent_id' }) + agentId!: string; + + @Column({ type: 'varchar', length: 16, name: 'kind' }) + kind!: EgressEventKind; + + @Column({ type: 'varchar', length: 16, name: 'decision' }) + decision!: EgressDecision; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'host' }) + host?: string | null; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'rule' }) + rule?: EgressRule | null; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'matched_entry' }) + matchedEntry?: string | null; + + @Column({ type: 'varchar', length: 16, nullable: true, name: 'matched_source' }) + matchedSource?: PlatformPolicyLayer | null; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'method' }) + method?: string | null; + + @Column({ type: 'int', nullable: true, name: 'http_status' }) + httpStatus?: number | null; + + /** Last observed URL (plain HTTP only; query strings / credentials stripped). */ + @Column({ type: 'text', nullable: true, name: 'sample_url' }) + sampleUrl?: string | null; + + @Column({ type: 'int', default: 1, name: 'count' }) + count!: number; + + @Column({ type: 'bigint', default: 0, name: 'bytes_in' }) + bytesIn!: string; + + @Column({ type: 'bigint', default: 0, name: 'bytes_out' }) + bytesOut!: string; + + @Column({ type: 'timestamptz', name: 'first_seen_at' }) + firstSeenAt!: Date; + + @Column({ type: 'timestamptz', name: 'last_seen_at' }) + lastSeenAt!: Date; + + @Column({ type: 'varchar', length: 16, nullable: true, name: 'mode' }) + mode?: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true, name: 'policy_revision' }) + policyRevision?: string | null; + + /** Guard failure details (kind `guard`). */ + @Column({ type: 'text', nullable: true, name: 'message' }) + message?: string | null; + + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) + createdAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts index 72f343bcb..3cb9dcf26 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts @@ -1,8 +1,11 @@ +import type { EffectiveEgressPolicy, EffectiveMessageFilterRule } from '@forepath/agenstra/shared/util-opencode-config'; import { createAes256GcmTransformer } from '@forepath/shared/backend'; import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; +export type AgentEgressStatus = 'inactive' | 'active' | 'failed'; + /** * Container type enum representing the type of container. * This is used to identify what type of content the container contains. @@ -106,6 +109,39 @@ export class AgentEntity { @Column({ type: 'varchar', length: 255, nullable: true, name: 'acp_session_container_id' }) acpSessionContainerId?: string | null; + /** + * Egress guard sidecar owning this agent's network namespace (`NetworkMode: container:`). + * `null` when the agent never required egress filtering (legacy network topology). + */ + @Column({ type: 'varchar', length: 255, nullable: true, name: 'egress_guard_container_id' }) + egressGuardContainerId?: string | null; + + /** Effective egress policy last received from the controller (layered agent configuration). */ + @Column({ type: 'jsonb', nullable: true, name: 'egress_policy' }) + egressPolicy?: EffectiveEgressPolicy | null; + + /** Revision of the guard policy bundle last applied successfully. */ + @Column({ type: 'varchar', length: 64, nullable: true, name: 'egress_policy_revision' }) + egressPolicyRevision?: string | null; + + /** `inactive` (no enforcement) | `active` (guard applied) | `failed` (last apply failed; previous bundle kept). */ + @Column({ type: 'varchar', length: 16, nullable: true, name: 'egress_status' }) + egressStatus?: AgentEgressStatus | null; + + @Column({ type: 'text', nullable: true, name: 'egress_last_error' }) + egressLastError?: string | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'egress_applied_at' }) + egressAppliedAt?: Date | null; + + /** Docker log timestamp (RFC 3339 nano) up to which guard logs were collected. */ + @Column({ type: 'varchar', length: 64, nullable: true, name: 'egress_log_cursor' }) + egressLogCursor?: string | null; + + /** Effective chat message filter rules (global → workspace → environment) last received from the controller. */ + @Column({ type: 'jsonb', nullable: true, name: 'message_filters' }) + messageFilters?: EffectiveMessageFilterRule[] | null; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.spec.ts deleted file mode 100644 index 3a294f7fd..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { RegexFilterRuleEntity } from './regex-filter-rule.entity'; - -describe('RegexFilterRuleEntity', () => { - it('creates an instance', () => { - expect(new RegexFilterRuleEntity()).toBeDefined(); - }); - - it('supports persisted fields', () => { - const row = new RegexFilterRuleEntity(); - - row.id = 'rule-uuid'; - row.pattern = '\\d+'; - row.regexFlags = 'g'; - row.direction = 'outgoing'; - row.filterType = 'drop'; - row.replaceContent = null; - row.priority = 1; - row.createdAt = new Date('2024-01-01'); - row.updatedAt = new Date('2024-01-02'); - - expect(row.pattern).toBe('\\d+'); - expect(row.direction).toBe('outgoing'); - expect(row.filterType).toBe('drop'); - expect(row.replaceContent).toBeNull(); - expect(row.priority).toBe(1); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.ts deleted file mode 100644 index ba774c986..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/regex-filter-rule.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; - -/** Direction of messages this rule applies to (matches FilterDirection). */ -export type RegexFilterRuleDirection = 'incoming' | 'outgoing' | 'bidirectional'; - -/** What to do when the pattern matches. */ -export type RegexFilterRuleType = 'none' | 'filter' | 'drop'; - -/** - * Persisted regex chat filter rule for agent-manager. - */ -@Entity('regex_filter_rules') -export class RegexFilterRuleEntity { - @PrimaryGeneratedColumn('uuid', { name: 'id' }) - id!: string; - - @Column({ type: 'text', name: 'pattern' }) - pattern!: string; - - /** Allowed: g, i, m, s, u only (passed to RegExp). */ - @Column({ type: 'varchar', length: 16, name: 'regex_flags', default: 'g' }) - regexFlags!: string; - - @Column({ type: 'varchar', length: 32, name: 'direction' }) - direction!: RegexFilterRuleDirection; - - @Column({ type: 'varchar', length: 16, name: 'filter_type' }) - filterType!: RegexFilterRuleType; - - @Column({ type: 'text', name: 'replace_content', nullable: true }) - replaceContent?: string | null; - - @Column({ type: 'int', name: 'priority', default: 0 }) - priority!: number; - - @CreateDateColumn({ name: 'created_at' }) - createdAt!: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt!: Date; -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 937ccc61d..7fd43dae0 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -31,6 +31,7 @@ import { FilterDirection, } from '../providers/chat-filter.interface'; import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentEgressService } from '../services/agent-egress.service'; import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; import { AgentGitStateBroadcastService } from '../services/agent-git-state-broadcast.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; @@ -350,12 +351,16 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, private readonly workspaceChangeNotifier: WorkspaceChangeNotifierService, @Optional() private readonly environmentProgress?: EnvironmentProgressService, @Optional() private readonly openCodeConfigSync?: OpenCodeConfigSyncService, + @Optional() private readonly agentEgress?: AgentEgressService, ) {} onModuleInit(): void { this.openCodeConfigSync?.registerConfigSyncedBroadcaster((agentId) => { this.broadcastToAllSockets('opencodeConfigSynced', createSuccessResponse({ agentId })); }); + this.agentEgress?.registerStatusChangedBroadcaster((agentId) => { + void this.broadcastEgressStatusChanged(agentId); + }); this.gitStateBroadcast.registerBroadcaster((agentId) => this.broadcastGitStateChanged(agentId)); this.workspaceChangeNotifier.registerIndexBroadcaster((agentId, event, data) => { this.broadcastToAgent(agentId, event, createSuccessResponse(data)); @@ -3191,6 +3196,29 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, } } + /** Push the egress guard status of an agent to its viewers (`egressStatusChanged`). */ + private async broadcastEgressStatusChanged(agentId: string): Promise { + if (!this.agentEgress) { + return; + } + + try { + const agent = await this.agentsRepository.findById(agentId); + + if (!agent) { + return; + } + + this.broadcastToAgent( + agentId, + 'egressStatusChanged', + createSuccessResponse({ agentId, ...(await this.agentEgress.describe(agent)) }), + ); + } catch (error: unknown) { + this.logger.debug(`Failed to broadcast egress status for ${agentId}: ${(error as Error).message}`); + } + } + /** * Apply filters to a message based on direction. * This is the single hook point for filtering messages. diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts index 37d740305..bf0b7b181 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts @@ -14,16 +14,16 @@ import { AgentMessageEntity } from '../entities/agent-message.entity'; import { AgentEntity } from '../entities/agent.entity'; import { DeploymentConfigurationEntity } from '../entities/deployment-configuration.entity'; import { DeploymentRunEntity } from '../entities/deployment-run.entity'; -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; +import { AgentEgressEventEntity } from '../entities/agent-egress-event.entity'; import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-configuration-override.entity'; import { AgentsGateway } from '../gateways/agents.gateway'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { OpenCodeAgentProvider } from '../providers/agents/opencode-agent.provider'; import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { BidirectionalChatFilter } from '../providers/filters/bidirectional-chat-filter'; -import { DatabaseRegexIncomingChatFilter } from '../providers/filters/database-regex-incoming-chat-filter'; -import { DatabaseRegexOutgoingChatFilter } from '../providers/filters/database-regex-outgoing-chat-filter'; import { IncomingChatFilter } from '../providers/filters/incoming-chat-filter'; +import { MessageFiltersIncomingChatFilter } from '../providers/filters/message-filters-incoming-chat-filter'; +import { MessageFiltersOutgoingChatFilter } from '../providers/filters/message-filters-outgoing-chat-filter'; import { NoopChatFilter } from '../providers/filters/noop-chat-filter'; import { OutgoingChatFilter } from '../providers/filters/outgoing-chat-filter'; import { PipelineProviderFactory } from '../providers/pipeline-provider.factory'; @@ -95,7 +95,7 @@ describe('AgentsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(DeploymentRunEntity)) .useValue(mockRepository) - .overrideProvider(getRepositoryToken(RegexFilterRuleEntity)) + .overrideProvider(getRepositoryToken(AgentEgressEventEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(WorkspaceConfigurationOverrideEntity)) .useValue(mockRepository) @@ -291,13 +291,14 @@ describe('AgentsModule', () => { expect(factory.getFilter('bidirectional-example')).toBe(bidirectionalFilter); expect(bidirectionalFilter.getType()).toBe('bidirectional-example'); - const dbIn = module.get(DatabaseRegexIncomingChatFilter); - const dbOut = module.get(DatabaseRegexOutgoingChatFilter); + const messageFiltersIn = module.get(MessageFiltersIncomingChatFilter); + const messageFiltersOut = module.get(MessageFiltersOutgoingChatFilter); - expect(factory.hasFilter('database-regex-incoming')).toBe(true); - expect(factory.getFilter('database-regex-incoming')).toBe(dbIn); - expect(factory.hasFilter('database-regex-outgoing')).toBe(true); - expect(factory.getFilter('database-regex-outgoing')).toBe(dbOut); + expect(factory.hasFilter('message-filters-incoming')).toBe(true); + expect(factory.getFilter('message-filters-incoming')).toBe(messageFiltersIn); + expect(factory.hasFilter('message-filters-outgoing')).toBe(true); + expect(factory.getFilter('message-filters-outgoing')).toBe(messageFiltersOut); + expect(factory.hasFilter('database-regex-incoming')).toBe(false); }); it('should initialize CHAT_FILTER_INIT factory', () => { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts index 42540d65c..1f6cbe986 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -11,7 +11,7 @@ import { AgentsDeploymentsController } from '../controllers/agents-deployments.c import { InstanceStatusController } from '../controllers/instance-status.controller'; import { AgentsEnvironmentVariablesController } from '../controllers/agents-environment-variables.controller'; import { AgentsFilesController } from '../controllers/agents-files.controller'; -import { AgentsFiltersController } from '../controllers/agents-filters.controller'; +import { AgentsEgressController } from '../controllers/agents-egress.controller'; import { AgentsMessagesController } from '../controllers/agents-messages.controller'; import { AgentsVcsController } from '../controllers/agents-vcs.controller'; import { AgentsVerificationController } from '../controllers/agents-verification.controller'; @@ -31,7 +31,7 @@ import { AgentEntity } from '../entities/agent.entity'; import { AgentDirectoryIndexEntity } from '../entities/agent-directory-index.entity'; import { DeploymentConfigurationEntity } from '../entities/deployment-configuration.entity'; import { DeploymentRunEntity } from '../entities/deployment-run.entity'; -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; +import { AgentEgressEventEntity } from '../entities/agent-egress-event.entity'; import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-configuration-override.entity'; import { AgentsGateway } from '../gateways/agents.gateway'; import { VncGateway } from '../gateways/vnc.gateway'; @@ -55,9 +55,9 @@ import { } from '../providers/outbound-agent-event-publisher'; import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { BidirectionalChatFilter } from '../providers/filters/bidirectional-chat-filter'; -import { DatabaseRegexIncomingChatFilter } from '../providers/filters/database-regex-incoming-chat-filter'; -import { DatabaseRegexOutgoingChatFilter } from '../providers/filters/database-regex-outgoing-chat-filter'; import { IncomingChatFilter } from '../providers/filters/incoming-chat-filter'; +import { MessageFiltersIncomingChatFilter } from '../providers/filters/message-filters-incoming-chat-filter'; +import { MessageFiltersOutgoingChatFilter } from '../providers/filters/message-filters-outgoing-chat-filter'; import { NoopChatFilter } from '../providers/filters/noop-chat-filter'; import { OutgoingChatFilter } from '../providers/filters/outgoing-chat-filter'; import { PipelineProviderFactory } from '../providers/pipeline-provider.factory'; @@ -73,7 +73,7 @@ import { AgentMessagesRepository } from '../repositories/agent-messages.reposito import { AgentsRepository } from '../repositories/agents.repository'; import { DeploymentConfigurationsRepository } from '../repositories/deployment-configurations.repository'; import { DeploymentRunsRepository } from '../repositories/deployment-runs.repository'; -import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; +import { AgentEgressEventsRepository } from '../repositories/agent-egress-events.repository'; import { WorkspaceConfigurationOverridesRepository } from '../repositories/workspace-configuration-overrides.repository'; import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; import { AgentEnvironmentVariablesService } from '../services/agent-environment-variables.service'; @@ -85,7 +85,9 @@ import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentGitCredentialsService } from '../services/agent-git-credentials.service'; import { AgentRuntimeRefreshService } from '../services/agent-runtime-refresh.service'; import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; -import { AgentsFiltersService } from '../services/agents-filters.service'; +import { AgentEgressEventsCollectorService } from '../services/agent-egress-events-collector.service'; +import { AgentEgressService } from '../services/agent-egress.service'; +import { AgentMessageFiltersService } from '../services/agent-message-filters.service'; import { AgentsVcsService } from '../services/agents-vcs.service'; import { AgentsVerificationService } from '../services/agents-verification.service'; import { AgentsService } from '../services/agents.service'; @@ -95,8 +97,6 @@ import { DeploymentsService } from '../services/deployments.service'; import { DockerService } from '../services/docker.service'; import { EnvironmentProgressService } from '../services/environment-progress.service'; import { PromptContextComposerService } from '../services/prompt-context-composer.service'; -import { RegexFilterRulesCacheService } from '../services/regex-filter-rules-cache.service'; -import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules-evaluate.service'; import { VncBridgeService } from '../services/vnc-bridge.service'; import { VncSessionsService } from '../services/vnc-sessions.service'; import { VncTicketService } from '../services/vnc-ticket.service'; @@ -119,7 +119,7 @@ import { WorkspaceInotifySupervisor } from '../services/workspace-inotify-superv AgentEnvironmentVariableEntity, DeploymentConfigurationEntity, DeploymentRunEntity, - RegexFilterRuleEntity, + AgentEgressEventEntity, WorkspaceConfigurationOverrideEntity, ]), ], @@ -137,7 +137,7 @@ import { WorkspaceInotifySupervisor } from '../services/workspace-inotify-superv AgentsVncController, AgentsEnvironmentVariablesController, AgentsChatSessionsController, - AgentsFiltersController, + AgentsEgressController, ConfigController, InstanceStatusController, WorkspaceConfigurationOverridesController, @@ -202,14 +202,14 @@ import { WorkspaceInotifySupervisor } from '../services/workspace-inotify-superv IncomingChatFilter, OutgoingChatFilter, BidirectionalChatFilter, - RegexFilterRulesRepository, - RegexFilterRulesCacheService, - RegexFilterRulesEvaluateService, - AgentsFiltersService, + AgentMessageFiltersService, + AgentEgressService, + AgentEgressEventsRepository, + AgentEgressEventsCollectorService, WorkspaceConfigurationOverridesRepository, WorkspaceConfigurationOverridesService, - DatabaseRegexIncomingChatFilter, - DatabaseRegexOutgoingChatFilter, + MessageFiltersIncomingChatFilter, + MessageFiltersOutgoingChatFilter, DynamicProviderLoaderService, { provide: 'AGENT_PROVIDER_INIT', @@ -251,16 +251,16 @@ import { WorkspaceInotifySupervisor } from '../services/workspace-inotify-superv incomingFilter: IncomingChatFilter, outgoingFilter: OutgoingChatFilter, bidirectionalFilter: BidirectionalChatFilter, - dbIncoming: DatabaseRegexIncomingChatFilter, - dbOutgoing: DatabaseRegexOutgoingChatFilter, + messageFiltersIncoming: MessageFiltersIncomingChatFilter, + messageFiltersOutgoing: MessageFiltersOutgoingChatFilter, dynamicLoader: DynamicProviderLoaderService, ) => { factory.registerFilter(noopFilter); factory.registerFilter(incomingFilter); factory.registerFilter(outgoingFilter); factory.registerFilter(bidirectionalFilter); - factory.registerFilter(dbIncoming); - factory.registerFilter(dbOutgoing); + factory.registerFilter(messageFiltersIncoming); + factory.registerFilter(messageFiltersOutgoing); await registerDynamicProviders({ envKey: 'DYNAMIC_CHAT_FILTERS', @@ -278,8 +278,8 @@ import { WorkspaceInotifySupervisor } from '../services/workspace-inotify-superv IncomingChatFilter, OutgoingChatFilter, BidirectionalChatFilter, - DatabaseRegexIncomingChatFilter, - DatabaseRegexOutgoingChatFilter, + MessageFiltersIncomingChatFilter, + MessageFiltersOutgoingChatFilter, DynamicProviderLoaderService, ], }, diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.spec.ts deleted file mode 100644 index 0ddccbe91..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { RegexFilterRulesEvaluateService } from '../../services/regex-filter-rules-evaluate.service'; -import { FilterDirection, FilterResult } from '../chat-filter.interface'; - -import { DatabaseRegexIncomingChatFilter } from './database-regex-incoming-chat-filter'; - -describe('DatabaseRegexIncomingChatFilter', () => { - let filter: DatabaseRegexIncomingChatFilter; - let evaluateService: jest.Mocked>; - - beforeEach(async () => { - jest.clearAllMocks(); - evaluateService = { evaluate: jest.fn() }; - const module: TestingModule = await Test.createTestingModule({ - providers: [ - DatabaseRegexIncomingChatFilter, - { provide: RegexFilterRulesEvaluateService, useValue: evaluateService }, - ], - }).compile(); - - filter = module.get(DatabaseRegexIncomingChatFilter); - }); - - describe('getType', () => { - it('returns database-regex-incoming', () => { - expect(filter.getType()).toBe('database-regex-incoming'); - }); - }); - - describe('getDisplayName', () => { - it('returns a stable label', () => { - expect(filter.getDisplayName()).toBe('Database regex rules (incoming)'); - }); - }); - - describe('getDirection', () => { - it('returns INCOMING', () => { - expect(filter.getDirection()).toBe(FilterDirection.INCOMING); - }); - }); - - describe('filter', () => { - it('delegates to evaluateService with INCOMING direction', async () => { - const result: FilterResult = { filtered: true, action: 'flag', reason: 'db-rule' }; - - evaluateService.evaluate.mockResolvedValue(result); - - const ctx = { agentId: 'agent-1', actor: 'user' as const }; - const out = await filter.filter('hello world', ctx); - - expect(out).toBe(result); - expect(evaluateService.evaluate).toHaveBeenCalledTimes(1); - expect(evaluateService.evaluate).toHaveBeenCalledWith('hello world', FilterDirection.INCOMING); - }); - - it('works without context', async () => { - const result: FilterResult = { filtered: false }; - - evaluateService.evaluate.mockResolvedValue(result); - - const out = await filter.filter('plain'); - - expect(out).toEqual(result); - expect(evaluateService.evaluate).toHaveBeenCalledWith('plain', FilterDirection.INCOMING); - }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.ts deleted file mode 100644 index 0836e5466..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-incoming-chat-filter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { RegexFilterRulesEvaluateService } from '../../services/regex-filter-rules-evaluate.service'; -import { ChatFilter, FilterContext, FilterDirection, FilterResult } from '../chat-filter.interface'; - -@Injectable() -export class DatabaseRegexIncomingChatFilter implements ChatFilter { - private static readonly TYPE = 'database-regex-incoming'; - - constructor(private readonly evaluateService: RegexFilterRulesEvaluateService) {} - - getType(): string { - return DatabaseRegexIncomingChatFilter.TYPE; - } - - getDisplayName(): string { - return 'Database regex rules (incoming)'; - } - - getDirection(): FilterDirection { - return FilterDirection.INCOMING; - } - - async filter(message: string, _context?: FilterContext): Promise { - return await this.evaluateService.evaluate(message, FilterDirection.INCOMING); - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.spec.ts deleted file mode 100644 index bf8b33618..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { RegexFilterRulesEvaluateService } from '../../services/regex-filter-rules-evaluate.service'; -import { FilterDirection, FilterResult } from '../chat-filter.interface'; - -import { DatabaseRegexOutgoingChatFilter } from './database-regex-outgoing-chat-filter'; - -describe('DatabaseRegexOutgoingChatFilter', () => { - let filter: DatabaseRegexOutgoingChatFilter; - let evaluateService: jest.Mocked>; - - beforeEach(async () => { - jest.clearAllMocks(); - evaluateService = { evaluate: jest.fn() }; - const module: TestingModule = await Test.createTestingModule({ - providers: [ - DatabaseRegexOutgoingChatFilter, - { provide: RegexFilterRulesEvaluateService, useValue: evaluateService }, - ], - }).compile(); - - filter = module.get(DatabaseRegexOutgoingChatFilter); - }); - - describe('getType', () => { - it('returns database-regex-outgoing', () => { - expect(filter.getType()).toBe('database-regex-outgoing'); - }); - }); - - describe('getDisplayName', () => { - it('returns a stable label', () => { - expect(filter.getDisplayName()).toBe('Database regex rules (outgoing)'); - }); - }); - - describe('getDirection', () => { - it('returns OUTGOING', () => { - expect(filter.getDirection()).toBe(FilterDirection.OUTGOING); - }); - }); - - describe('filter', () => { - it('delegates to evaluateService with OUTGOING direction', async () => { - const result: FilterResult = { filtered: true, action: 'drop', reason: 'blocked' }; - - evaluateService.evaluate.mockResolvedValue(result); - - const ctx = { agentId: 'agent-1', actor: 'agent' as const }; - const out = await filter.filter('model output', ctx); - - expect(out).toBe(result); - expect(evaluateService.evaluate).toHaveBeenCalledTimes(1); - expect(evaluateService.evaluate).toHaveBeenCalledWith('model output', FilterDirection.OUTGOING); - }); - - it('works without context', async () => { - const result: FilterResult = { filtered: false }; - - evaluateService.evaluate.mockResolvedValue(result); - - const out = await filter.filter('plain'); - - expect(out).toEqual(result); - expect(evaluateService.evaluate).toHaveBeenCalledWith('plain', FilterDirection.OUTGOING); - }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.ts deleted file mode 100644 index 913ad0d1e..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/database-regex-outgoing-chat-filter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { RegexFilterRulesEvaluateService } from '../../services/regex-filter-rules-evaluate.service'; -import { ChatFilter, FilterContext, FilterDirection, FilterResult } from '../chat-filter.interface'; - -@Injectable() -export class DatabaseRegexOutgoingChatFilter implements ChatFilter { - private static readonly TYPE = 'database-regex-outgoing'; - - constructor(private readonly evaluateService: RegexFilterRulesEvaluateService) {} - - getType(): string { - return DatabaseRegexOutgoingChatFilter.TYPE; - } - - getDisplayName(): string { - return 'Database regex rules (outgoing)'; - } - - getDirection(): FilterDirection { - return FilterDirection.OUTGOING; - } - - async filter(message: string, _context?: FilterContext): Promise { - return await this.evaluateService.evaluate(message, FilterDirection.OUTGOING); - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-chat-filters.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-chat-filters.spec.ts new file mode 100644 index 000000000..20c2d0f21 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-chat-filters.spec.ts @@ -0,0 +1,34 @@ +import { FilterDirection } from '../chat-filter.interface'; + +import { MessageFiltersIncomingChatFilter } from './message-filters-incoming-chat-filter'; +import { MessageFiltersOutgoingChatFilter } from './message-filters-outgoing-chat-filter'; + +describe('Message filter chat filters', () => { + const evaluate = jest.fn().mockResolvedValue({ filtered: true, action: 'drop', reason: 'Rule (global)' }); + const service = { evaluate } as never; + + beforeEach(() => evaluate.mockClear()); + + it('evaluates incoming messages for the context agent', async () => { + const filter = new MessageFiltersIncomingChatFilter(service); + + expect(filter.getType()).toBe('message-filters-incoming'); + expect(filter.getDisplayName()).toBe('Message filters (incoming)'); + expect(filter.getDirection()).toBe(FilterDirection.INCOMING); + await expect(filter.filter('hi', { agentId: 'agent-1', actor: 'user' })).resolves.toEqual({ + filtered: true, + action: 'drop', + reason: 'Rule (global)', + }); + expect(evaluate).toHaveBeenCalledWith('agent-1', 'hi', 'incoming'); + }); + + it('evaluates outgoing messages for the context agent', async () => { + const filter = new MessageFiltersOutgoingChatFilter(service); + + expect(filter.getType()).toBe('message-filters-outgoing'); + expect(filter.getDirection()).toBe(FilterDirection.OUTGOING); + await filter.filter('reply'); + expect(evaluate).toHaveBeenCalledWith(undefined, 'reply', 'outgoing'); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-incoming-chat-filter.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-incoming-chat-filter.ts new file mode 100644 index 000000000..4a6b6f485 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-incoming-chat-filter.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; + +import { AgentMessageFiltersService } from '../../services/agent-message-filters.service'; +import { ChatFilter, FilterContext, FilterDirection, FilterResult } from '../chat-filter.interface'; + +/** Layered message filter rules (agent configuration → Security → Message filters) for user messages. */ +@Injectable() +export class MessageFiltersIncomingChatFilter implements ChatFilter { + static readonly TYPE = 'message-filters-incoming'; + + constructor(private readonly messageFilters: AgentMessageFiltersService) {} + + getType(): string { + return MessageFiltersIncomingChatFilter.TYPE; + } + + getDisplayName(): string { + return 'Message filters (incoming)'; + } + + getDirection(): FilterDirection { + return FilterDirection.INCOMING; + } + + async filter(message: string, context?: FilterContext): Promise { + return await this.messageFilters.evaluate(context?.agentId, message, 'incoming'); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-outgoing-chat-filter.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-outgoing-chat-filter.ts new file mode 100644 index 000000000..da5a1605d --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/filters/message-filters-outgoing-chat-filter.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; + +import { AgentMessageFiltersService } from '../../services/agent-message-filters.service'; +import { ChatFilter, FilterContext, FilterDirection, FilterResult } from '../chat-filter.interface'; + +/** Layered message filter rules (agent configuration → Security → Message filters) for agent responses. */ +@Injectable() +export class MessageFiltersOutgoingChatFilter implements ChatFilter { + static readonly TYPE = 'message-filters-outgoing'; + + constructor(private readonly messageFilters: AgentMessageFiltersService) {} + + getType(): string { + return MessageFiltersOutgoingChatFilter.TYPE; + } + + getDisplayName(): string { + return 'Message filters (outgoing)'; + } + + getDirection(): FilterDirection { + return FilterDirection.OUTGOING; + } + + async filter(message: string, context?: FilterContext): Promise { + return await this.messageFilters.evaluate(context?.agentId, message, 'outgoing'); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync-platform-policy.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync-platform-policy.spec.ts new file mode 100644 index 000000000..1dd17a66c --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync-platform-policy.spec.ts @@ -0,0 +1,168 @@ +import type { AgentsRepository } from '../../repositories/agents.repository'; +import type { AgentEgressService } from '../../services/agent-egress.service'; +import type { AgentMessageFiltersService } from '../../services/agent-message-filters.service'; +import type { AgentRuntimeRefreshService } from '../../services/agent-runtime-refresh.service'; +import type { DockerService } from '../../services/docker.service'; + +import type { OpenCodeClientFactory } from './opencode-client.factory'; +import { OpenCodeConfigSyncService } from './opencode-config-sync.service'; + +describe('OpenCodeConfigSyncService platform security policy', () => { + const agent = { + id: 'agent-1', + name: 'Agent One', + containerId: 'container-1', + opencodeUserSecrets: null, + egressGuardContainerId: null as string | null, + }; + let agentsRepository: { findById: jest.Mock; update: jest.Mock }; + let dockerService: Record; + let egress: { applyPolicyForAgent: jest.Mock; describe: jest.Mock }; + let messageFilters: { store: jest.Mock }; + let service: OpenCodeConfigSyncService; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + agent.egressGuardContainerId = null; + agentsRepository = { + findById: jest.fn().mockResolvedValue(agent), + update: jest.fn().mockResolvedValue(agent), + }; + dockerService = { + getContainerEnvironmentMap: jest.fn().mockResolvedValue({}), + updateContainer: jest.fn().mockResolvedValue('container-2'), + sendCommandToContainer: jest.fn().mockResolvedValue(''), + getContainerHomeDirectory: jest.fn().mockResolvedValue('/home/agenstra'), + getEnvironmentApplyStrategy: jest.fn().mockResolvedValue('restart'), + }; + egress = { + applyPolicyForAgent: jest.fn().mockResolvedValue({ + ok: true, + applied: true, + revision: 'rev-1', + joinGuardId: 'guard-1', + proxyEnv: { + HTTP_PROXY: 'http://127.0.0.1:3128', + HTTPS_PROXY: 'http://127.0.0.1:3128', + http_proxy: 'http://127.0.0.1:3128', + https_proxy: 'http://127.0.0.1:3128', + NO_PROXY: 'localhost,127.0.0.1,::1', + no_proxy: 'localhost,127.0.0.1,::1', + }, + }), + describe: jest.fn().mockResolvedValue({ status: 'active', mode: 'enforce' }), + }; + messageFilters = { store: jest.fn().mockResolvedValue([]) }; + service = new OpenCodeConfigSyncService( + agentsRepository as unknown as AgentsRepository, + { + invalidate: jest.fn(), + waitForHealthy: jest.fn().mockResolvedValue(undefined), + getClient: jest.fn().mockResolvedValue({}), + resolveConnection: jest.fn().mockResolvedValue({ baseUrl: 'http://opencode', authorization: 'Basic x' }), + } as unknown as OpenCodeClientFactory, + dockerService as unknown as DockerService, + undefined, + { + invalidateConnections: jest.fn(), + waitForHealthy: jest.fn().mockResolvedValue(true), + reattachWatchers: jest.fn().mockResolvedValue(undefined), + restoreGitCredentials: jest.fn().mockResolvedValue(true), + } as unknown as AgentRuntimeRefreshService, + egress as unknown as AgentEgressService, + messageFilters as unknown as AgentMessageFiltersService, + ); + fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '{}', + json: async () => ({}), + } as unknown as Response); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('stores message filters, applies the egress policy and joins the guard namespace in one recreate', async () => { + const result = await service.syncEffective( + agent.id, + {}, + { HTTPS_PROXY: 'http://corp-proxy:8080' }, + { + egress: { mode: 'enforce', allowHosts: [{ value: 'github.com', source: 'global' }] }, + messageFilters: [{ name: 'A', pattern: 'a', action: 'drop', source: 'global' }], + }, + ); + + expect(result).toEqual({ ok: true }); + expect(messageFilters.store).toHaveBeenCalledWith('agent-1', [ + { name: 'A', pattern: 'a', action: 'drop', source: 'global' }, + ]); + expect(egress.applyPolicyForAgent).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ mode: 'enforce', allowHosts: [{ value: 'github.com', source: 'global' }] }), + { HTTPS_PROXY: 'http://corp-proxy:8080' }, + ); + // Agent proxies point at the guard (the corporate proxy is chained by the guard instead). + expect(dockerService.updateContainer).toHaveBeenCalledWith('container-1', { + env: expect.objectContaining({ + HTTPS_PROXY: 'http://127.0.0.1:3128', + https_proxy: 'http://127.0.0.1:3128', + no_proxy: 'localhost,127.0.0.1,::1', + }), + sharedNetworkContainerId: 'guard-1', + capDrop: ['NET_ADMIN', 'NET_RAW'], + }); + expect(agentsRepository.update).toHaveBeenCalledWith('agent-1', { containerId: 'container-2' }); + }); + + it('keeps the stored policy when an older controller omits the platform policy', async () => { + const result = await service.syncEffective(agent.id, {}, {}); + + expect(result.ok).toBe(true); + expect(messageFilters.store).not.toHaveBeenCalled(); + expect(egress.applyPolicyForAgent).not.toHaveBeenCalled(); + }); + + it('re-applies the stored egress policy for guarded agents without a new policy', async () => { + agent.egressGuardContainerId = 'guard-1'; + egress.applyPolicyForAgent.mockResolvedValue({ ok: true, applied: false, revision: 'rev-1', proxyEnv: {} }); + + await service.syncEffective(agent.id, {}, {}); + + expect(egress.applyPolicyForAgent).toHaveBeenCalledWith('agent-1', undefined, {}); + }); + + it('applies everything else but reports egress failures', async () => { + egress.applyPolicyForAgent.mockResolvedValue({ + ok: false, + error: 'squid configuration rejected', + applied: false, + revision: null, + proxyEnv: {}, + }); + + const result = await service.syncEffective(agent.id, {}, {}, { egress: { mode: 'enforce' } }); + + expect(result).toEqual({ ok: false, error: 'Egress policy not applied: squid configuration rejected' }); + expect(fetchSpy).toHaveBeenCalledWith( + 'http://opencode/global/config', + expect.objectContaining({ method: 'PATCH' }), + ); + }); + + it('includes the egress status in the environment layer config response', async () => { + await expect(service.get(agent.id)).resolves.toEqual( + expect.objectContaining({ egress: { status: 'active', mode: 'enforce' } }), + ); + }); + + it('rejects invalid platform security config on environment PUT', async () => { + await expect(service.put(agent.id, { config: { egress: { mode: 'nope' } } })).rejects.toThrow(/mode/); + await expect( + service.put(agent.id, { config: { message_filters: [{ name: 'x', pattern: '(a+)+', action: 'drop' }] } }), + ).rejects.toThrow(/nested quantifiers/); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts index 9305451c5..3d761acef 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts @@ -1,6 +1,9 @@ import { applySecretsPatch, assertNoCredentialKeysInConfig, + assertValidPlatformSecurityConfig, + normalizeEffectiveEgressPolicy, + OpencodeConfigValidationError, buildAllowDenyContext, composeLayerOverlay, enforceAllowDenyOnOverlay, @@ -31,9 +34,16 @@ import { OpencodeMcpAuthStartResponseDto, OpencodeMcpServerStatusDto, OpencodeMcpStatusListResponseDto, + SyncAgentPlatformPolicyDto, UpsertAgentOpencodeConfigDto, } from '../../dto/agent-opencode-config.dto'; import { AgentsRepository } from '../../repositories/agents.repository'; +import { + AgentEgressService, + EGRESS_GUARDED_AGENT_CAP_DROP, + type AgentEgressApplyResult, +} from '../../services/agent-egress.service'; +import { AgentMessageFiltersService } from '../../services/agent-message-filters.service'; import { AgentRuntimeRefreshService } from '../../services/agent-runtime-refresh.service'; import { DockerService } from '../../services/docker.service'; import { @@ -254,6 +264,10 @@ export class OpenCodeConfigSyncService { private readonly environmentProgress?: EnvironmentProgressService, @Optional() private readonly agentRuntimeRefresh?: AgentRuntimeRefreshService, + @Optional() + private readonly agentEgress?: AgentEgressService, + @Optional() + private readonly messageFilters?: AgentMessageFiltersService, ) {} registerConfigSyncedBroadcaster(broadcaster: (agentId: string) => void): void { @@ -274,6 +288,7 @@ export class OpenCodeConfigSyncService { overrides: agent.opencodeUserOverrides ?? {}, secretKeys: Object.keys(secrets), updatedAt: agent.updatedAt, + ...(this.agentEgress ? { egress: await this.agentEgress.describe(agent) } : {}), }; } @@ -281,6 +296,17 @@ export class OpenCodeConfigSyncService { assertNoCredentialKeysInConfig(dto.config ?? undefined); assertNoCredentialKeysInConfig(dto.overrides ?? undefined); + try { + assertValidPlatformSecurityConfig(dto.config ?? undefined); + assertValidPlatformSecurityConfig(dto.overrides ?? undefined); + } catch (error) { + if (error instanceof OpencodeConfigValidationError) { + throw new BadRequestException(error.message); + } + + throw error; + } + const agent = await this.agentsRepository.findById(agentId); if (!agent) { @@ -323,6 +349,7 @@ export class OpenCodeConfigSyncService { overrides: saved.opencodeUserOverrides ?? {}, secretKeys: Object.keys(secrets), updatedAt: saved.updatedAt, + ...(this.agentEgress ? { egress: await this.agentEgress.describe(saved) } : {}), }; } @@ -334,11 +361,12 @@ export class OpenCodeConfigSyncService { agentId: string, effective: Record, parentSecrets: Record = {}, + platformPolicy?: SyncAgentPlatformPolicyDto, ): Promise { const progressRef: ConfigSyncProgressRef = {}; try { - const result = await this.syncEffectiveInternal(agentId, effective, parentSecrets, progressRef); + const result = await this.syncEffectiveInternal(agentId, effective, parentSecrets, progressRef, platformPolicy); if (result.ok) { progressRef.tracker?.complete(); @@ -360,6 +388,7 @@ export class OpenCodeConfigSyncService { effective: Record, parentSecrets: Record, progressRef: ConfigSyncProgressRef, + platformPolicy?: SyncAgentPlatformPolicyDto, ): Promise { assertNoCredentialKeysInConfig(effective); @@ -385,12 +414,31 @@ export class OpenCodeConfigSyncService { try { progressRef.agentName = agent.name; + + // Platform security policy (layered agent configuration → Security). Message filters take effect + // immediately; the egress guard is created / updated before the container environment is applied + // so a topology change (joining the guard namespace) happens in the same recreate. + if (platformPolicy?.messageFilters !== undefined) { + await this.messageFilters?.store(agentId, platformPolicy.messageFilters); + } + + let egressPlan: AgentEgressApplyResult | undefined; + + if (this.agentEgress && (platformPolicy?.egress !== undefined || agent.egressGuardContainerId)) { + egressPlan = await this.agentEgress.applyPolicyForAgent( + agentId, + platformPolicy?.egress !== undefined ? normalizeEffectiveEgressPolicy(platformPolicy.egress) : undefined, + secrets, + ); + } + const envResult = await this.applyEnvSecretsToContainer( agentId, agent.containerId, secrets, prepared, progressRef, + egressPlan, ); if (!envResult.ok) { @@ -498,6 +546,11 @@ export class OpenCodeConfigSyncService { return { ok: false, error: message }; } + if (egressPlan && !egressPlan.ok) { + // Everything else was applied; surface the egress failure so the controller retries / notifies. + return { ok: false, error: `Egress policy not applied: ${egressPlan.error ?? 'unknown error'}` }; + } + return { ok: true }; } catch (error) { const message = formatOpenCodeError(error); @@ -640,6 +693,7 @@ mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, secrets: Record, effective: Record, progressRef: ConfigSyncProgressRef = {}, + egressPlan?: AgentEgressApplyResult, ): Promise { const network = extractNetworkSecrets(secrets); const providerEnv = extractProviderEnvSecrets(secrets, effective as Record); @@ -671,6 +725,12 @@ mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, desiredEnv[key] = value; } + // While an egress guard enforces, every proxy variable points at the guard's explicit proxy + // (the configured upstream proxy is chained by the guard instead). + for (const [key, value] of Object.entries(egressPlan?.proxyEnv ?? {})) { + desiredEnv[key] = value; + } + if (desiredEnv.NODE_EXTRA_CA_CERTS && isPemCertificateMaterial(desiredEnv.NODE_EXTRA_CA_CERTS)) { try { const home = await this.dockerService.getContainerHomeDirectory(containerId); @@ -694,7 +754,7 @@ mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, // Agent-level environment variables stay authoritative; their keys only get a new fallback value. return await withAgentEnvironmentLock(agentId, () => - this.applyShieldedEnvToContainer(agentId, containerId, desiredEnv, progressRef), + this.applyShieldedEnvToContainer(agentId, containerId, desiredEnv, progressRef, egressPlan?.joinGuardId), ); } @@ -703,6 +763,7 @@ mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, containerId: string, requestedEnv: Record, progressRef: ConfigSyncProgressRef, + joinGuardId?: string, ): Promise { const agent = await this.agentsRepository.findById(agentId); const shielded = shieldAgentEnvironmentVariables( @@ -733,13 +794,18 @@ mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, } } + if (joinGuardId) { + // Joining the egress guard namespace is a network-mode change: always a recreate. + changed = true; + } + if (!changed) { return { ok: true, containerId, recreated: false }; } try { // Containers with the mounted environment are restarted in place; legacy ones are recreated once. - const strategy = await this.dockerService.getEnvironmentApplyStrategy(containerId); + const strategy = joinGuardId ? 'recreate' : await this.dockerService.getEnvironmentApplyStrategy(containerId); progressRef.tracker = this.environmentProgress?.start({ agentId, @@ -751,7 +817,10 @@ mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, : CONFIG_SYNC_ENVIRONMENT_PROGRESS_STEPS, }); progressRef.tracker?.advance(strategy === 'restart' ? 'restartingContainer' : 'recreatingContainer'); - const newContainerId = await this.dockerService.updateContainer(containerId, { env: desiredEnv }); + const newContainerId = await this.dockerService.updateContainer(containerId, { + env: desiredEnv, + ...(joinGuardId ? { sharedNetworkContainerId: joinGuardId, capDrop: [...EGRESS_GUARDED_AGENT_CAP_DROP] } : {}), + }); if (newContainerId !== containerId) { await this.agentsRepository.update(agentId, { containerId: newContainerId }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts new file mode 100644 index 000000000..d73f83024 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-egress-events.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThan, MoreThan, Repository } from 'typeorm'; + +import { AgentEgressEventEntity } from '../entities/agent-egress-event.entity'; + +export type NewAgentEgressEvent = Omit; + +@Injectable() +export class AgentEgressEventsRepository { + constructor( + @InjectRepository(AgentEgressEventEntity) + private readonly repository: Repository, + ) {} + + async insertMany(events: readonly NewAgentEgressEvent[]): Promise { + if (events.length === 0) { + return; + } + + await this.repository.insert(events.map((event) => this.repository.create(event))); + } + + /** Events with `seq` strictly greater than `afterSeq`, ascending (controller ingestion cursor). */ + async findAfter(afterSeq: string, limit: number, agentId?: string): Promise { + return await this.repository.find({ + where: { seq: MoreThan(afterSeq), ...(agentId ? { agentId } : {}) }, + order: { seq: 'ASC' }, + take: limit, + }); + } + + /** Most recent events for one agent (status endpoint), newest first. */ + async findRecentForAgent(agentId: string, limit: number): Promise { + return await this.repository.find({ + where: { agentId }, + order: { seq: 'DESC' }, + take: limit, + }); + } + + async deleteCreatedBefore(cutoff: Date): Promise { + const result = await this.repository.delete({ createdAt: LessThan(cutoff) }); + + return result.affected ?? 0; + } + + async countByDecision(): Promise> { + const rows = await this.repository + .createQueryBuilder('event') + .select('event.kind', 'kind') + .addSelect('event.decision', 'decision') + .addSelect('COALESCE(SUM(event.count), 0)', 'count') + .groupBy('event.kind') + .addGroupBy('event.decision') + .getRawMany<{ kind: string; decision: string; count: string }>(); + + return rows.map((row) => ({ kind: row.kind, decision: row.decision, count: Number.parseInt(row.count, 10) || 0 })); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts index c94bbc872..50d925e56 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts @@ -74,6 +74,16 @@ export class AgentsRepository { }); } + /** + * Find all agents that own an egress guard sidecar. + * @returns Array of agent entities with egressGuardContainerId + */ + async findAllWithEgressGuards(): Promise { + return await this.repository.find({ + where: [{ egressGuardContainerId: Not(IsNull()) }], + }); + } + /** * Count total number of agents. * @returns Total count of agents diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.spec.ts deleted file mode 100644 index abafbbc55..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; - -import { RegexFilterRulesRepository } from './regex-filter-rules.repository'; - -describe('RegexFilterRulesRepository', () => { - let repository: RegexFilterRulesRepository; - const typeOrm = { - findOne: jest.fn(), - find: jest.fn(), - count: jest.fn(), - create: jest.fn(), - save: jest.fn(), - remove: jest.fn(), - }; - const row: RegexFilterRuleEntity = Object.assign(new RegexFilterRuleEntity(), { - id: 'rule-1', - pattern: 'x', - regexFlags: 'g', - direction: 'incoming' as const, - filterType: 'none' as const, - priority: 0, - createdAt: new Date(), - updatedAt: new Date(), - }); - - beforeEach(async () => { - jest.clearAllMocks(); - const module: TestingModule = await Test.createTestingModule({ - providers: [ - RegexFilterRulesRepository, - { provide: getRepositoryToken(RegexFilterRuleEntity), useValue: typeOrm }, - ], - }).compile(); - - repository = module.get(RegexFilterRulesRepository); - }); - - describe('findByIdOrThrow', () => { - it('returns entity when found', async () => { - typeOrm.findOne.mockResolvedValue(row); - await expect(repository.findByIdOrThrow('rule-1')).resolves.toEqual(row); - expect(typeOrm.findOne).toHaveBeenCalledWith({ where: { id: 'rule-1' } }); - }); - - it('throws NotFoundException when missing', async () => { - typeOrm.findOne.mockResolvedValue(null); - await expect(repository.findByIdOrThrow('missing')).rejects.toThrow(NotFoundException); - }); - }); - - describe('findAllOrdered', () => { - it('orders by priority and createdAt', async () => { - typeOrm.find.mockResolvedValue([row]); - const result = await repository.findAllOrdered(); - - expect(result).toEqual([row]); - expect(typeOrm.find).toHaveBeenCalledWith({ - order: { priority: 'ASC', createdAt: 'ASC' }, - }); - }); - }); - - describe('findAll', () => { - it('applies take, skip and order', async () => { - typeOrm.find.mockResolvedValue([]); - await repository.findAll(20, 40); - expect(typeOrm.find).toHaveBeenCalledWith({ - take: 20, - skip: 40, - order: { priority: 'ASC', createdAt: 'ASC' }, - }); - }); - }); - - describe('count', () => { - it('delegates to TypeORM count', async () => { - typeOrm.count.mockResolvedValue(7); - await expect(repository.count()).resolves.toBe(7); - }); - }); - - describe('create', () => { - it('creates and saves', async () => { - typeOrm.create.mockReturnValue(row); - typeOrm.save.mockResolvedValue(row); - const result = await repository.create({ - pattern: 'a', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - }); - - expect(typeOrm.create).toHaveBeenCalled(); - expect(typeOrm.save).toHaveBeenCalledWith(row); - expect(result).toEqual(row); - }); - }); - - describe('update', () => { - it('merges and saves', async () => { - typeOrm.findOne.mockResolvedValue({ ...row }); - typeOrm.save.mockImplementation(async (r: RegexFilterRuleEntity) => r); - const result = await repository.update('rule-1', { priority: 9 }); - - expect(result.priority).toBe(9); - expect(typeOrm.save).toHaveBeenCalled(); - }); - }); - - describe('delete', () => { - it('removes after find', async () => { - typeOrm.findOne.mockResolvedValue(row); - typeOrm.remove.mockResolvedValue(row); - await repository.delete('rule-1'); - expect(typeOrm.remove).toHaveBeenCalledWith(row); - }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts deleted file mode 100644 index ffb83ce34..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; - -/** - * Repository for regex filter rules. - */ -@Injectable() -export class RegexFilterRulesRepository { - constructor( - @InjectRepository(RegexFilterRuleEntity) - private readonly repository: Repository, - ) {} - - async findByIdOrThrow(id: string): Promise { - const row = await this.repository.findOne({ where: { id } }); - - if (!row) { - throw new NotFoundException(`Regex filter rule with ID ${id} not found`); - } - - return row; - } - - async findAllOrdered(): Promise { - return await this.repository.find({ - order: { priority: 'ASC', createdAt: 'ASC' }, - }); - } - - async findAll(limit = 100, offset = 0): Promise { - return await this.repository.find({ - take: limit, - skip: offset, - order: { priority: 'ASC', createdAt: 'ASC' }, - }); - } - - async count(): Promise { - return await this.repository.count(); - } - - async create(dto: Partial): Promise { - const row = this.repository.create(dto); - - return await this.repository.save(row); - } - - async update(id: string, dto: Partial): Promise { - const row = await this.findByIdOrThrow(id); - - Object.assign(row, dto); - - return await this.repository.save(row); - } - - async delete(id: string): Promise { - const row = await this.findByIdOrThrow(id); - - await this.repository.remove(row); - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.spec.ts index 6e65802fd..c8d709c6e 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.spec.ts @@ -23,7 +23,7 @@ import { AgenstraManagerMetricsCollectorService } from './agenstra-manager-metri describe('AgenstraManagerMetricsCollectorService', () => { const agentCreateQueryBuilder = jest.fn(); const messageCreateQueryBuilder = jest.fn(); - const filterCreateQueryBuilder = jest.fn(); + const egressCreateQueryBuilder = jest.fn(); const agentRepository = { createQueryBuilder: agentCreateQueryBuilder, count: jest.fn(), @@ -31,8 +31,8 @@ describe('AgenstraManagerMetricsCollectorService', () => { const messageRepository = { createQueryBuilder: messageCreateQueryBuilder, }; - const filterRulesRepository = { - createQueryBuilder: filterCreateQueryBuilder, + const egressEventsRepository = { + createQueryBuilder: egressCreateQueryBuilder, }; beforeEach(() => { @@ -41,7 +41,7 @@ describe('AgenstraManagerMetricsCollectorService', () => { resolveOtelRuntimeConfig.mockReturnValue({ enabled: true }); }); - it('sets agent, chat, filter-trigger, and filter-rule gauges when OTEL is enabled', async () => { + it('sets agent, chat, filter-trigger, egress and message-filter gauges when OTEL is enabled', async () => { agentCreateQueryBuilder.mockReturnValue({ select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), @@ -49,7 +49,11 @@ describe('AgenstraManagerMetricsCollectorService', () => { addGroupBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), getCount: jest.fn().mockResolvedValue(2), - getRawMany: jest.fn().mockResolvedValue([{ agentType: 'opencode', containerType: 'docker', count: '2' }]), + getRawMany: jest + .fn() + .mockResolvedValueOnce([{ agentType: 'opencode', containerType: 'docker', count: '2' }]) + .mockResolvedValueOnce([{ status: 'active', count: '1' }]), + getRawOne: jest.fn().mockResolvedValue({ count: '4' }), }); agentRepository.count.mockResolvedValue(3); messageCreateQueryBuilder.mockReturnValue({ @@ -62,18 +66,18 @@ describe('AgenstraManagerMetricsCollectorService', () => { { actor: 'agent', filtered: true, count: '2', charCount: '40', wordCount: '8' }, ]), }); - filterCreateQueryBuilder.mockReturnValue({ + egressCreateQueryBuilder.mockReturnValue({ select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), addGroupBy: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue([{ direction: 'incoming', filterType: 'drop', count: '3' }]), + getRawMany: jest.fn().mockResolvedValue([{ kind: 'web', decision: 'blocked', count: '3' }]), }); const service = new AgenstraManagerMetricsCollectorService( agentRepository as never, messageRepository as never, - filterRulesRepository as never, + egressEventsRepository as never, ); await (service as unknown as { collectMetrics: () => Promise }).collectMetrics(); @@ -99,13 +103,20 @@ describe('AgenstraManagerMetricsCollectorService', () => { expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.filter_triggers', 2, { actor: 'agent', }); - expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.filter_rules', 3, { - direction: 'incoming', - filter_type: 'drop', + expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.egress_guards', 1, { + status: 'active', }); - expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.filter_rules', 0, { - direction: 'outgoing', - filter_type: 'filter', + expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.egress_guards', 0, { + status: 'failed', }); + expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.egress_decisions', 3, { + kind: 'web', + decision: 'blocked', + }); + expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.egress_decisions', 0, { + kind: 'dns', + decision: 'blocked', + }); + expect(setGauge).toHaveBeenCalledWith('forepath.agenstra', 'agenstra.manager.message_filter_rules', 4); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts index 8c9a49bbd..7c0c706b7 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts @@ -4,18 +4,15 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AgentMessageEntity } from '../entities/agent-message.entity'; +import { AgentEgressEventEntity } from '../entities/agent-egress-event.entity'; import { AgentEntity, ContainerType } from '../entities/agent.entity'; -import { - RegexFilterRuleDirection, - RegexFilterRuleEntity, - RegexFilterRuleType, -} from '../entities/regex-filter-rule.entity'; const METER_NAME = 'forepath.agenstra'; const POLL_INTERVAL_MS = 60_000; -const FILTER_DIRECTIONS: RegexFilterRuleDirection[] = ['incoming', 'outgoing', 'bidirectional']; -const FILTER_TYPES: RegexFilterRuleType[] = ['none', 'filter', 'drop']; +const EGRESS_STATUSES = ['inactive', 'active', 'failed'] as const; +const EGRESS_KINDS = ['web', 'dns', 'network', 'guard'] as const; +const EGRESS_DECISIONS = ['allowed', 'blocked', 'observed', 'error'] as const; const CHAT_ACTORS = ['user', 'agent'] as const; @Injectable() @@ -28,8 +25,8 @@ export class AgenstraManagerMetricsCollectorService implements OnModuleInit, OnM private readonly agentRepository: Repository, @InjectRepository(AgentMessageEntity) private readonly messageRepository: Repository, - @InjectRepository(RegexFilterRuleEntity) - private readonly filterRulesRepository: Repository, + @InjectRepository(AgentEgressEventEntity) + private readonly egressEventsRepository: Repository, ) {} onModuleInit(): void { @@ -56,7 +53,7 @@ export class AgenstraManagerMetricsCollectorService implements OnModuleInit, OnM try { await this.collectAgentMetrics(); await this.collectChatMetrics(); - await this.collectFilterRuleMetrics(); + await this.collectSecurityPolicyMetrics(); } catch (error) { this.logger.warn(`Failed to collect Agenstra manager OTEL metrics: ${(error as Error).message}`); } @@ -149,27 +146,46 @@ export class AgenstraManagerMetricsCollectorService implements OnModuleInit, OnM } } - private async collectFilterRuleMetrics(): Promise { - const filterRuleRows = await this.filterRulesRepository - .createQueryBuilder('rule') - .select('rule.direction', 'direction') - .addSelect('rule.filterType', 'filterType') + private async collectSecurityPolicyMetrics(): Promise { + const statusRows = await this.agentRepository + .createQueryBuilder('agent') + .select("COALESCE(agent.egressStatus, 'inactive')", 'status') .addSelect('COUNT(*)', 'count') - .groupBy('rule.direction') - .addGroupBy('rule.filterType') - .getRawMany<{ direction: string; filterType: string; count: string }>(); + .groupBy("COALESCE(agent.egressStatus, 'inactive')") + .getRawMany<{ status: string; count: string }>(); + const byStatus = new Map(statusRows.map((row) => [row.status, parseInt(row.count ?? '0', 10)])); - const byKey = new Map( - filterRuleRows.map((row) => [`${row.direction}:${row.filterType}`, parseInt(row.count ?? '0', 10)]), - ); + for (const status of EGRESS_STATUSES) { + setGauge(METER_NAME, 'agenstra.manager.egress_guards', byStatus.get(status) ?? 0, { status }); + } - for (const direction of FILTER_DIRECTIONS) { - for (const filterType of FILTER_TYPES) { - setGauge(METER_NAME, 'agenstra.manager.filter_rules', byKey.get(`${direction}:${filterType}`) ?? 0, { - direction, - filter_type: filterType, + const eventRows = await this.egressEventsRepository + .createQueryBuilder('event') + .select('event.kind', 'kind') + .addSelect('event.decision', 'decision') + .addSelect('COALESCE(SUM(event.count), 0)', 'count') + .groupBy('event.kind') + .addGroupBy('event.decision') + .getRawMany<{ kind: string; decision: string; count: string }>(); + const byKey = new Map(eventRows.map((row) => [`${row.kind}:${row.decision}`, parseInt(row.count ?? '0', 10)])); + + for (const kind of EGRESS_KINDS) { + for (const decision of EGRESS_DECISIONS) { + setGauge(METER_NAME, 'agenstra.manager.egress_decisions', byKey.get(`${kind}:${decision}`) ?? 0, { + kind, + decision, }); } } + + const ruleRow = await this.agentRepository + .createQueryBuilder('agent') + .select( + "COALESCE(SUM(CASE WHEN jsonb_typeof(agent.messageFilters) = 'array' THEN jsonb_array_length(agent.messageFilters) ELSE 0 END), 0)", + 'count', + ) + .getRawOne<{ count: string }>(); + + setGauge(METER_NAME, 'agenstra.manager.message_filter_rules', parseInt(ruleRow?.count ?? '0', 10)); } } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.spec.ts new file mode 100644 index 000000000..e199aee0f --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.spec.ts @@ -0,0 +1,174 @@ +import { resolveEffectiveEgressPolicy } from '@forepath/agenstra/shared/util-opencode-config'; + +import { AgentEgressEventsCollectorService } from './agent-egress-events-collector.service'; + +const policy = resolveEffectiveEgressPolicy([ + { source: 'global', overlay: { egress: { mode: 'enforce', deny_hosts: ['example.org'] } } }, +]); + +describe('AgentEgressEventsCollectorService', () => { + let agents: Record; + let events: Record; + let docker: Record; + let egress: Record; + let service: AgentEgressEventsCollectorService; + const agent = { + id: 'agent-1', + containerId: 'agent-container', + egressGuardContainerId: 'guard-1', + egressPolicy: policy, + egressPolicyRevision: 'rev-1', + egressLogCursor: '2026-10-12T16:25:40.000000000Z', + }; + + beforeEach(() => { + agents = { + findAllWithEgressGuards: jest.fn().mockResolvedValue([agent]), + update: jest.fn().mockResolvedValue({}), + }; + events = { + insertMany: jest.fn().mockResolvedValue(undefined), + deleteCreatedBefore: jest.fn().mockResolvedValue(3), + }; + docker = { + inspectContainerSummary: jest.fn(async (id: string) => ({ + id, + running: true, + startedAt: new Date(1000), + networkMode: id === 'agent-container' ? 'container:guard-1' : 'agent-net', + labels: {}, + })), + getContainerLogsSince: jest + .fn() + .mockResolvedValue( + [ + '2026-10-12T16:25:39.000000000Z dnsmasq[1]: config old.example.org is NXDOMAIN', + '2026-10-12T16:25:41.100000000Z AGENSTRA_EGRESS 1791822322.256 NONE_NONE 0 CONNECT 1.2.3.4:443 1.2.3.4 www.example.org - 0 0 2 deny_host -', + '2026-10-12T16:25:41.200000000Z AGENSTRA_EGRESS 1791822322.300 NONE_NONE 0 CONNECT 1.2.3.4:443 1.2.3.4 www.example.org - 0 0 2 deny_host -', + '2026-10-12T16:25:42.000000000Z AGENSTRA_EGRESS 1791822323.000 TCP_TUNNEL 200 CONNECT github.com:443 github.com github.com 140.82.121.4 100 10 5 - -', + '2026-10-12T16:25:43.000000000Z dnsmasq[1]: config www.example.org is NXDOMAIN', + '2026-10-12T16:25:44.000000000Z AGENSTRA_GUARD {"event":"process_restarted","detail":"dnsmasq exited"}', + ].join('\n'), + ), + sendCommandToContainer: jest + .fn() + .mockResolvedValue( + '[2:120] -A AGENSTRA_EGRESS_OUT -p udp -m udp --dport 443 -m comment --comment "agenstra:quic" -j REJECT', + ), + }; + egress = { + recoverMissingGuard: jest.fn().mockResolvedValue('guard-2'), + beforeAgentStart: jest.fn().mockResolvedValue(undefined), + refreshAgentNamespaceIfStale: jest.fn().mockResolvedValue(false), + removeOrphanedGuards: jest.fn().mockResolvedValue(0), + }; + service = new AgentEgressEventsCollectorService(agents as never, events as never, docker as never, egress as never); + }); + + it('collects new log lines after the cursor, aggregates them and advances the cursor', async () => { + await expect(service.collectForAgent(agent as never)).resolves.toBe(4); + + expect(docker.getContainerLogsSince).toHaveBeenCalledWith( + 'guard-1', + Math.floor(Date.parse(agent.egressLogCursor) / 1000), + ); + const rows = events.insertMany.mock.calls[0][0]; + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: 'agent-1', + kind: 'web', + decision: 'blocked', + host: 'www.example.org', + rule: 'deny_host', + matchedEntry: 'example.org', + count: 2, + mode: 'enforce', + policyRevision: 'rev-1', + }), + expect.objectContaining({ + kind: 'web', + decision: 'allowed', + host: 'github.com', + bytesIn: '100', + bytesOut: '10', + }), + expect.objectContaining({ kind: 'dns', decision: 'blocked', host: 'www.example.org' }), + expect.objectContaining({ kind: 'guard', decision: 'error', message: 'process_restarted: dnsmasq exited' }), + ]), + ); + expect(rows.some((row: { host: string }) => row.host === 'old.example.org')).toBe(false); + expect(agents.update).toHaveBeenCalledWith('agent-1', { egressLogCursor: '2026-10-12T16:25:44.000000000Z' }); + }); + + it('counts netfilter categories from the second poll on', async () => { + docker.getContainerLogsSince.mockResolvedValue(''); + await service.collectForAgent(agent as never); + expect(events.insertMany).not.toHaveBeenCalled(); + + docker.sendCommandToContainer.mockResolvedValue( + '[5:300] -A AGENSTRA_EGRESS_OUT -p udp -m udp --dport 443 -m comment --comment "agenstra:quic" -j REJECT', + ); + await service.collectForAgent(agent as never); + expect(events.insertMany).toHaveBeenCalledWith([ + expect.objectContaining({ kind: 'network', decision: 'blocked', rule: 'quic', count: 3 }), + ]); + }); + + it('records and recovers a missing guard', async () => { + docker.inspectContainerSummary.mockResolvedValue(null); + + await service.collectForAgent(agent as never); + + expect(events.insertMany).toHaveBeenCalledWith([ + expect.objectContaining({ kind: 'guard', decision: 'error', message: expect.stringContaining('guard_missing') }), + ]); + expect(egress.recoverMissingGuard).toHaveBeenCalledWith(agent); + }); + + it('restarts a stopped guard while its agent runs', async () => { + docker.inspectContainerSummary.mockImplementation(async (id: string) => ({ + id, + running: id !== 'guard-1', + startedAt: new Date(1000), + networkMode: 'container:guard-1', + labels: {}, + })); + + await service.collectForAgent(agent as never); + + expect(egress.beforeAgentStart).toHaveBeenCalledWith(agent); + expect(docker.getContainerLogsSince).not.toHaveBeenCalled(); + expect(events.insertMany).toHaveBeenCalledWith([ + expect.objectContaining({ message: expect.stringContaining('guard_stopped') }), + ]); + }); + + it('runs cycles across agents, isolates failures and prunes old events', async () => { + agents.findAllWithEgressGuards.mockResolvedValue([{ ...agent, id: 'broken', egressGuardContainerId: 'x' }, agent]); + docker.inspectContainerSummary.mockImplementationOnce(async () => { + throw new Error('docker down'); + }); + + await service.runCycle(); + + expect(events.insertMany).toHaveBeenCalled(); + expect(events.deleteCreatedBefore).toHaveBeenCalledWith(expect.any(Date)); + expect(egress.removeOrphanedGuards).toHaveBeenCalled(); + }); + + it('does not start polling when disabled', () => { + process.env['AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED'] = 'false'; + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + service.onModuleInit(); + expect(setIntervalSpy).not.toHaveBeenCalled(); + + delete process.env['AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED']; + service.onModuleInit(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + service.onModuleDestroy(); + setIntervalSpy.mockRestore(); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts new file mode 100644 index 000000000..9437198fd --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress-events-collector.service.ts @@ -0,0 +1,290 @@ +import { normalizeEffectiveEgressPolicy } from '@forepath/agenstra/shared/util-opencode-config'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; + +import { + EGRESS_GUARD_CLI, + resolveEgressEventsPollIntervalMs, + resolveEgressEventsRetentionHours, +} from '../constants/egress-guard.constants'; +import { AgentEntity } from '../entities/agent.entity'; +import { AgentEgressEventsRepository, type NewAgentEgressEvent } from '../repositories/agent-egress-events.repository'; +import { AgentsRepository } from '../repositories/agents.repository'; +import { + aggregateEgressObservations, + compareDockerTimestamps, + diffEgressCounters, + parseEgressCounters, + parseGuardLogLine, + splitDockerTimestamp, + type EgressGuardObservation, +} from '../utils/egress-guard-log.utils'; + +import { AgentEgressService } from './agent-egress.service'; +import { DockerService } from './docker.service'; + +const PRUNE_EVERY_MS = 10 * 60 * 1000; + +/** + * Background collector for egress guard decisions and health. + * + * Every poll it reads each guard's new log lines (Squid access log, dnsmasq query log, supervisor + * events) and netfilter counters, aggregates identical decisions and stores them in + * `agent_egress_events` for the controller's audit ingestion. It also keeps the topology healthy: + * a stopped guard is started, agents holding a stale namespace are restarted, and a missing guard is + * recreated (fail closed meanwhile: the agent's namespace has no route without its guard). + */ +@Injectable() +export class AgentEgressEventsCollectorService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AgentEgressEventsCollectorService.name); + private interval?: NodeJS.Timeout; + private running = false; + private lastPruneAt = 0; + private readonly counterSnapshots = new Map>(); + + constructor( + private readonly agentsRepository: AgentsRepository, + private readonly eventsRepository: AgentEgressEventsRepository, + private readonly dockerService: DockerService, + private readonly agentEgress: AgentEgressService, + ) {} + + onModuleInit(): void { + if ((process.env['AGENT_EGRESS_EVENTS_COLLECTOR_ENABLED'] ?? 'true').trim().toLowerCase() === 'false') { + return; + } + + this.interval = setInterval(() => void this.runCycle(), resolveEgressEventsPollIntervalMs()); + this.interval.unref?.(); + } + + onModuleDestroy(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = undefined; + } + } + + /** One collection cycle over every guarded agent (re-entrancy safe). */ + async runCycle(): Promise { + if (this.running) { + return; + } + + this.running = true; + + try { + const agents = await this.agentsRepository.findAllWithEgressGuards(); + + for (const agent of agents) { + try { + await this.collectForAgent(agent); + } catch (error: unknown) { + this.logger.warn(`Egress collection failed for agent ${agent.id}: ${(error as Error).message}`); + } + } + + if (Date.now() - this.lastPruneAt > PRUNE_EVERY_MS) { + this.lastPruneAt = Date.now(); + await this.prune(); + } + } catch (error: unknown) { + this.logger.warn(`Egress collection cycle failed: ${(error as Error).message}`); + } finally { + this.running = false; + } + } + + async prune(): Promise { + const cutoff = new Date(Date.now() - resolveEgressEventsRetentionHours() * 60 * 60 * 1000); + const removed = await this.eventsRepository.deleteCreatedBefore(cutoff); + + try { + await this.agentEgress.removeOrphanedGuards(); + } catch (error: unknown) { + this.logger.debug(`Orphaned guard cleanup skipped: ${(error as Error).message}`); + } + + return removed; + } + + async collectForAgent(agent: AgentEntity): Promise { + const guardId = agent.egressGuardContainerId; + + if (!guardId) { + return 0; + } + + const policy = agent.egressPolicy ? normalizeEffectiveEgressPolicy(agent.egressPolicy) : null; + const mode = policy?.mode ?? 'off'; + const guard = await this.dockerService.inspectContainerSummary(guardId); + const observations: EgressGuardObservation[] = []; + + if (!guard) { + observations.push({ + kind: 'guard', + decision: 'error', + occurredAt: new Date(), + host: null, + rule: 'guard', + message: 'guard_missing: egress guard container not found; recreating it (agent egress is blocked meanwhile)', + count: 1, + }); + await this.persist(agent, observations, mode); + this.counterSnapshots.delete(guardId); + + try { + await this.agentEgress.recoverMissingGuard(agent); + } catch (error: unknown) { + this.logger.warn(`Failed to recover egress guard for agent ${agent.id}: ${(error as Error).message}`); + } + + return observations.length; + } + + const agentContainer = agent.containerId + ? await this.dockerService.inspectContainerSummary(agent.containerId) + : null; + + if (!guard.running && agentContainer?.running) { + observations.push({ + kind: 'guard', + decision: 'error', + occurredAt: new Date(), + host: null, + rule: 'guard', + message: 'guard_stopped: egress guard was not running while its agent was; restarting both', + count: 1, + }); + + try { + await this.agentEgress.beforeAgentStart(agent); + } catch (error: unknown) { + this.logger.warn(`Failed to restart egress guard for agent ${agent.id}: ${(error as Error).message}`); + } + } + + if (agentContainer?.networkMode === `container:${guardId}`) { + try { + await this.agentEgress.refreshAgentNamespaceIfStale(agent, guardId); + } catch (error: unknown) { + this.logger.warn(`Failed to refresh agent namespace for ${agent.id}: ${(error as Error).message}`); + } + } + + if (guard.running) { + const cursor = await this.collectLogs(agent, guardId, mode, policy, observations); + + await this.collectCounters(guardId, mode, observations); + + if (cursor && cursor !== agent.egressLogCursor) { + await this.agentsRepository.update(agent.id, { egressLogCursor: cursor }); + } + } + + return await this.persist(agent, observations, mode); + } + + private async collectLogs( + agent: AgentEntity, + guardId: string, + mode: ReturnType['mode'], + policy: ReturnType | null, + observations: EgressGuardObservation[], + ): Promise { + const cursor = agent.egressLogCursor ?? null; + const sinceSeconds = cursor ? Math.floor(new Date(cursor).getTime() / 1000) : 0; + const raw = await this.dockerService.getContainerLogsSince( + guardId, + Number.isFinite(sinceSeconds) ? sinceSeconds : 0, + ); + let latest = cursor; + + for (const rawLine of raw.split('\n')) { + const entry = splitDockerTimestamp(rawLine); + + if (!entry || (cursor && compareDockerTimestamps(entry.timestamp, cursor) <= 0)) { + continue; + } + + if (!latest || compareDockerTimestamps(entry.timestamp, latest) > 0) { + latest = entry.timestamp; + } + + const observation = parseGuardLogLine(entry, mode, policy); + + if (observation) { + observations.push(observation); + } + } + + return latest; + } + + private async collectCounters( + guardId: string, + mode: ReturnType['mode'], + observations: EgressGuardObservation[], + ): Promise { + let output: string; + + try { + output = await this.dockerService.sendCommandToContainer( + guardId, + [EGRESS_GUARD_CLI, 'counters'], + undefined, + true, + { + user: '0', + }, + ); + } catch { + return; + } + + const samples = parseEgressCounters(output); + const { observations: counted, snapshot } = diffEgressCounters( + this.counterSnapshots.get(guardId) ?? null, + samples, + mode, + new Date(), + ); + + this.counterSnapshots.set(guardId, snapshot); + observations.push(...counted); + } + + private async persist( + agent: AgentEntity, + observations: readonly EgressGuardObservation[], + mode: string, + ): Promise { + if (observations.length === 0) { + return 0; + } + + const rows: NewAgentEgressEvent[] = aggregateEgressObservations(observations).map((observation) => ({ + agentId: agent.id, + kind: observation.kind, + decision: observation.decision, + host: observation.host?.slice(0, 255) ?? null, + rule: observation.rule, + matchedEntry: observation.matchedEntry?.slice(0, 255) ?? null, + matchedSource: observation.matchedSource ?? null, + method: observation.method?.slice(0, 32) ?? null, + httpStatus: observation.httpStatus ?? null, + sampleUrl: observation.url ?? null, + count: Math.min(observation.count, 2_147_483_647), + bytesIn: String(observation.bytesIn ?? 0), + bytesOut: String(observation.bytesOut ?? 0), + firstSeenAt: observation.firstSeenAt, + lastSeenAt: observation.lastSeenAt, + mode, + policyRevision: agent.egressPolicyRevision ?? null, + message: observation.message ?? null, + })); + + await this.eventsRepository.insertMany(rows); + + return rows.length; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.spec.ts new file mode 100644 index 000000000..510dfa839 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.spec.ts @@ -0,0 +1,474 @@ +import { resolveEffectiveEgressPolicy } from '@forepath/agenstra/shared/util-opencode-config'; + +import { computeEgressPolicyRevision } from '../utils/egress-guard-config.utils'; + +import { AgentEgressService, EGRESS_GUARDED_AGENT_CAP_DROP, withAgentEgressLock } from './agent-egress.service'; + +const enforce = resolveEffectiveEgressPolicy([ + { source: 'global', overlay: { egress: { mode: 'enforce', deny_hosts: ['example.org'] } } }, +]); +const off = resolveEffectiveEgressPolicy([]); + +describe('AgentEgressService', () => { + let docker: Record; + let repository: Record; + let service: AgentEgressService; + let guardStatus: Record; + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env['AGENT_EGRESS_GUARD_ENABLED']; + process.env['AGENT_DOCKER_NETWORK'] = 'agent-net'; + guardStatus = { revision: '', mode: '', applied: '', squid: true, dnsmasq: true }; + docker = { + ensureNetworkExists: jest.fn().mockResolvedValue(undefined), + createEgressGuardContainer: jest.fn().mockResolvedValue('guard-1'), + inspectContainerSummary: jest.fn(async (id: string) => ({ + id, + running: true, + startedAt: new Date(1000), + networkMode: id === 'agent-container' ? 'agent-net' : 'agent-net', + labels: {}, + })), + sendCommandToContainer: jest.fn(async (_id: string, argv: string[]) => + argv[1] === 'status' ? `${JSON.stringify(guardStatus)}\n` : '', + ), + putFilesIntoContainer: jest.fn().mockResolvedValue(undefined), + startContainer: jest.fn().mockResolvedValue(undefined), + stopContainer: jest.fn().mockResolvedValue(undefined), + restartContainer: jest.fn().mockResolvedValue(undefined), + deleteContainer: jest.fn().mockResolvedValue(undefined), + updateContainer: jest.fn().mockResolvedValue('agent-container-2'), + listContainersByLabel: jest.fn().mockResolvedValue([]), + }; + repository = { + findById: jest.fn(), + update: jest.fn().mockResolvedValue({}), + findAllWithEgressGuards: jest.fn().mockResolvedValue([]), + }; + service = new AgentEgressService(repository as never, docker as never); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('buildProxyEnv', () => { + it('points every proxy variable at the guard while enforcing', () => { + expect(service.buildProxyEnv(enforce, true, { NO_PROXY: 'internal.corp, localhost' })).toEqual({ + HTTP_PROXY: 'http://127.0.0.1:3128', + HTTPS_PROXY: 'http://127.0.0.1:3128', + http_proxy: 'http://127.0.0.1:3128', + https_proxy: 'http://127.0.0.1:3128', + NO_PROXY: 'localhost,127.0.0.1,::1,internal.corp', + no_proxy: 'localhost,127.0.0.1,::1,internal.corp', + }); + }); + + it('only removes lower-case variants without an enforcing guard', () => { + expect(service.buildProxyEnv(off, true, {})).toEqual({ + http_proxy: undefined, + https_proxy: undefined, + no_proxy: undefined, + }); + expect(service.buildProxyEnv(enforce, false, {})).toEqual({ + http_proxy: undefined, + https_proxy: undefined, + no_proxy: undefined, + }); + }); + }); + + it('resolves the upstream proxy from layered network secrets', () => { + expect(service.resolveUpstream({ HTTPS_PROXY: 'http://proxy:8080', NO_PROXY: 'a.corp' })).toEqual({ + host: 'proxy', + port: 8080, + bypassHosts: ['a.corp'], + }); + expect(service.resolveUpstream({})).toBeNull(); + }); + + describe('applyPolicyForAgent', () => { + it('stays inactive without a guard when the policy is off', async () => { + repository.findById.mockResolvedValue({ id: 'agent-1', containerId: 'agent-container' }); + + const result = await service.applyPolicyForAgent('agent-1', off, {}); + + expect(result).toEqual(expect.objectContaining({ ok: true, applied: false, revision: null })); + expect(result.joinGuardId).toBeUndefined(); + expect(docker.createEgressGuardContainer).not.toHaveBeenCalled(); + expect(repository.update).toHaveBeenCalledWith('agent-1', expect.objectContaining({ egressStatus: 'inactive' })); + }); + + it('creates a guard, applies the bundle and asks the caller to join the namespace', async () => { + repository.findById.mockResolvedValue({ id: 'agent-1', containerId: 'agent-container' }); + + const result = await service.applyPolicyForAgent('agent-1', enforce, {}); + + expect(docker.ensureNetworkExists).toHaveBeenCalledWith('agent-net'); + expect(docker.createEgressGuardContainer).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'agenstra-egress-agent-1', + network: 'agent-net', + labels: { 'io.agenstra.egress-guard': '1', 'io.agenstra.agent-id': 'agent-1' }, + publishPorts: [4096, 6080], + }), + ); + expect(docker.sendCommandToContainer).toHaveBeenCalledWith( + 'guard-1', + ['/usr/local/bin/agenstra-egress', 'prepare'], + undefined, + true, + { user: '0' }, + ); + expect(docker.putFilesIntoContainer).toHaveBeenCalledWith( + 'guard-1', + '/var/lib/agenstra-egress/staged', + expect.arrayContaining([expect.objectContaining({ name: 'squid.conf', mode: 0o640 })]), + ); + expect(docker.sendCommandToContainer).toHaveBeenCalledWith( + 'guard-1', + ['/usr/local/bin/agenstra-egress', 'apply'], + undefined, + true, + { user: '0' }, + ); + expect(result).toEqual( + expect.objectContaining({ ok: true, applied: true, joinGuardId: 'guard-1', revision: expect.any(String) }), + ); + expect(result.proxyEnv['HTTPS_PROXY']).toBe('http://127.0.0.1:3128'); + expect(repository.update).toHaveBeenCalledWith('agent-1', { + egressGuardContainerId: 'guard-1', + egressLogCursor: null, + }); + expect(repository.update).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ + egressStatus: 'active', + egressPolicyRevision: result.revision, + egressLastError: null, + }), + ); + }); + + it('skips re-applying an unchanged revision on a joined guard', async () => { + const revision = computeEgressPolicyRevision(enforce, null); + + guardStatus = { revision, mode: 'enforce', applied: revision, squid: true, dnsmasq: true }; + repository.findById.mockResolvedValue({ + id: 'agent-1', + containerId: 'agent-container', + egressGuardContainerId: 'guard-1', + }); + docker.inspectContainerSummary.mockImplementation(async (id: string) => ({ + id, + running: true, + startedAt: new Date(1000), + networkMode: id === 'agent-container' ? 'container:guard-1' : 'agent-net', + labels: {}, + })); + + const result = await service.applyPolicyForAgent('agent-1', enforce, {}); + + expect(result).toEqual(expect.objectContaining({ ok: true, applied: false, revision })); + expect(result.joinGuardId).toBeUndefined(); + expect(docker.putFilesIntoContainer).not.toHaveBeenCalled(); + }); + + it('keeps the guard (pass-through) when the policy switches to off', async () => { + repository.findById.mockResolvedValue({ + id: 'agent-1', + containerId: 'agent-container', + egressGuardContainerId: 'guard-1', + }); + + const result = await service.applyPolicyForAgent('agent-1', off, {}); + + expect(result.ok).toBe(true); + expect(docker.putFilesIntoContainer).toHaveBeenCalled(); + expect(repository.update).toHaveBeenCalledWith('agent-1', expect.objectContaining({ egressStatus: 'inactive' })); + expect(result.proxyEnv['HTTP_PROXY']).toBeUndefined(); + }); + + it('reports failures, keeps the previous proxy env and marks the agent failed', async () => { + repository.findById.mockResolvedValue({ + id: 'agent-1', + containerId: 'agent-container', + egressGuardContainerId: 'guard-1', + egressPolicy: enforce, + egressPolicyRevision: 'old', + }); + docker.inspectContainerSummary.mockImplementation(async (id: string) => ({ + id, + running: true, + startedAt: new Date(1000), + networkMode: id === 'agent-container' ? 'container:guard-1' : 'agent-net', + labels: {}, + })); + docker.sendCommandToContainer.mockImplementation(async (_id: string, argv: string[]) => { + if (argv[1] === 'apply') { + throw new Error('squid configuration rejected'); + } + + return argv[1] === 'status' ? JSON.stringify(guardStatus) : ''; + }); + + const result = await service.applyPolicyForAgent('agent-1', enforce, {}); + + expect(result).toEqual( + expect.objectContaining({ ok: false, error: 'squid configuration rejected', revision: 'old', applied: false }), + ); + expect(result.proxyEnv['HTTPS_PROXY']).toBe('http://127.0.0.1:3128'); + expect(repository.update).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ egressStatus: 'failed', egressLastError: 'squid configuration rejected' }), + ); + // Inside the guard namespace the last known good bundle keeps enforcing: the agent keeps running. + expect(docker.stopContainer).not.toHaveBeenCalled(); + }); + + it('fails closed: stops a running agent outside any guard namespace when the guard cannot be provisioned', async () => { + docker.createEgressGuardContainer.mockRejectedValue(new Error('image not found')); + repository.findById.mockResolvedValue({ id: 'agent-1', containerId: 'agent-container', egressPolicy: null }); + + const result = await service.applyPolicyForAgent('agent-1', enforce, {}); + + expect(result.ok).toBe(false); + expect(docker.stopContainer).toHaveBeenCalledWith('agent-container'); + expect(repository.update).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ + egressStatus: 'failed', + egressLastError: 'image not found (agent stopped until its egress guard is available)', + }), + ); + }); + + it('does not stop agents when the failed policy does not require a guard', async () => { + docker.inspectContainerSummary.mockImplementation(async (id: string) => + id === 'guard-1' ? { id, running: false, startedAt: null, networkMode: 'agent-net', labels: {} } : null, + ); + docker.startContainer.mockRejectedValue(new Error('boom')); + repository.findById.mockResolvedValue({ + id: 'agent-1', + containerId: 'agent-container', + egressGuardContainerId: 'guard-1', + }); + + const result = await service.applyPolicyForAgent('agent-1', { ...enforce, mode: 'off' }, {}); + + expect(result.ok).toBe(false); + expect(docker.stopContainer).not.toHaveBeenCalled(); + }); + + it('refuses to create guards when the feature is disabled', async () => { + process.env['AGENT_EGRESS_GUARD_ENABLED'] = 'false'; + repository.findById.mockResolvedValue({ id: 'agent-1', containerId: 'agent-container' }); + + const result = await service.applyPolicyForAgent('agent-1', enforce, {}); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/disabled/); + expect(docker.createEgressGuardContainer).not.toHaveBeenCalled(); + }); + + it('fails cleanly for unknown agents', async () => { + repository.findById.mockResolvedValue(null); + + await expect(service.applyPolicyForAgent('missing', enforce, {})).resolves.toEqual( + expect.objectContaining({ ok: false }), + ); + }); + }); + + it('provisions a guard for a new agent and removes it when apply fails', async () => { + await expect(service.provisionGuardForNewAgent(enforce, null)).resolves.toEqual({ + guardId: 'guard-1', + revision: computeEgressPolicyRevision(enforce, null), + }); + + docker.sendCommandToContainer.mockImplementation(async (_id: string, argv: string[]) => { + if (argv[1] === 'apply') { + throw new Error('boom'); + } + + return argv[1] === 'status' ? JSON.stringify(guardStatus) : ''; + }); + + await expect(service.provisionGuardForNewAgent(enforce, null)).rejects.toThrow('boom'); + expect(docker.deleteContainer).toHaveBeenCalledWith('guard-1'); + }); + + it('removes a stale guard holding the container name and retries', async () => { + docker.createEgressGuardContainer + .mockRejectedValueOnce(Object.assign(new Error('conflict'), { statusCode: 409 })) + .mockResolvedValueOnce('guard-2'); + docker.inspectContainerSummary.mockImplementation(async (id: string) => ({ + id: id === 'agenstra-egress-agent-1' ? 'stale' : id, + running: true, + startedAt: new Date(1000), + networkMode: 'agent-net', + labels: { 'io.agenstra.egress-guard': '1' }, + })); + repository.findById.mockResolvedValue({ id: 'agent-1', containerId: 'agent-container' }); + + const result = await service.applyPolicyForAgent('agent-1', enforce, {}); + + expect(docker.deleteContainer).toHaveBeenCalledWith('stale'); + expect(result.joinGuardId).toBe('guard-2'); + }); + + describe('lifecycle', () => { + const agent = { id: 'agent-1', containerId: 'agent-container', egressGuardContainerId: 'guard-1' } as never; + + it('starts a stopped guard before the agent and restarts it before agent restarts', async () => { + docker.inspectContainerSummary.mockResolvedValue({ id: 'guard-1', running: false, startedAt: null, labels: {} }); + + await service.beforeAgentStart(agent); + expect(docker.startContainer).toHaveBeenCalledWith('guard-1'); + + await service.beforeAgentRestart(agent); + expect(docker.restartContainer).toHaveBeenCalledWith('guard-1'); + }); + + it('throws when the guard is missing', async () => { + docker.inspectContainerSummary.mockResolvedValue(null); + + await expect(service.beforeAgentStart(agent)).rejects.toThrow(/missing/); + }); + + it('stops / removes the guard after the agent', async () => { + await service.afterAgentStop(agent); + await service.afterAgentRemove(agent); + + expect(docker.stopContainer).toHaveBeenCalledWith('guard-1'); + expect(docker.deleteContainer).toHaveBeenCalledWith('guard-1'); + }); + + it('refuses to start agents whose policy requires a guard that does not exist yet', async () => { + const unguarded = { id: 'agent-3', containerId: 'c', egressPolicy: enforce } as never; + + await expect(service.beforeAgentStart(unguarded)).rejects.toThrow(/requires an egress guard/); + await expect(service.beforeAgentRestart(unguarded)).rejects.toThrow(/requires an egress guard/); + }); + + it('is a no-op for agents without a guard', async () => { + const plain = { id: 'agent-2', containerId: 'c' } as never; + + await service.beforeAgentStart(plain); + await service.beforeAgentRestart(plain); + await service.afterAgentStop(plain); + await service.afterAgentRemove(plain); + + expect(docker.inspectContainerSummary).not.toHaveBeenCalled(); + }); + + it('restarts agents that still hold a stale namespace', async () => { + docker.inspectContainerSummary.mockImplementation(async (id: string) => ({ + id, + running: true, + startedAt: id === 'guard-1' ? new Date(5000) : new Date(1000), + labels: {}, + })); + + await expect(service.refreshAgentNamespaceIfStale(agent, 'guard-1')).resolves.toBe(true); + expect(docker.restartContainer).toHaveBeenCalledWith('agent-container'); + + docker.inspectContainerSummary.mockImplementation(async (id: string) => ({ + id, + running: true, + startedAt: id === 'guard-1' ? new Date(1000) : new Date(5000), + labels: {}, + })); + await expect(service.refreshAgentNamespaceIfStale(agent, 'guard-1')).resolves.toBe(false); + }); + }); + + it('recovers a missing guard and moves the agent into the new namespace', async () => { + repository.findById.mockResolvedValue({ + id: 'agent-1', + containerId: 'agent-container', + egressGuardContainerId: 'gone', + egressPolicy: enforce, + }); + docker.inspectContainerSummary.mockImplementation(async (id: string) => + id === 'gone' ? null : { id, running: true }, + ); + + await expect( + service.recoverMissingGuard({ + id: 'agent-1', + containerId: 'agent-container', + egressGuardContainerId: 'gone', + } as never), + ).resolves.toBe('guard-1'); + expect(docker.updateContainer).toHaveBeenCalledWith('agent-container', { + sharedNetworkContainerId: 'guard-1', + capDrop: [...EGRESS_GUARDED_AGENT_CAP_DROP], + }); + expect(repository.update).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ + containerId: 'agent-container-2', + egressGuardContainerId: 'guard-1', + egressStatus: 'active', + }), + ); + }); + + it('removes only old unreferenced guards', async () => { + docker.listContainersByLabel.mockResolvedValue([ + { id: 'referenced-guard', labels: {}, createdAt: new Date(0) }, + { id: 'orphan', labels: {}, createdAt: new Date(0) }, + { id: 'fresh', labels: {}, createdAt: new Date() }, + ]); + repository.findAllWithEgressGuards.mockResolvedValue([{ egressGuardContainerId: 'referenced' }]); + + await expect(service.removeOrphanedGuards()).resolves.toBe(1); + expect(docker.deleteContainer).toHaveBeenCalledTimes(1); + expect(docker.deleteContainer).toHaveBeenCalledWith('orphan'); + }); + + it('describes status from the database and optionally live from the guard', async () => { + const agent = { + id: 'agent-1', + egressGuardContainerId: 'abcdef1234567890', + egressStatus: 'active', + egressPolicy: enforce, + egressPolicyRevision: 'rev', + egressAppliedAt: new Date(1), + egressLastError: null, + } as never; + + await expect(service.describe(agent)).resolves.toEqual({ + status: 'active', + mode: 'enforce', + guardContainerId: 'abcdef123456', + policyRevision: 'rev', + appliedAt: new Date(1), + lastError: null, + }); + + guardStatus = { revision: 'rev', mode: 'enforce', applied: 'rev', squid: true, dnsmasq: false }; + await expect(service.describe(agent, true)).resolves.toEqual( + expect.objectContaining({ + guard: expect.objectContaining({ running: true, activeRevision: 'rev', proxyRunning: true, dnsRunning: false }), + }), + ); + }); + + it('serializes work per agent', async () => { + const order: string[] = []; + const first = withAgentEgressLock('a', async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push('first'); + }); + const second = withAgentEgressLock('a', async () => { + order.push('second'); + }); + + await Promise.all([first, second]); + expect(order).toEqual(['first', 'second']); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts new file mode 100644 index 000000000..8b2ab8807 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-egress.service.ts @@ -0,0 +1,679 @@ +import { + egressPolicyRequiresGuard, + resolveEffectiveEgressPolicy, + type EffectiveEgressPolicy, +} from '@forepath/agenstra/shared/util-opencode-config'; +import { Injectable, Logger } from '@nestjs/common'; + +import { + EGRESS_GUARD_AGENT_LABEL, + EGRESS_GUARD_CLI, + EGRESS_GUARD_FORWARDED_AGENT_PORTS, + EGRESS_GUARD_LABEL, + EGRESS_GUARD_NAME_PREFIX, + EGRESS_GUARD_NO_PROXY_DEFAULTS, + EGRESS_GUARD_PROXY_ENV_KEYS, + EGRESS_GUARD_PROXY_URL, + EGRESS_GUARD_READY_TIMEOUT_MS, + EGRESS_GUARD_STAGED_DIR_NAME, + EGRESS_GUARD_STATE_DIR, + resolveEgressGuardImage, + resolveEgressGuardMemoryBytes, +} from '../constants/egress-guard.constants'; +import type { AgentEgressStatusDto } from '../dto/agent-egress.dto'; +import { AgentEntity } from '../entities/agent.entity'; +import { AgentsRepository } from '../repositories/agents.repository'; +import { + computeEgressPolicyRevision, + parseEgressUpstreamProxy, + renderEgressGuardArtifacts, + type EgressUpstreamProxy, +} from '../utils/egress-guard-config.utils'; + +import { DockerService } from './docker.service'; + +/** Capabilities dropped from agents that join a guard namespace (no raw sockets / netfilter changes). */ +export const EGRESS_GUARDED_AGENT_CAP_DROP = ['NET_ADMIN', 'NET_RAW'] as const; + +export interface GuardRuntimeStatus { + revision: string; + mode: string; + applied: string; + squid: boolean; + dnsmasq: boolean; +} + +export interface AgentEgressApplyResult { + ok: boolean; + error?: string; + /** Guard the agent must join (container recreate with `container:` network mode). */ + joinGuardId?: string; + /** Proxy env overrides for the agent container (`undefined` removes a key). */ + proxyEnv: Record; + /** Whether an apply happened (policy revision changed). */ + applied: boolean; + revision: string | null; +} + +const egressLocks = new Map>(); + +/** Serialize guard topology / policy work per agent (sync, lifecycle and the background monitor). */ +export async function withAgentEgressLock(agentId: string, task: () => Promise): Promise { + const previous = egressLocks.get(agentId) ?? Promise.resolve(); + const run = previous.catch(() => undefined).then(task); + const tail = run.catch(() => undefined); + + egressLocks.set(agentId, tail); + + try { + return await run; + } finally { + if (egressLocks.get(agentId) === tail) { + egressLocks.delete(agentId); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shortError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + + return message.length > 1000 ? `${message.slice(0, 1000)}…` : message; +} + +/** + * Per-agent egress guard sidecar orchestration. + * + * The guard is created lazily the first time an agent's effective egress mode is not `off`. From then + * on the agent lives in the guard's network namespace; switching back to `off` keeps the topology and + * applies a pass-through policy (no container churn, instant re-enable). + */ +@Injectable() +export class AgentEgressService { + private readonly logger = new Logger(AgentEgressService.name); + private statusChangedBroadcaster?: (agentId: string) => void; + + constructor( + private readonly agentsRepository: AgentsRepository, + private readonly dockerService: DockerService, + ) {} + + private get env(): NodeJS.ProcessEnv { + return process.env; + } + + registerStatusChangedBroadcaster(broadcaster: (agentId: string) => void): void { + this.statusChangedBroadcaster = broadcaster; + } + + /** Feature switch (`AGENT_EGRESS_GUARD_ENABLED=false` disables guard provisioning on this manager). */ + isEnabled(): boolean { + return (this.env['AGENT_EGRESS_GUARD_ENABLED'] ?? 'true').trim().toLowerCase() !== 'false'; + } + + /** Parent proxy for the guard from layered network secrets (HTTPS_PROXY preferred). */ + resolveUpstream(secrets: Record): EgressUpstreamProxy | null { + return parseEgressUpstreamProxy(secrets['HTTPS_PROXY'] || secrets['HTTP_PROXY'], secrets['NO_PROXY']); + } + + /** + * Proxy environment for the agent container. While a guard enforces (mode ≠ off), every proxy variant + * points at the guard's explicit proxy; otherwise lower-case variants are removed and upper-case values + * fall back to the layered network secrets (handled by the caller). + */ + buildProxyEnv( + policy: EffectiveEgressPolicy | null | undefined, + hasGuard: boolean, + secrets: Record, + ): Record { + const env: Record = {}; + + if (!hasGuard || !egressPolicyRequiresGuard(policy)) { + for (const key of EGRESS_GUARD_PROXY_ENV_KEYS) { + if (key === key.toLowerCase()) { + env[key] = undefined; + } + } + + return env; + } + + const noProxy = [ + ...EGRESS_GUARD_NO_PROXY_DEFAULTS, + ...(secrets['NO_PROXY'] ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ]; + const noProxyValue = [...new Set(noProxy)].join(','); + + env['HTTP_PROXY'] = EGRESS_GUARD_PROXY_URL; + env['HTTPS_PROXY'] = EGRESS_GUARD_PROXY_URL; + env['http_proxy'] = EGRESS_GUARD_PROXY_URL; + env['https_proxy'] = EGRESS_GUARD_PROXY_URL; + env['NO_PROXY'] = noProxyValue; + env['no_proxy'] = noProxyValue; + + return env; + } + + // ------------------------------------------------------------------------- + // Guard container primitives + // ------------------------------------------------------------------------- + + private async createGuard(agentId: string | null): Promise { + const network = this.env['AGENT_DOCKER_NETWORK']?.trim() || undefined; + + if (network) { + await this.dockerService.ensureNetworkExists(network); + } + + const suffix = agentId ?? `pending-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const name = `${EGRESS_GUARD_NAME_PREFIX}${suffix}`; + const create = () => + this.dockerService.createEgressGuardContainer({ + name, + image: resolveEgressGuardImage(this.env), + labels: { + [EGRESS_GUARD_LABEL]: '1', + ...(agentId ? { [EGRESS_GUARD_AGENT_LABEL]: agentId } : {}), + }, + network, + publishPorts: EGRESS_GUARD_FORWARDED_AGENT_PORTS, + memoryBytes: resolveEgressGuardMemoryBytes(this.env), + }); + + try { + return await create(); + } catch (error: unknown) { + if ((error as { statusCode?: number }).statusCode !== 409) { + throw error; + } + + // A stale guard with the same name (e.g. left behind by a crashed recovery) blocks the name. + const stale = await this.dockerService.inspectContainerSummary(name); + + if (!stale || stale.labels[EGRESS_GUARD_LABEL] !== '1') { + throw error; + } + + await this.removeGuardContainer(stale.id); + + return await create(); + } + } + + async getGuardStatus(guardId: string): Promise { + try { + const output = await this.dockerService.sendCommandToContainer( + guardId, + [EGRESS_GUARD_CLI, 'status'], + undefined, + true, + { + user: '0', + }, + ); + const line = output + .split('\n') + .map((entry) => entry.trim()) + .reverse() + .find((entry) => entry.startsWith('{')); + + return line ? (JSON.parse(line) as GuardRuntimeStatus) : null; + } catch { + return null; + } + } + + private async waitForGuardReady(guardId: string): Promise { + const deadline = Date.now() + EGRESS_GUARD_READY_TIMEOUT_MS; + let last: GuardRuntimeStatus | null = null; + + while (Date.now() < deadline) { + last = await this.getGuardStatus(guardId); + + if (last?.squid && last.dnsmasq) { + return; + } + + await sleep(500); + } + + throw new Error( + `Egress guard ${guardId.slice(0, 12)} did not become ready (squid=${last?.squid ?? false}, dnsmasq=${last?.dnsmasq ?? false})`, + ); + } + + /** Render, upload, validate and activate a policy bundle (the guard keeps the last known good on failure). */ + async applyPolicyToGuard( + guardId: string, + policy: EffectiveEgressPolicy, + upstream: EgressUpstreamProxy | null, + force = false, + ): Promise<{ revision: string; applied: boolean }> { + const revision = computeEgressPolicyRevision(policy, upstream); + + if (!force) { + const status = await this.getGuardStatus(guardId); + + if (status?.revision === revision && status.applied === revision && status.squid && status.dnsmasq) { + return { revision, applied: false }; + } + } + + const artifacts = renderEgressGuardArtifacts(policy, upstream); + + await this.dockerService.sendCommandToContainer(guardId, [EGRESS_GUARD_CLI, 'prepare'], undefined, true, { + user: '0', + }); + await this.dockerService.putFilesIntoContainer( + guardId, + `${EGRESS_GUARD_STATE_DIR}/${EGRESS_GUARD_STAGED_DIR_NAME}`, + Object.entries(artifacts.files).map(([name, content]) => ({ name, content, mode: 0o640 })), + ); + await this.dockerService.sendCommandToContainer(guardId, [EGRESS_GUARD_CLI, 'apply'], undefined, true, { + user: '0', + }); + + return { revision: artifacts.revision, applied: true }; + } + + /** + * Create a guard and apply the policy before the agent exists (fail closed at agent creation). + * The guard is removed again when provisioning fails. + */ + async provisionGuardForNewAgent( + policy: EffectiveEgressPolicy, + upstream: EgressUpstreamProxy | null, + ): Promise<{ guardId: string; revision: string }> { + const guardId = await this.createGuard(null); + + try { + await this.waitForGuardReady(guardId); + const { revision } = await this.applyPolicyToGuard(guardId, policy, upstream, true); + + return { guardId, revision }; + } catch (error) { + await this.removeGuardContainer(guardId); + + throw error; + } + } + + async removeGuardContainer(guardId: string): Promise { + try { + await this.dockerService.deleteContainer(guardId); + } catch (error: unknown) { + this.logger.warn(`Failed to remove egress guard ${guardId.slice(0, 12)}: ${shortError(error)}`); + } + } + + // ------------------------------------------------------------------------- + // Agent orchestration + // ------------------------------------------------------------------------- + + /** + * Bring the agent's guard in line with its effective policy (create / start / apply). + * Does not recreate the agent container; when the agent still runs on the legacy network the result + * carries `joinGuardId` and the caller recreates it (together with its environment changes). + */ + async applyPolicyForAgent( + agentId: string, + policyInput: EffectiveEgressPolicy | null | undefined, + secrets: Record, + ): Promise { + return await withAgentEgressLock(agentId, async () => { + const agent = await this.agentsRepository.findById(agentId); + + if (!agent) { + return { + ok: false, + error: `Agent with ID '${agentId}' not found`, + proxyEnv: {}, + applied: false, + revision: null, + }; + } + + const policy = policyInput ?? agent.egressPolicy ?? resolveEffectiveEgressPolicy([]); + const upstream = this.resolveUpstream(secrets); + const requiresGuard = egressPolicyRequiresGuard(policy); + let guardId = agent.egressGuardContainerId ?? null; + let guard = guardId ? await this.dockerService.inspectContainerSummary(guardId) : null; + + if (guardId && !guard) { + guardId = null; + } + + if (!guardId && !requiresGuard) { + await this.agentsRepository.update(agentId, { + egressPolicy: policy, + egressStatus: 'inactive', + egressLastError: null, + egressPolicyRevision: null, + }); + + return { ok: true, proxyEnv: this.buildProxyEnv(policy, false, secrets), applied: false, revision: null }; + } + + try { + if (!guardId) { + if (!this.isEnabled()) { + throw new Error( + 'Egress guard sidecars are disabled on this agent manager (AGENT_EGRESS_GUARD_ENABLED=false)', + ); + } + + guardId = await this.createGuard(agentId); + guard = await this.dockerService.inspectContainerSummary(guardId); + await this.agentsRepository.update(agentId, { egressGuardContainerId: guardId, egressLogCursor: null }); + } else if (guard && !guard.running) { + await this.dockerService.startContainer(guardId); + } + + await this.waitForGuardReady(guardId); + const { revision, applied } = await this.applyPolicyToGuard(guardId, policy, upstream); + const agentContainer = agent.containerId + ? await this.dockerService.inspectContainerSummary(agent.containerId) + : null; + const joined = agentContainer?.networkMode === `container:${guardId}`; + + await this.agentsRepository.update(agentId, { + egressPolicy: policy, + egressPolicyRevision: revision, + egressStatus: requiresGuard ? 'active' : 'inactive', + egressLastError: null, + egressAppliedAt: applied ? new Date() : (agent.egressAppliedAt ?? new Date()), + }); + + if (joined) { + await this.refreshAgentNamespaceIfStale(agent, guardId); + } + + if (applied) { + this.statusChangedBroadcaster?.(agentId); + } + + return { + ok: true, + ...(joined || !agent.containerId ? {} : { joinGuardId: guardId }), + proxyEnv: this.buildProxyEnv(policy, true, secrets), + applied, + revision, + }; + } catch (error: unknown) { + const message = shortError(error); + + this.logger.warn(`Egress policy apply failed for agent ${agentId}: ${message}`); + const stopped = requiresGuard ? await this.stopUnguardedAgent(agent, guardId) : false; + + await this.agentsRepository.update(agentId, { + egressPolicy: policy, + egressStatus: 'failed', + egressLastError: stopped ? `${message} (agent stopped until its egress guard is available)` : message, + ...(guardId ? { egressGuardContainerId: guardId } : {}), + }); + this.statusChangedBroadcaster?.(agentId); + + // Keep whatever proxy configuration the agent currently has: a guard that already exists still + // enforces its last known good bundle. + return { + ok: false, + error: message, + proxyEnv: this.buildProxyEnv(agent.egressPolicy, !!agent.egressGuardContainerId, secrets), + applied: false, + revision: agent.egressPolicyRevision ?? null, + }; + } + }); + } + + /** + * Fail closed: an agent whose policy requires a guard must never keep running outside a guard + * namespace. Agents already inside a guard namespace keep the guard's last known good bundle. + * The next successful sync recreates (and starts) the agent inside the guard namespace. + */ + private async stopUnguardedAgent(agent: AgentEntity, guardId: string | null): Promise { + if (!agent.containerId) { + return false; + } + + try { + const container = await this.dockerService.inspectContainerSummary(agent.containerId); + + if (!container?.running || (guardId && container.networkMode === `container:${guardId}`)) { + return false; + } + + this.logger.warn(`Stopping agent ${agent.id}: its egress policy requires a guard that could not be provisioned`); + await this.dockerService.stopContainer(agent.containerId); + + return true; + } catch (error: unknown) { + this.logger.error(`Failed to stop unguarded agent ${agent.id}: ${shortError(error)}`); + + return false; + } + } + + /** Refuse to (re)start an agent outside a guard namespace while its policy requires one. */ + private assertGuardNotRequired(agent: AgentEntity): void { + if (egressPolicyRequiresGuard(agent.egressPolicy)) { + throw new Error( + `Agent ${agent.id} requires an egress guard (mode '${agent.egressPolicy?.mode}') that is not provisioned yet; it will be provisioned on the next config sync`, + ); + } + } + + /** + * After the guard was (re)started its network namespace is new: a running agent still holds the old + * namespace (no interfaces → no traffic, fail closed) and must be restarted to join the new one. + */ + async refreshAgentNamespaceIfStale(agent: AgentEntity, guardId: string): Promise { + if (!agent.containerId) { + return false; + } + + const [guard, container] = await Promise.all([ + this.dockerService.inspectContainerSummary(guardId), + this.dockerService.inspectContainerSummary(agent.containerId), + ]); + + if (!guard?.running || !container?.running || !guard.startedAt || !container.startedAt) { + return false; + } + + if (guard.startedAt.getTime() <= container.startedAt.getTime()) { + return false; + } + + this.logger.log(`Restarting agent ${agent.id} to join the restarted egress guard namespace`); + await this.dockerService.restartContainer(agent.containerId); + + return true; + } + + /** Start the guard (if any) before the agent container starts; refreshes stale namespaces. */ + async beforeAgentStart(agent: AgentEntity): Promise { + const guardId = agent.egressGuardContainerId; + + if (!guardId) { + this.assertGuardNotRequired(agent); + + return; + } + + await withAgentEgressLock(agent.id, async () => { + const guard = await this.dockerService.inspectContainerSummary(guardId); + + if (!guard) { + throw new Error(`Egress guard for agent ${agent.id} is missing; it will be recreated on the next config sync`); + } + + if (!guard.running) { + await this.dockerService.startContainer(guardId); + await this.waitForGuardReady(guardId); + } + }); + } + + /** Restart the guard before an agent restart (fresh namespace, re-activates the stored bundle). */ + async beforeAgentRestart(agent: AgentEntity): Promise { + const guardId = agent.egressGuardContainerId; + + if (!guardId) { + this.assertGuardNotRequired(agent); + + return; + } + + await withAgentEgressLock(agent.id, async () => { + const guard = await this.dockerService.inspectContainerSummary(guardId); + + if (!guard) { + throw new Error(`Egress guard for agent ${agent.id} is missing; it will be recreated on the next config sync`); + } + + await this.dockerService.restartContainer(guardId); + await this.waitForGuardReady(guardId); + }); + } + + /** Stop the guard after the agent stopped. */ + async afterAgentStop(agent: AgentEntity): Promise { + if (!agent.egressGuardContainerId) { + return; + } + + try { + await this.dockerService.stopContainer(agent.egressGuardContainerId); + } catch (error: unknown) { + this.logger.warn(`Failed to stop egress guard for agent ${agent.id}: ${shortError(error)}`); + } + } + + /** Remove the guard after the agent container was deleted. */ + async afterAgentRemove(agent: AgentEntity): Promise { + if (agent.egressGuardContainerId) { + await this.removeGuardContainer(agent.egressGuardContainerId); + } + } + + /** + * Recreate a missing guard for an agent living in a dead namespace (manual `docker rm`, host cleanup) + * and move the agent into the new namespace. Uses the stored policy; returns the new guard id. + */ + async recoverMissingGuard(agent: AgentEntity, secrets: Record = {}): Promise { + if (!agent.containerId || !this.isEnabled()) { + return null; + } + + return await withAgentEgressLock(agent.id, async () => { + const fresh = await this.agentsRepository.findById(agent.id); + + if (!fresh?.containerId || !fresh.egressGuardContainerId) { + return null; + } + + if (await this.dockerService.inspectContainerSummary(fresh.egressGuardContainerId)) { + return null; + } + + const policy = fresh.egressPolicy ?? resolveEffectiveEgressPolicy([]); + const guardId = await this.createGuard(fresh.id); + + try { + await this.waitForGuardReady(guardId); + const { revision } = await this.applyPolicyToGuard(guardId, policy, this.resolveUpstream(secrets), true); + const newContainerId = await this.dockerService.updateContainer(fresh.containerId, { + sharedNetworkContainerId: guardId, + capDrop: [...EGRESS_GUARDED_AGENT_CAP_DROP], + }); + + await this.agentsRepository.update(fresh.id, { + containerId: newContainerId, + egressGuardContainerId: guardId, + egressPolicyRevision: revision, + egressStatus: egressPolicyRequiresGuard(policy) ? 'active' : 'inactive', + egressLastError: null, + egressAppliedAt: new Date(), + egressLogCursor: null, + }); + this.statusChangedBroadcaster?.(fresh.id); + + return guardId; + } catch (error) { + await this.removeGuardContainer(guardId); + await this.agentsRepository.update(fresh.id, { egressStatus: 'failed', egressLastError: shortError(error) }); + + throw error; + } + }); + } + + /** Guard containers that no agent references (created > 10 minutes ago, avoids racing provisioning). */ + async removeOrphanedGuards(): Promise { + const guards = await this.dockerService.listContainersByLabel(`${EGRESS_GUARD_LABEL}=1`); + + if (guards.length === 0) { + return 0; + } + + const agents = await this.agentsRepository.findAllWithEgressGuards(); + const referenced = new Set(agents.map((agent) => agent.egressGuardContainerId).filter(Boolean) as string[]); + const cutoff = Date.now() - 10 * 60 * 1000; + let removed = 0; + + for (const guard of guards) { + const isReferenced = [...referenced].some( + (id) => guard.id === id || guard.id.startsWith(id) || id.startsWith(guard.id), + ); + + if (isReferenced || guard.createdAt.getTime() > cutoff) { + continue; + } + + await this.removeGuardContainer(guard.id); + removed += 1; + } + + return removed; + } + + /** Runtime status for API responses (cheap: DB only unless `live` is requested). */ + async describe(agent: AgentEntity, live = false): Promise { + const policy = agent.egressPolicy ?? null; + const base: AgentEgressStatusDto = { + status: agent.egressStatus ?? 'inactive', + mode: policy?.mode ?? 'off', + guardContainerId: agent.egressGuardContainerId ? agent.egressGuardContainerId.slice(0, 12) : null, + policyRevision: agent.egressPolicyRevision ?? null, + appliedAt: agent.egressAppliedAt ?? null, + lastError: agent.egressLastError ?? null, + }; + + if (!live || !agent.egressGuardContainerId) { + return base; + } + + const [summary, runtime] = await Promise.all([ + this.dockerService.inspectContainerSummary(agent.egressGuardContainerId), + this.getGuardStatus(agent.egressGuardContainerId), + ]); + + return { + ...base, + guard: { + running: summary?.running ?? false, + startedAt: summary?.startedAt ?? null, + activeRevision: runtime?.revision || null, + proxyRunning: runtime?.squid ?? false, + dnsRunning: runtime?.dnsmasq ?? false, + }, + }; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.spec.ts new file mode 100644 index 000000000..f4c599f7c --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.spec.ts @@ -0,0 +1,111 @@ +import { AgentMessageFiltersService } from './agent-message-filters.service'; + +describe('AgentMessageFiltersService', () => { + const rules = [ + { + name: 'Drop keys', + pattern: 'BEGIN PRIVATE KEY', + flags: 'g', + direction: 'bidirectional', + action: 'drop', + enabled: true, + source: 'global', + }, + { + name: 'Mask tokens', + pattern: 'token-\\d+', + flags: 'g', + direction: 'incoming', + action: 'replace', + replacement: '[redacted]', + enabled: true, + source: 'workspace', + }, + { + name: 'Flag hello', + pattern: 'hello', + flags: 'gi', + direction: 'outgoing', + action: 'flag', + enabled: true, + source: 'environment', + }, + ]; + let repository: { findById: jest.Mock; update: jest.Mock }; + let service: AgentMessageFiltersService; + + beforeEach(() => { + repository = { + findById: jest.fn().mockResolvedValue({ id: 'agent-1', messageFilters: rules }), + update: jest.fn().mockResolvedValue({}), + }; + service = new AgentMessageFiltersService(repository as never); + }); + + it('returns not filtered without an agent id or rules', async () => { + await expect(service.evaluate(undefined, 'x', 'incoming')).resolves.toEqual({ filtered: false }); + + repository.findById.mockResolvedValue({ id: 'agent-2', messageFilters: null }); + await expect(service.evaluate('agent-2', 'BEGIN PRIVATE KEY', 'incoming')).resolves.toEqual({ filtered: false }); + }); + + it('drops when a drop rule matches and reports the rule with its layer', async () => { + await expect(service.evaluate('agent-1', 'token-1 BEGIN PRIVATE KEY', 'incoming')).resolves.toEqual({ + filtered: true, + action: 'drop', + reason: 'Drop keys (global)', + }); + }); + + it('replaces content and flags by direction', async () => { + await expect(service.evaluate('agent-1', 'use token-42', 'incoming')).resolves.toEqual({ + filtered: true, + action: 'flag', + modifiedMessage: 'use [redacted]', + reason: 'Mask tokens (workspace)', + }); + await expect(service.evaluate('agent-1', 'Hello world', 'outgoing')).resolves.toEqual({ + filtered: true, + action: 'flag', + reason: 'Flag hello (environment)', + }); + await expect(service.evaluate('agent-1', 'use token-42', 'outgoing')).resolves.toEqual({ filtered: false }); + }); + + it('caches rules per agent and refreshes them on store', async () => { + await service.evaluate('agent-1', 'a', 'incoming'); + await service.evaluate('agent-1', 'b', 'incoming'); + + expect(repository.findById).toHaveBeenCalledTimes(1); + + const stored = await service.store('agent-1', [ + { name: 'Only', pattern: 'x', action: 'drop', source: 'workspace' }, + { name: 'Invalid', pattern: '(', action: 'drop' }, + ]); + + expect(stored).toEqual([ + expect.objectContaining({ + name: 'Only', + flags: 'g', + direction: 'bidirectional', + enabled: true, + source: 'workspace', + }), + ]); + expect(repository.update).toHaveBeenCalledWith('agent-1', { messageFilters: stored }); + await expect(service.evaluate('agent-1', 'x', 'outgoing')).resolves.toEqual( + expect.objectContaining({ action: 'drop', reason: 'Only (workspace)' }), + ); + expect(repository.findById).toHaveBeenCalledTimes(1); + + service.invalidate('agent-1'); + await service.evaluate('agent-1', 'x', 'outgoing'); + expect(repository.findById).toHaveBeenCalledTimes(2); + }); + + it('fails open (not filtered) when rules cannot be loaded', async () => { + repository.findById.mockRejectedValue(new Error('db down')); + + await expect(service.evaluate('agent-3', 'x', 'incoming')).resolves.toEqual({ filtered: false }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts new file mode 100644 index 000000000..c797982bd --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-filters.service.ts @@ -0,0 +1,96 @@ +import { + describeMessageFilterRule, + evaluateMessageFilters, + normalizeEffectiveMessageFilters, + type EffectiveMessageFilterRule, +} from '@forepath/agenstra/shared/util-opencode-config'; +import { Injectable, Logger } from '@nestjs/common'; + +import type { FilterResult } from '../providers/chat-filter.interface'; +import { AgentsRepository } from '../repositories/agents.repository'; + +/** + * Per-agent chat message filter rules (layered agent configuration: global → workspace → environment). + * Rules arrive with every effective config sync and are cached in memory per agent. + */ +@Injectable() +export class AgentMessageFiltersService { + private readonly logger = new Logger(AgentMessageFiltersService.name); + private readonly cache = new Map(); + + constructor(private readonly agentsRepository: AgentsRepository) {} + + /** Persist the effective rules for an agent (normalized; invalid rules are dropped). */ + async store(agentId: string, rules: unknown): Promise { + const normalized = normalizeEffectiveMessageFilters(rules); + + await this.agentsRepository.update(agentId, { messageFilters: normalized }); + this.cache.set(agentId, normalized); + + return normalized; + } + + invalidate(agentId?: string): void { + if (agentId) { + this.cache.delete(agentId); + } else { + this.cache.clear(); + } + } + + async getRules(agentId: string): Promise { + const cached = this.cache.get(agentId); + + if (cached) { + return cached; + } + + const agent = await this.agentsRepository.findById(agentId); + const rules = normalizeEffectiveMessageFilters(agent?.messageFilters ?? []); + + this.cache.set(agentId, rules); + + return rules; + } + + /** Evaluate a chat message for a gateway direction; most restrictive outcome wins. */ + async evaluate( + agentId: string | undefined, + message: string, + direction: 'incoming' | 'outgoing', + ): Promise { + if (!agentId) { + return { filtered: false }; + } + + let rules: EffectiveMessageFilterRule[]; + + try { + rules = await this.getRules(agentId); + } catch (error: unknown) { + this.logger.warn(`Failed to load message filters for agent ${agentId}: ${(error as Error).message}`); + + return { filtered: false }; + } + + if (rules.length === 0) { + return { filtered: false }; + } + + const result = evaluateMessageFilters(rules, message, direction); + + if (result.status === 'allowed' || !result.matchedRule) { + return { filtered: false }; + } + + const reason = describeMessageFilterRule(result.matchedRule); + + if (result.status === 'dropped') { + return { filtered: true, action: 'drop', reason }; + } + + return result.modifiedMessage !== undefined + ? { filtered: true, action: 'flag', modifiedMessage: result.modifiedMessage, reason } + : { filtered: true, action: 'flag', reason }; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.spec.ts deleted file mode 100644 index 96f0ada76..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; - -import { AgentsFiltersService } from './agents-filters.service'; -import { RegexFilterRulesCacheService } from './regex-filter-rules-cache.service'; - -describe('AgentsFiltersService', () => { - let service: AgentsFiltersService; - let cache: RegexFilterRulesCacheService; - const repoMock: jest.Mocked< - Pick< - RegexFilterRulesRepository, - 'create' | 'update' | 'delete' | 'findByIdOrThrow' | 'findAll' | 'findAllOrdered' | 'count' - > - > = { - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - findByIdOrThrow: jest.fn(), - findAll: jest.fn().mockResolvedValue([]), - findAllOrdered: jest.fn().mockResolvedValue([]), - count: jest.fn().mockResolvedValue(0), - }; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [ - AgentsFiltersService, - RegexFilterRulesCacheService, - { provide: RegexFilterRulesRepository, useValue: repoMock }, - ], - }).compile(); - - service = moduleRef.get(AgentsFiltersService); - cache = moduleRef.get(RegexFilterRulesCacheService); - }); - - it('create invalidates cache', async () => { - const invalidate = jest.spyOn(cache, 'invalidate'); - const created = { id: '1' } as RegexFilterRuleEntity; - - repoMock.create.mockResolvedValue(created); - const out = await service.create({ - pattern: 'a', - direction: 'incoming', - filterType: 'none', - priority: 0, - }); - - expect(out).toBe(created); - expect(invalidate).toHaveBeenCalled(); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts deleted file mode 100644 index bbab45e2c..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { CreateRegexFilterRuleDto } from '../dto/create-regex-filter-rule.dto'; -import { UpdateRegexFilterRuleDto } from '../dto/update-regex-filter-rule.dto'; -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; -import { - assertReplaceContentForFilterType, - compileRegexOrThrow, - normalizeRegexFlags, -} from '../utils/regex-filter-rule.utils'; - -import { RegexFilterRulesCacheService } from './regex-filter-rules-cache.service'; - -/** - * CRUD for regex filter rules (HTTP API + cache invalidation). - */ -@Injectable() -export class AgentsFiltersService { - constructor( - private readonly repository: RegexFilterRulesRepository, - private readonly cache: RegexFilterRulesCacheService, - ) {} - - async list(limit = 100, offset = 0): Promise { - return await this.repository.findAll(limit, offset); - } - - async count(): Promise { - return await this.repository.count(); - } - - async getById(id: string): Promise { - return await this.repository.findByIdOrThrow(id); - } - - async create(dto: CreateRegexFilterRuleDto): Promise { - assertReplaceContentForFilterType(dto.filterType, dto.replaceContent); - const flags = normalizeRegexFlags(dto.regexFlags); - - compileRegexOrThrow(dto.pattern, flags); - const row = await this.repository.create({ - pattern: dto.pattern, - regexFlags: flags, - direction: dto.direction, - filterType: dto.filterType, - replaceContent: dto.filterType === 'filter' ? dto.replaceContent : null, - priority: dto.priority ?? 0, - }); - - this.cache.invalidate(); - - return row; - } - - async update(id: string, dto: UpdateRegexFilterRuleDto): Promise { - const existing = await this.repository.findByIdOrThrow(id); - const filterType = dto.filterType ?? existing.filterType; - let replaceContent = dto.replaceContent !== undefined ? dto.replaceContent : existing.replaceContent; - - if (filterType !== 'filter') { - replaceContent = null; - } - - assertReplaceContentForFilterType(filterType, replaceContent); - const pattern = dto.pattern ?? existing.pattern; - const regexFlags = dto.regexFlags !== undefined ? normalizeRegexFlags(dto.regexFlags) : existing.regexFlags; - - compileRegexOrThrow(pattern, regexFlags); - const updated = await this.repository.update(id, { - pattern, - regexFlags, - direction: dto.direction ?? existing.direction, - filterType, - replaceContent: filterType === 'filter' ? replaceContent : null, - priority: dto.priority ?? existing.priority, - }); - - this.cache.invalidate(); - - return updated; - } - - async delete(id: string): Promise { - await this.repository.delete(id); - this.cache.invalidate(); - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts index 08a069b34..5693c4871 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts @@ -2728,4 +2728,154 @@ describe('AgentsService', () => { ); }); }); + describe('egress guard integration', () => { + const enforcePolicy = { + mode: 'enforce', + allowHosts: [{ value: 'github.com', source: 'global' }], + denyHosts: [], + denyUrlPatterns: [], + networkAllow: [], + blockPrivateNetworks: true, + blockIpLiterals: false, + logAllowed: true, + scalarSources: { mode: 'global', blockPrivateNetworks: null, blockIpLiterals: null, logAllowed: null }, + }; + let egress: Record; + let guardedService: AgentsService; + + beforeEach(() => { + process.env.GIT_REPOSITORY_SETUP_MODE = 'empty'; + egress = { + isEnabled: jest.fn().mockReturnValue(true), + provisionGuardForNewAgent: jest.fn().mockResolvedValue({ guardId: 'guard-1', revision: 'rev-1' }), + removeGuardContainer: jest.fn().mockResolvedValue(undefined), + buildProxyEnv: jest.fn().mockReturnValue({ + HTTPS_PROXY: 'http://127.0.0.1:3128', + http_proxy: 'http://127.0.0.1:3128', + no_proxy: undefined, + }), + beforeAgentStart: jest.fn().mockResolvedValue(undefined), + beforeAgentRestart: jest.fn().mockResolvedValue(undefined), + afterAgentStop: jest.fn().mockResolvedValue(undefined), + afterAgentRemove: jest.fn().mockResolvedValue(undefined), + describe: jest.fn().mockResolvedValue({ status: 'active', mode: 'enforce' }), + }; + guardedService = new AgentsService( + mockRepository as unknown as AgentsRepository, + mockDockerService as unknown as DockerService, + mockPasswordService as unknown as PasswordService, + mockAgentProviderFactory as unknown as AgentProviderFactory, + mockAgentChatSessionsService as unknown as AgentChatSessionsService, + mockOpenCodeClientFactory as unknown as OpenCodeClientFactory, + new AgentGitCredentialsService(mockDockerService as unknown as DockerService), + mockDeploymentsService as unknown as DeploymentsService, + undefined, + undefined, + egress as never, + ); + mockRepository.findByName.mockResolvedValue(null); + mockPasswordService.hashPassword.mockResolvedValue('hashed'); + mockDockerService.createContainer.mockResolvedValue('container-id-123'); + mockDockerService.sendCommandToContainer.mockResolvedValue(undefined); + mockRepository.create.mockResolvedValue({ ...mockAgent }); + }); + + it('provisions the guard before the agent and starts the agent inside its namespace', async () => { + const result = await guardedService.create({ name: 'Guarded', egressPolicy: enforcePolicy } as CreateAgentDto); + + expect(egress.provisionGuardForNewAgent).toHaveBeenCalledWith(expect.objectContaining({ mode: 'enforce' }), null); + expect(egress.provisionGuardForNewAgent.mock.invocationCallOrder[0]).toBeLessThan( + mockDockerService.createContainer.mock.invocationCallOrder[0], + ); + + const createArgs = mockDockerService.createContainer.mock.calls[0][0]; + + expect(createArgs.sharedNetworkContainerId).toBe('guard-1'); + expect(createArgs.capDrop).toEqual(['NET_ADMIN', 'NET_RAW']); + expect(createArgs.network).toBeUndefined(); + expect(createArgs.ports).toBeUndefined(); + expect(createArgs.env).toEqual( + expect.objectContaining({ HTTPS_PROXY: 'http://127.0.0.1:3128', http_proxy: 'http://127.0.0.1:3128' }), + ); + expect(createArgs.env).not.toHaveProperty('no_proxy'); + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + egressGuardContainerId: 'guard-1', + egressPolicyRevision: 'rev-1', + egressStatus: 'active', + egressPolicy: expect.objectContaining({ mode: 'enforce' }), + }), + ); + expect(result.egress).toEqual({ status: 'active', mode: 'enforce' }); + }); + + it('removes the guard when the agent container cannot be created', async () => { + mockDockerService.createContainer.mockRejectedValueOnce(new Error('create failed')); + + await expect( + guardedService.create({ name: 'Guarded', egressPolicy: enforcePolicy } as CreateAgentDto), + ).rejects.toThrow('create failed'); + expect(egress.removeGuardContainer).toHaveBeenCalledWith('guard-1'); + }); + + it('removes the guard when provisioning after container creation fails', async () => { + mockRepository.create.mockRejectedValueOnce(new Error('db down')); + + await expect( + guardedService.create({ name: 'Guarded', egressPolicy: enforcePolicy } as CreateAgentDto), + ).rejects.toThrow(BadRequestException); + expect(mockDockerService.deleteContainer).toHaveBeenCalledWith('container-id-123'); + expect(egress.removeGuardContainer).toHaveBeenCalledWith('guard-1'); + }); + + it('rejects creation when filtering is required but guards are disabled', async () => { + egress.isEnabled.mockReturnValue(false); + + await expect( + guardedService.create({ name: 'Guarded', egressPolicy: enforcePolicy } as CreateAgentDto), + ).rejects.toThrow(/egress guard sidecars are disabled/); + expect(mockDockerService.createContainer).not.toHaveBeenCalled(); + }); + + it('keeps the legacy topology for policies that do not require a guard', async () => { + await guardedService.create({ name: 'Plain', egressPolicy: { ...enforcePolicy, mode: 'off' } } as CreateAgentDto); + + expect(egress.provisionGuardForNewAgent).not.toHaveBeenCalled(); + expect(mockDockerService.createContainer.mock.calls[0][0].sharedNetworkContainerId).toBeUndefined(); + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ egressStatus: 'inactive', egressPolicy: expect.objectContaining({ mode: 'off' }) }), + ); + }); + + it('orders guard and agent operations in the lifecycle', async () => { + const guarded = { ...mockAgent, egressGuardContainerId: 'guard-1' }; + + mockRepository.findByIdOrThrow.mockResolvedValue(guarded); + mockDockerService.startContainer.mockResolvedValue(undefined); + mockDockerService.stopContainer.mockResolvedValue(undefined); + mockDockerService.restartContainer.mockResolvedValue(undefined); + mockDockerService.deleteContainer.mockResolvedValue(undefined); + mockRepository.delete.mockResolvedValue(undefined); + + await guardedService.start(guarded.id); + expect(egress.beforeAgentStart.mock.invocationCallOrder[0]).toBeLessThan( + mockDockerService.startContainer.mock.invocationCallOrder[0], + ); + + await guardedService.restart(guarded.id); + expect(egress.beforeAgentRestart.mock.invocationCallOrder[0]).toBeLessThan( + mockDockerService.restartContainer.mock.invocationCallOrder[0], + ); + + await guardedService.stop(guarded.id); + expect(egress.afterAgentStop.mock.invocationCallOrder[0]).toBeGreaterThan( + mockDockerService.stopContainer.mock.invocationCallOrder[0], + ); + + await guardedService.remove(guarded.id); + expect(egress.afterAgentRemove.mock.invocationCallOrder[0]).toBeGreaterThan( + mockDockerService.deleteContainer.mock.invocationCallOrder[0], + ); + }); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts index 9f12b59d5..3aca32e7a 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts @@ -1,5 +1,9 @@ import { randomBytes } from 'crypto'; +import { + egressPolicyRequiresGuard, + normalizeEffectiveEgressPolicy, +} from '@forepath/agenstra/shared/util-opencode-config'; import { PasswordService } from '@forepath/identity/backend'; import { BadRequestException, @@ -30,6 +34,7 @@ import { serializeAgentEnvironmentBaseline } from '../utils/agent-environment-ba import { expandProviderPathTildeInContainer } from '../utils/provider-container-path.utils'; import { AgentChatSessionsService } from './agent-chat-sessions.service'; +import { AgentEgressService, EGRESS_GUARDED_AGENT_CAP_DROP } from './agent-egress.service'; import { AgentGitCredentialsService } from './agent-git-credentials.service'; import { DeploymentsService } from './deployments.service'; import { DockerService } from './docker.service'; @@ -67,6 +72,8 @@ export class AgentsService implements OnApplicationBootstrap { private readonly workspaceInotifySupervisor?: WorkspaceInotifySupervisor, @Optional() private readonly environmentProgress?: EnvironmentProgressService, + @Optional() + private readonly agentEgress?: AgentEgressService, ) {} /** @@ -312,49 +319,91 @@ export class AgentsService implements OnApplicationBootstrap { await this.dockerService.ensureNetworkExists(agentDockerNetwork); } + // Egress filtering required by the layered agent configuration: provision the guard and apply its + // policy before the agent container exists (fail closed — the agent never runs unfiltered). + const egressPolicy = createAgentDto.egressPolicy + ? normalizeEffectiveEgressPolicy(createAgentDto.egressPolicy) + : null; + let egressGuard: { guardId: string; revision: string } | undefined; + + if (egressPolicy && egressPolicyRequiresGuard(egressPolicy)) { + if (!this.agentEgress?.isEnabled()) { + throw new BadRequestException( + 'The agent configuration requires egress filtering, but egress guard sidecars are disabled on this agent manager', + ); + } + + egressGuard = await this.agentEgress.provisionGuardForNewAgent(egressPolicy, null); + } + + const egressProxyEnv = egressGuard + ? Object.fromEntries( + Object.entries(this.agentEgress?.buildProxyEnv(egressPolicy, true, {}) ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + : {}; + // Create a docker container - const containerId = await this.dockerService.createContainer({ - image: dockerImage, - env: { - AGENT_NAME: createAgentDto.name, - OPENCODE_SERVER_PASSWORD: opencodeServerPassword, - OPENCODE_SERVER_USERNAME: opencodeServerUsername, - OPENCODE_SERVER_HOSTNAME: '0.0.0.0', - OPENCODE_SERVER_PORT: String(OPENCODE_SERVER_PORT), - ...this.buildGitContainerEnv(gitRepositorySetupMode, repositoryUrl), - ...(provider.getEnvironmentVariables ? provider.getEnvironmentVariables() : {}), - }, - volumes: [ - { - hostPath: agentVolumePath, - containerPath: basePath, - readOnly: false, - }, - { - hostPath: AgentsService.WORKSPACE_CONTEXT_HOST_PATH, - containerPath: AgentsService.WORKSPACE_CONTEXT_CONTAINER_PATH, - readOnly: true, + let containerId: string; + + try { + containerId = await this.dockerService.createContainer({ + image: dockerImage, + env: { + AGENT_NAME: createAgentDto.name, + OPENCODE_SERVER_PASSWORD: opencodeServerPassword, + OPENCODE_SERVER_USERNAME: opencodeServerUsername, + OPENCODE_SERVER_HOSTNAME: '0.0.0.0', + OPENCODE_SERVER_PORT: String(OPENCODE_SERVER_PORT), + ...this.buildGitContainerEnv(gitRepositorySetupMode, repositoryUrl), + ...(provider.getEnvironmentVariables ? provider.getEnvironmentVariables() : {}), + ...egressProxyEnv, }, - ], - ...(agentDockerNetwork - ? { - // Reach OpenCode/VNC via container IP on the agent network — do not publish ports on the host. - network: agentDockerNetwork, - } - : { - // Local/dev without AGENT_DOCKER_NETWORK: bind OpenCode + websockify to loopback only. - ports: [ - { - containerPort: OPENCODE_SERVER_PORT, - hostIp: '127.0.0.1', - }, - { - containerPort: VNC_WEBSOCKIFY_PORT, - hostIp: '127.0.0.1', - }, - ], - }), - }); + volumes: [ + { + hostPath: agentVolumePath, + containerPath: basePath, + readOnly: false, + }, + { + hostPath: AgentsService.WORKSPACE_CONTEXT_HOST_PATH, + containerPath: AgentsService.WORKSPACE_CONTEXT_CONTAINER_PATH, + readOnly: true, + }, + ], + ...(egressGuard + ? { + // The guard owns the network namespace (agent network attachment / published ports). + sharedNetworkContainerId: egressGuard.guardId, + capDrop: [...EGRESS_GUARDED_AGENT_CAP_DROP], + } + : agentDockerNetwork + ? { + // Reach OpenCode/VNC via container IP on the agent network — do not publish ports on the host. + network: agentDockerNetwork, + } + : { + // Local/dev without AGENT_DOCKER_NETWORK: bind OpenCode + websockify to loopback only. + ports: [ + { + containerPort: OPENCODE_SERVER_PORT, + hostIp: '127.0.0.1', + }, + { + containerPort: VNC_WEBSOCKIFY_PORT, + hostIp: '127.0.0.1', + }, + ], + }), + }); + } catch (error) { + if (egressGuard) { + await this.agentEgress?.removeGuardContainer(egressGuard.guardId); + } + + throw error; + } let persistedAgentId: string | undefined; @@ -387,6 +436,19 @@ export class AgentsService implements OnApplicationBootstrap { ? GitRepositorySetupMode.EMPTY : createAgentDto.gitRepositorySetupMode, environmentVariableBaseline: serializeAgentEnvironmentBaseline({}), + ...(egressPolicy + ? { + egressPolicy, + egressStatus: egressGuard ? ('active' as const) : ('inactive' as const), + ...(egressGuard + ? { + egressGuardContainerId: egressGuard.guardId, + egressPolicyRevision: egressGuard.revision, + egressAppliedAt: new Date(), + } + : {}), + } + : {}), }); persistedAgentId = agent.id; @@ -445,6 +507,10 @@ export class AgentsService implements OnApplicationBootstrap { ); } + if (egressGuard) { + await this.agentEgress?.removeGuardContainer(egressGuard.guardId); + } + // Drop the persisted row too, otherwise a broken environment without a container remains listed if (persistedAgentId) { try { @@ -569,6 +635,7 @@ export class AgentsService implements OnApplicationBootstrap { } } + await this.agentEgress?.afterAgentRemove(agent); await this.agentsRepository.delete(id); } @@ -583,6 +650,8 @@ export class AgentsService implements OnApplicationBootstrap { if (agent.containerId) { try { + // The egress guard owns the namespace: it must run before the agent can join it. + await this.agentEgress?.beforeAgentStart(agent); await this.dockerService.startContainer(agent.containerId); } catch (error: unknown) { const err = error as { message?: string; stack?: string }; @@ -631,6 +700,8 @@ export class AgentsService implements OnApplicationBootstrap { } } + await this.agentEgress?.afterAgentStop(agent); + return await this.mapToResponseDto(agent); } @@ -645,6 +716,8 @@ export class AgentsService implements OnApplicationBootstrap { if (agent.containerId) { try { + // Fresh guard namespace first; the agent restart then joins it. + await this.agentEgress?.beforeAgentRestart(agent); await this.dockerService.restartContainer(agent.containerId); } catch (error: unknown) { const err = error as { message?: string; stack?: string }; @@ -757,6 +830,7 @@ export class AgentsService implements OnApplicationBootstrap { git: this.mapAgentGit(agent), chats, primaryChatId: primary.id, + ...(this.agentEgress ? { egress: await this.agentEgress.describe(agent) } : {}), createdAt: agent.createdAt, updatedAt: agent.updatedAt, }; @@ -791,6 +865,7 @@ export class AgentsService implements OnApplicationBootstrap { if (agent.containerId && !restartedContainers.has(agent.containerId)) { try { this.logger.log(`Restarting agent container ${agent.containerId} for agent ${agent.name}`); + await this.agentEgress?.beforeAgentRestart(agent); await this.dockerService.restartContainer(agent.containerId); restartedContainers.add(agent.containerId); this.logger.log(`✅ Successfully restarted agent container ${agent.containerId}`); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts index c66d6b402..dcf975c64 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts @@ -2815,4 +2815,224 @@ describe('DockerService', () => { expect((mockDocker as any).pull).not.toHaveBeenCalled(); }); }); + + describe('egress guard topology', () => { + let createdContainer: { id: string; start: jest.Mock; rename: jest.Mock; remove: jest.Mock; putArchive: jest.Mock }; + + beforeEach(() => { + createdContainer = { + id: 'created-id', + start: jest.fn().mockResolvedValue(undefined), + rename: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + putArchive: jest.fn().mockResolvedValue(undefined), + }; + (mockDocker as any).createContainer = jest.fn().mockResolvedValue(createdContainer); + (mockDocker as any).listContainers = jest.fn(); + jest.spyOn(service, 'ensureImageExists').mockResolvedValue(undefined); + }); + + it('creates agents inside the guard namespace without ports and with dropped capabilities', async () => { + await service.createContainer({ + image: 'worker:latest', + ports: [{ containerPort: 4096, hostIp: '127.0.0.1' }], + network: 'ignored-network', + sharedNetworkContainerId: 'guard-1', + capDrop: ['NET_ADMIN', 'NET_RAW'], + }); + + const args = (mockDocker as any).createContainer.mock.calls[0][0]; + + expect(args.ExposedPorts).toBeUndefined(); + expect(args.HostConfig.PortBindings).toBeUndefined(); + expect(args.HostConfig.NetworkMode).toBe('container:guard-1'); + expect(args.HostConfig.CapDrop).toEqual(['NET_ADMIN', 'NET_RAW']); + }); + + it('recreates an agent into the guard namespace (no ports / DNS / network reconnect)', async () => { + mockContainer.inspect.mockResolvedValue({ + Id: 'agent-id', + Name: '/agent', + Image: 'worker:latest', + State: { Running: true }, + Config: { Image: 'worker:latest', Env: ['A=1'], ExposedPorts: { '4096/tcp': {} }, Labels: {} }, + HostConfig: { + NetworkMode: 'agent-net', + PortBindings: { '4096/tcp': [{ HostIp: '127.0.0.1' }] }, + Dns: ['1.1.1.1'], + ExtraHosts: ['host.docker.internal:host-gateway'], + CapDrop: ['MKNOD'], + }, + NetworkSettings: { Networks: { 'agent-net': { IPAddress: '172.18.0.4' } } }, + Mounts: [], + }); + mockContainer.stop.mockResolvedValue(undefined); + mockContainer.remove.mockResolvedValue(undefined); + + const result = await service.updateContainer('agent-id', { + env: { HTTPS_PROXY: 'http://127.0.0.1:3128' }, + sharedNetworkContainerId: 'guard-1', + capDrop: ['NET_ADMIN', 'NET_RAW'], + }); + + const args = (mockDocker as any).createContainer.mock.calls[0][0]; + + expect(result).toBe('created-id'); + expect(args.ExposedPorts).toBeUndefined(); + expect(args.Env).toEqual(['A=1', 'HTTPS_PROXY=http://127.0.0.1:3128']); + expect(args.HostConfig).toEqual( + expect.objectContaining({ + NetworkMode: 'container:guard-1', + PortBindings: undefined, + Dns: undefined, + ExtraHosts: undefined, + CapDrop: ['MKNOD', 'NET_ADMIN', 'NET_RAW'], + }), + ); + expect(mockNetwork.connect).not.toHaveBeenCalled(); + expect(createdContainer.start).toHaveBeenCalled(); + }); + + it('keeps a joined agent in its namespace on later environment recreates', async () => { + mockContainer.inspect.mockResolvedValue({ + Id: 'agent-id', + Name: '/agent', + Image: 'worker:latest', + State: { Running: true }, + Config: { Image: 'worker:latest', Env: [], ExposedPorts: { '4096/tcp': {} }, Labels: {} }, + HostConfig: { NetworkMode: 'container:guard-1' }, + NetworkSettings: { Networks: {} }, + Mounts: [], + }); + mockContainer.stop.mockResolvedValue(undefined); + mockContainer.remove.mockResolvedValue(undefined); + + await service.updateContainer('agent-id', { env: { B: '2' } }); + + const args = (mockDocker as any).createContainer.mock.calls[0][0]; + + expect(args.HostConfig.NetworkMode).toBe('container:guard-1'); + expect(args.ExposedPorts).toBeUndefined(); + }); + + it('resolves agent HTTP endpoints through the namespace owner', async () => { + process.env.AGENT_DOCKER_NETWORK = 'agent-net'; + mockContainer.inspect + .mockResolvedValueOnce({ HostConfig: { NetworkMode: 'container:guard-1' }, NetworkSettings: { Networks: {} } }) + .mockResolvedValueOnce({ + HostConfig: { NetworkMode: 'agent-net' }, + NetworkSettings: { Networks: { 'agent-net': { IPAddress: '172.18.0.9' } } }, + }); + + await expect(service.resolveContainerHttpBaseUrl('agent-id', 4096)).resolves.toBe('http://172.18.0.9:4096'); + expect(mockDocker.getContainer).toHaveBeenLastCalledWith('guard-1'); + }); + + it('creates hardened guard sidecars and publishes agent ports only without a network', async () => { + await service.createEgressGuardContainer({ + name: 'agenstra-egress-a', + image: 'guard:latest', + labels: { 'io.agenstra.egress-guard': '1' }, + publishPorts: [4096, 6080], + memoryBytes: 1024, + }); + + const args = (mockDocker as any).createContainer.mock.calls[0][0]; + + expect(args).toEqual( + expect.objectContaining({ + name: 'agenstra-egress-a', + Image: 'guard:latest', + Tty: true, + Labels: { 'io.agenstra.egress-guard': '1' }, + ExposedPorts: { '4096/tcp': {}, '6080/tcp': {} }, + }), + ); + expect(args.HostConfig).toEqual( + expect.objectContaining({ + PortBindings: { '4096/tcp': [{ HostIp: '127.0.0.1' }], '6080/tcp': [{ HostIp: '127.0.0.1' }] }, + CapDrop: ['ALL'], + CapAdd: expect.arrayContaining(['NET_ADMIN']), + SecurityOpt: ['no-new-privileges'], + Sysctls: { 'net.ipv6.conf.all.disable_ipv6': '1', 'net.ipv6.conf.default.disable_ipv6': '1' }, + Memory: 1024, + RestartPolicy: { Name: 'unless-stopped' }, + }), + ); + expect(args.HostConfig.CapAdd).not.toContain('NET_RAW'); + + (mockDocker as any).createContainer.mockClear(); + await service.createEgressGuardContainer({ + name: 'g', + image: 'guard', + labels: {}, + network: 'agent-net', + publishPorts: [4096], + }); + expect((mockDocker as any).createContainer.mock.calls[0][0].HostConfig.NetworkMode).toBe('agent-net'); + expect((mockDocker as any).createContainer.mock.calls[0][0].HostConfig.PortBindings).toBeUndefined(); + }); + + it('removes a guard that fails to start', async () => { + createdContainer.start.mockRejectedValue(new Error('no NET_ADMIN')); + + await expect(service.createEgressGuardContainer({ name: 'g', image: 'guard', labels: {} })).rejects.toThrow( + 'no NET_ADMIN', + ); + expect(createdContainer.remove).toHaveBeenCalledWith({ force: true }); + }); + + it('uploads flat files as a tar archive', async () => { + await service.putFilesIntoContainer('guard-1', '/var/lib/agenstra-egress/staged', [ + { name: 'squid.conf', content: 'http_port 3128\n' }, + ]); + + const [archive, options] = mockContainer.putArchive.mock.calls[0]; + + expect(options).toEqual({ path: '/var/lib/agenstra-egress/staged' }); + expect(extractFirstFileFromTar(archive).toString('utf-8')).toBe('http_port 3128\n'); + }); + + it('reads timestamped logs since a cursor (capped to the newest bytes)', async () => { + mockContainer.logs.mockResolvedValue(Buffer.from('2026-01-01T00:00:00Z a\n2026-01-01T00:00:01Z b\n')); + + await expect(service.getContainerLogsSince('guard-1', 12.7, 26)).resolves.toBe('2026-01-01T00:00:01Z b\n'); + expect(mockContainer.logs).toHaveBeenCalledWith({ + stdout: true, + stderr: true, + timestamps: true, + follow: false, + since: 12, + }); + }); + + it('summarizes containers and returns null when missing', async () => { + mockContainer.inspect.mockResolvedValueOnce({ + Id: 'full-id', + State: { Running: true, StartedAt: '2026-01-01T00:00:00Z' }, + HostConfig: { NetworkMode: 'container:guard' }, + Config: { Labels: { a: 'b' } }, + }); + + await expect(service.inspectContainerSummary('x')).resolves.toEqual({ + id: 'full-id', + running: true, + startedAt: new Date('2026-01-01T00:00:00Z'), + networkMode: 'container:guard', + labels: { a: 'b' }, + }); + + mockContainer.inspect.mockRejectedValueOnce({ statusCode: 404 }); + await expect(service.inspectContainerSummary('x')).resolves.toBeNull(); + }); + + it('lists containers by label', async () => { + (mockDocker as any).listContainers.mockResolvedValue([{ Id: 'g1', Labels: { l: '1' }, Created: 10 }]); + + await expect(service.listContainersByLabel('l=1')).resolves.toEqual([ + { id: 'g1', labels: { l: '1' }, createdAt: new Date(10_000) }, + ]); + expect((mockDocker as any).listContainers).toHaveBeenCalledWith({ all: true, filters: { label: ['l=1'] } }); + }); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts index 217323b14..bba8580c7 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts @@ -19,6 +19,7 @@ import { AGENT_ENVIRONMENT_VOLUME_LABEL, AGENT_ENVIRONMENT_VOLUME_PREFIX, assertValidEnvironmentVariableName, + buildFlatTar, buildSingleFileTar, extractFirstFileFromTar, parseDockerEnvList, @@ -76,8 +77,16 @@ export class DockerService { volumes?: Array<{ hostPath: string; containerPath: string; readOnly?: boolean }>; ports?: Array<{ containerPort: number; hostPort?: number; hostIp?: string; protocol?: 'tcp' | 'udp' }>; network?: string; + /** + * Join another container's network namespace (`container:`, e.g. the egress guard). + * Ports are published on (and `network` belongs to) the namespace owner instead. + */ + sharedNetworkContainerId?: string; + capDrop?: string[]; }): Promise { - const { image, env, volumes = [], ports = [], network } = options; + const { image, env, volumes = [], network, sharedNetworkContainerId, capDrop } = options; + // Docker rejects port publishing / exposing for containers sharing another network namespace. + const ports = sharedNetworkContainerId ? [] : (options.ports ?? []); // Resolve image: explicit -> env -> default placeholder const resolvedImage = image || process.env.AGENT_DEFAULT_IMAGE || 'ghcr.io/forepath/agenstra-manager-worker:latest'; // Build HostConfig.Binds from volumes @@ -124,7 +133,12 @@ export class DockerService { RestartPolicy: { Name: 'unless-stopped', }, - NetworkMode: network ? network : undefined, + NetworkMode: sharedNetworkContainerId + ? `container:${sharedNetworkContainerId}` + : network + ? network + : undefined, + ...(capDrop?.length ? { CapDrop: capDrop } : {}), }, }); @@ -176,13 +190,25 @@ export class DockerService { containerId: string, options: { env?: Record; + /** + * Move the container into another container's network namespace (egress guard). + * Always recreates the container (network mode is immutable). + */ + sharedNetworkContainerId?: string; + capDrop?: string[]; }, ): Promise { - const { env } = options; + const { env, sharedNetworkContainerId, capDrop } = options; try { const inspectInfo = await this.inspectContainerOrThrow(containerId); + if (sharedNetworkContainerId) { + return await this.withEnvironmentLock(inspectInfo.Id, () => + this.recreateWithUpdatedEnvironment(containerId, inspectInfo, env, { sharedNetworkContainerId, capDrop }), + ); + } + if (this.resolveEnvironmentApplyStrategy(inspectInfo) === 'restart') { return await this.withEnvironmentLock(inspectInfo.Id, () => this.restartWithUpdatedEnvironment(containerId, inspectInfo, env), @@ -257,6 +283,7 @@ export class DockerService { containerId: string, inspectInfo: Docker.ContainerInspectInfo, env: Record | undefined, + topology?: { sharedNetworkContainerId: string; capDrop?: string[] }, ): Promise { const container = this.docker.getContainer(containerId); const existingEnvironmentMount = this.findEnvironmentMount(inspectInfo); @@ -268,8 +295,28 @@ export class DockerService { ); const containerName = inspectInfo.Name.startsWith('/') ? inspectInfo.Name.slice(1) : inspectInfo.Name; const image = inspectInfo.Config?.Image || inspectInfo.Image; - const hostConfig = inspectInfo.HostConfig || {}; - const exposedPorts = inspectInfo.Config?.ExposedPorts || {}; + const inspectedHostConfig = inspectInfo.HostConfig || {}; + const targetNetworkMode = topology + ? `container:${topology.sharedNetworkContainerId}` + : (inspectedHostConfig.NetworkMode ?? undefined); + const sharesNetworkNamespace = typeof targetNetworkMode === 'string' && targetNetworkMode.startsWith('container:'); + // Joining a shared namespace: ports / DNS / hosts belong to the namespace owner (Docker rejects them here). + const hostConfig: Docker.HostConfig = sharesNetworkNamespace + ? { + ...inspectedHostConfig, + NetworkMode: targetNetworkMode, + PortBindings: undefined, + PublishAllPorts: false, + Dns: undefined, + DnsOptions: undefined, + DnsSearch: undefined, + ExtraHosts: undefined, + ...(topology?.capDrop?.length + ? { CapDrop: [...new Set([...(inspectedHostConfig.CapDrop ?? []), ...topology.capDrop])] } + : {}), + } + : inspectedHostConfig; + const exposedPorts = sharesNetworkNamespace ? {} : inspectInfo.Config?.ExposedPorts || {}; const labels = inspectInfo.Config?.Labels || {}; const networkSettings = inspectInfo.NetworkSettings; const mounts = inspectInfo.Mounts || []; @@ -406,8 +453,9 @@ export class DockerService { await newContainer.rename({ name: containerName }); - // Reconnect to networks if the container was connected to any - const networks = networkSettings?.Networks; + // Reconnect to networks if the container was connected to any (never for a shared namespace: + // the namespace owner — e.g. the egress guard — holds the network attachments). + const networks = sharesNetworkNamespace ? undefined : networkSettings?.Networks; if (networks) { for (const networkName of Object.keys(networks)) { @@ -2275,7 +2323,7 @@ export class DockerService { * Preferring the agent network first avoids picking a secondary Docker network IP that the * manager cannot route to after `createNetwork` attaches the worker to a second network. */ - async resolveContainerHttpBaseUrl(containerId: string, containerPort: number): Promise { + async resolveContainerHttpBaseUrl(containerId: string, containerPort: number, depth = 0): Promise { const container = this.docker.getContainer(containerId); let inspectInfo: Docker.ContainerInspectInfo; @@ -2291,6 +2339,14 @@ export class DockerService { throw error; } + // Agents behind an egress guard share the guard's network namespace: their listeners are + // reachable on the guard's IP / published ports. + const networkMode = inspectInfo.HostConfig?.NetworkMode ?? ''; + + if (networkMode.startsWith('container:') && depth === 0) { + return await this.resolveContainerHttpBaseUrl(networkMode.slice('container:'.length), containerPort, depth + 1); + } + const networks = inspectInfo.NetworkSettings?.Networks || {}; const preferredNetwork = process.env.AGENT_DOCKER_NETWORK?.trim(); @@ -2476,6 +2532,154 @@ export class DockerService { }); } + /** + * Create and start an egress guard sidecar. The guard owns the agent's network namespace, so it is + * attached to the agent network (or publishes the agent's ports on loopback when no network is set). + * Only the guard receives CAP_NET_ADMIN; every other capability is dropped except those Squid / + * dnsmasq need to drop privileges. IPv6 is disabled in the namespace (IPv4 is fully filtered). + */ + async createEgressGuardContainer(options: { + name: string; + image: string; + labels: Record; + network?: string; + publishPorts?: readonly number[]; + memoryBytes?: number; + }): Promise { + const { name, image, labels, network, publishPorts = [], memoryBytes } = options; + + await this.ensureImageExists(image); + + const exposedPorts: Record> = {}; + const portBindings: Record> = {}; + + if (!network) { + for (const port of publishPorts) { + exposedPorts[`${port}/tcp`] = {}; + portBindings[`${port}/tcp`] = [{ HostIp: '127.0.0.1' }]; + } + } + + const container = await this.docker.createContainer({ + name, + Image: image, + Tty: true, + Labels: labels, + ExposedPorts: Object.keys(exposedPorts).length ? exposedPorts : undefined, + HostConfig: { + NetworkMode: network || undefined, + PortBindings: Object.keys(portBindings).length ? portBindings : undefined, + RestartPolicy: { Name: 'unless-stopped' }, + CapDrop: ['ALL'], + CapAdd: ['NET_ADMIN', 'NET_BIND_SERVICE', 'SETUID', 'SETGID', 'CHOWN', 'DAC_OVERRIDE', 'FOWNER', 'KILL'], + SecurityOpt: ['no-new-privileges'], + Sysctls: { + 'net.ipv6.conf.all.disable_ipv6': '1', + 'net.ipv6.conf.default.disable_ipv6': '1', + }, + LogConfig: { Type: 'json-file', Config: { 'max-size': '10m', 'max-file': '2' } }, + ...(memoryBytes ? { Memory: memoryBytes } : {}), + PidsLimit: 256, + }, + }); + + try { + await container.start(); + } catch (error) { + await container.remove({ force: true }).catch(() => undefined); + + throw error; + } + + return container.id as unknown as string; + } + + /** Upload flat files into an existing directory of a container (root-owned; permissions fixed by the caller). */ + async putFilesIntoContainer( + containerId: string, + targetDirectory: string, + files: ReadonlyArray<{ name: string; content: string | Buffer; mode?: number }>, + ): Promise { + const container = this.docker.getContainer(containerId); + + await container.putArchive( + buildFlatTar( + files.map((file) => ({ + name: file.name, + content: Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content, 'utf-8'), + mode: file.mode ?? 0o640, + })), + ), + { path: targetDirectory }, + ); + } + + /** + * Read a TTY container's logs since a UNIX timestamp (seconds), with Docker RFC 3339 nano timestamps. + * Output is capped at `maxBytes` (newest content kept). + */ + async getContainerLogsSince(containerId: string, sinceSeconds: number, maxBytes = 8 * 1024 * 1024): Promise { + const container = this.docker.getContainer(containerId); + const raw = (await container.logs({ + stdout: true, + stderr: true, + timestamps: true, + follow: false, + since: Math.max(0, Math.floor(sinceSeconds)), + })) as unknown as Buffer | string; + const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf-8'); + if (buffer.length <= maxBytes) { + return buffer.toString('utf-8'); + } + + // Keep the newest content and drop the partial first line of the truncated window. + const tail = buffer.subarray(buffer.length - maxBytes).toString('utf-8'); + const firstNewline = tail.indexOf('\n'); + + return firstNewline === -1 ? '' : tail.slice(firstNewline + 1); + } + + /** Lightweight inspect (null when the container does not exist). */ + async inspectContainerSummary(containerId: string): Promise<{ + id: string; + running: boolean; + startedAt: Date | null; + networkMode: string | null; + labels: Record; + } | null> { + try { + const info = await this.docker.getContainer(containerId).inspect(); + const startedAt = info.State?.StartedAt ? new Date(info.State.StartedAt) : null; + + return { + id: info.Id, + running: info.State?.Running === true, + startedAt: startedAt && !Number.isNaN(startedAt.getTime()) && startedAt.getTime() > 0 ? startedAt : null, + networkMode: info.HostConfig?.NetworkMode ?? null, + labels: info.Config?.Labels ?? {}, + }; + } catch (error: unknown) { + if ((error as { statusCode?: number }).statusCode === 404) { + return null; + } + + throw error; + } + } + + /** List containers (running or not) carrying a label. */ + async listContainersByLabel( + label: string, + ): Promise; createdAt: Date }>> { + const containers = await this.docker.listContainers({ all: true, filters: { label: [label] } }); + + return containers.map((summary) => ({ + id: summary.Id, + labels: summary.Labels ?? {}, + createdAt: new Date((summary.Created ?? 0) * 1000), + })); + } + /** * Return the first regular file under dir (depth-first). Avoids shell `find` so spaced names work. */ diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.spec.ts deleted file mode 100644 index 92f28f366..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; - -import { RegexFilterRulesCacheService } from './regex-filter-rules-cache.service'; - -describe('RegexFilterRulesCacheService', () => { - let cache: RegexFilterRulesCacheService; - const repository = { findAllOrdered: jest.fn() }; - const sampleRows: RegexFilterRuleEntity[] = [ - Object.assign(new RegexFilterRuleEntity(), { - id: 'a', - pattern: 'a', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - priority: 0, - createdAt: new Date(), - updatedAt: new Date(), - }), - ]; - - beforeEach(async () => { - jest.clearAllMocks(); - repository.findAllOrdered.mockResolvedValue(sampleRows); - const module: TestingModule = await Test.createTestingModule({ - providers: [RegexFilterRulesCacheService, { provide: RegexFilterRulesRepository, useValue: repository }], - }).compile(); - - cache = module.get(RegexFilterRulesCacheService); - }); - - it('loads from repository when cache is empty', async () => { - const first = await cache.getAllOrdered(); - - expect(first).toEqual(sampleRows); - expect(repository.findAllOrdered).toHaveBeenCalledTimes(1); - }); - - it('returns cached rows without hitting repository again', async () => { - await cache.getAllOrdered(); - await cache.getAllOrdered(); - expect(repository.findAllOrdered).toHaveBeenCalledTimes(1); - }); - - it('invalidate clears cache so the next read refetches', async () => { - await cache.getAllOrdered(); - cache.invalidate(); - await cache.getAllOrdered(); - expect(repository.findAllOrdered).toHaveBeenCalledTimes(2); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts deleted file mode 100644 index da918ad06..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-cache.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; - -/** - * In-memory cache of all regex rules ordered by priority (invalidated on CRUD). - */ -@Injectable() -export class RegexFilterRulesCacheService { - private cached: RegexFilterRuleEntity[] | null = null; - - constructor(private readonly repository: RegexFilterRulesRepository) {} - - invalidate(): void { - this.cached = null; - } - - async getAllOrdered(): Promise { - if (this.cached) { - return this.cached; - } - - this.cached = await this.repository.findAllOrdered(); - - return this.cached; - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.spec.ts deleted file mode 100644 index c465f1850..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import type { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { FilterDirection } from '../providers/chat-filter.interface'; -import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; - -import { RegexFilterRulesCacheService } from './regex-filter-rules-cache.service'; -import { RegexFilterRulesEvaluateService } from './regex-filter-rules-evaluate.service'; - -describe('RegexFilterRulesEvaluateService', () => { - let service: RegexFilterRulesEvaluateService; - let cache: RegexFilterRulesCacheService; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [ - RegexFilterRulesEvaluateService, - RegexFilterRulesCacheService, - { - provide: RegexFilterRulesRepository, - useValue: { - findAllOrdered: jest.fn(), - findAll: jest.fn(), - count: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - findByIdOrThrow: jest.fn(), - }, - }, - ], - }).compile(); - - service = moduleRef.get(RegexFilterRulesEvaluateService); - cache = moduleRef.get(RegexFilterRulesCacheService); - }); - - it('returns drop on first matching rule', async () => { - jest.spyOn(cache, 'getAllOrdered').mockResolvedValue([ - { - id: 'r1', - pattern: 'secret', - regexFlags: 'g', - direction: 'incoming', - filterType: 'drop', - replaceContent: null, - priority: 0, - } as RegexFilterRuleEntity, - ]); - const r = await service.evaluate('has secret word', FilterDirection.INCOMING); - - expect(r).toEqual(expect.objectContaining({ filtered: true, action: 'drop' })); - }); - - it('skips outgoing-only rule on incoming evaluation', async () => { - jest.spyOn(cache, 'getAllOrdered').mockResolvedValue([ - { - id: 'r1', - pattern: 'x', - regexFlags: 'g', - direction: 'outgoing', - filterType: 'drop', - replaceContent: null, - priority: 0, - } as RegexFilterRuleEntity, - ]); - const r = await service.evaluate('x', FilterDirection.INCOMING); - - expect(r).toEqual({ filtered: false }); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts deleted file mode 100644 index 4acda3326..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/regex-filter-rules-evaluate.service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; -import { FilterDirection, FilterResult } from '../providers/chat-filter.interface'; -import { - applyReplace, - compileRegexOrThrow, - normalizeRegexFlags, - ruleMatchesMessage, -} from '../utils/regex-filter-rule.utils'; - -import { RegexFilterRulesCacheService } from './regex-filter-rules-cache.service'; - -/** - * Evaluates DB regex rules for a gateway direction (first match wins). - */ -@Injectable() -export class RegexFilterRulesEvaluateService { - constructor(private readonly cache: RegexFilterRulesCacheService) {} - - private appliesToGatewayDirection(rule: RegexFilterRuleEntity, gatewayDirection: FilterDirection): boolean { - if (rule.direction === 'bidirectional') { - return true; - } - - if (gatewayDirection === FilterDirection.INCOMING) { - return rule.direction === 'incoming'; - } - - if (gatewayDirection === FilterDirection.OUTGOING) { - return rule.direction === 'outgoing'; - } - - return false; - } - - /** - * Returns first matching rule outcome, or not filtered. - */ - async evaluate(message: string, gatewayDirection: FilterDirection): Promise { - const rules = await this.cache.getAllOrdered(); - - for (const rule of rules) { - if (!this.appliesToGatewayDirection(rule, gatewayDirection)) { - continue; - } - - try { - const flags = normalizeRegexFlags(rule.regexFlags); - - compileRegexOrThrow(rule.pattern, flags); - - if (!ruleMatchesMessage(rule, message)) { - continue; - } - - if (rule.filterType === 'drop') { - return { filtered: true, action: 'drop', reason: `Regex rule ${rule.id}` }; - } - - if (rule.filterType === 'none') { - return { filtered: true, action: 'flag', reason: 'Regex rule matched (none)' }; - } - - if (rule.filterType === 'filter') { - const modified = applyReplace(rule, message); - - return { - filtered: true, - action: 'flag', - modifiedMessage: modified, - reason: 'Regex rule replaced content', - }; - } - } catch { - // Skip invalid rules at runtime rather than failing chat - continue; - } - } - - return { filtered: false }; - } -} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.spec.ts index 24deb1d1e..8aae8cbdf 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.spec.ts @@ -1,5 +1,6 @@ import { assertValidEnvironmentVariableName, + buildFlatTar, buildSingleFileTar, extractFirstFileFromTar, parseDockerEnvList, @@ -143,4 +144,24 @@ describe('agent-environment-file.utils', () => { expect(() => extractFirstFileFromTar(archive)).toThrow('Unsupported tar header encoding'); }); }); + describe('buildFlatTar', () => { + it('packs multiple files in order with their modes', () => { + const archive = buildFlatTar([ + { name: 'a.conf', content: Buffer.from('first'), mode: 0o640 }, + { name: 'b.txt', content: Buffer.from('second') }, + ]); + + expect(extractFirstFileFromTar(archive).toString()).toBe('first'); + expect(archive.subarray(100, 107).toString('ascii')).toBe('0000640'); + // Second header follows the 512-byte aligned first entry. + expect(archive.subarray(1024, 1029).toString('ascii')).toBe('b.txt'); + expect(archive.subarray(1024 + 512, 1024 + 518).toString('utf-8')).toBe('second'); + expect(archive.length % 512).toBe(0); + }); + + it('rejects nested or special entry names', () => { + expect(() => buildFlatTar([{ name: 'dir/file', content: Buffer.alloc(0) }])).toThrow('Invalid tar entry name'); + expect(() => buildFlatTar([{ name: '..', content: Buffer.alloc(0) }])).toThrow('Invalid tar entry name'); + }); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.ts index fc43be686..ca6ca4b9e 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/agent-environment-file.utils.ts @@ -104,36 +104,51 @@ function writeTarOctal(header: Buffer, value: number, offset: number, length: nu * Ownership is root (Docker ignores tar ownership unless `copyUIDGID` is requested). */ export function buildSingleFileTar(fileName: string, content: Buffer, mode = 0o600): Buffer { - if (!fileName || fileName.includes('/') || Buffer.byteLength(fileName) > 100) { - throw new Error(`Invalid tar entry name '${fileName}'`); - } + return buildFlatTar([{ name: fileName, content, mode }]); +} - const header = Buffer.alloc(TAR_BLOCK_SIZE, 0); - - writeTarString(header, fileName, 0, 100); - writeTarOctal(header, mode, 100, 8); - writeTarOctal(header, 0, 108, 8); - writeTarOctal(header, 0, 116, 8); - writeTarOctal(header, content.length, 124, 12); - writeTarOctal(header, Math.floor(Date.now() / 1000), 136, 12); - header.fill(' ', 148, 156); - header.write('0', 156, 1, 'ascii'); - writeTarString(header, 'ustar\0', 257, 6); - writeTarString(header, '00', 263, 2); - writeTarString(header, 'root', 265, 32); - writeTarString(header, 'root', 297, 32); - - let checksum = 0; - - for (const byte of header) { - checksum += byte; - } +/** + * Builds a flat (no directories) multi-file ustar archive for Docker's `PUT /containers/{id}/archive`. + * Ownership is root (Docker ignores tar ownership unless `copyUIDGID` is requested). + */ +export function buildFlatTar(entries: ReadonlyArray<{ name: string; content: Buffer; mode?: number }>): Buffer { + const parts: Buffer[] = []; + const mtime = Math.floor(Date.now() / 1000); - writeTarString(header, `${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8); + for (const { name, content, mode = 0o600 } of entries) { + if (!name || name.includes('/') || name === '.' || name === '..' || Buffer.byteLength(name) > 100) { + throw new Error(`Invalid tar entry name '${name}'`); + } + + const header = Buffer.alloc(TAR_BLOCK_SIZE, 0); + + writeTarString(header, name, 0, 100); + writeTarOctal(header, mode, 100, 8); + writeTarOctal(header, 0, 108, 8); + writeTarOctal(header, 0, 116, 8); + writeTarOctal(header, content.length, 124, 12); + writeTarOctal(header, mtime, 136, 12); + header.fill(' ', 148, 156); + header.write('0', 156, 1, 'ascii'); + writeTarString(header, 'ustar\0', 257, 6); + writeTarString(header, '00', 263, 2); + writeTarString(header, 'root', 265, 32); + writeTarString(header, 'root', 297, 32); + + let checksum = 0; + + for (const byte of header) { + checksum += byte; + } - const padding = (TAR_BLOCK_SIZE - (content.length % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE; + writeTarString(header, `${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8); + + const padding = (TAR_BLOCK_SIZE - (content.length % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE; + + parts.push(header, content, Buffer.alloc(padding, 0)); + } - return Buffer.concat([header, content, Buffer.alloc(padding, 0), Buffer.alloc(TAR_BLOCK_SIZE * 2, 0)]); + return Buffer.concat([...parts, Buffer.alloc(TAR_BLOCK_SIZE * 2, 0)]); } function readTarOctal(block: Buffer, offset: number, length: number): number { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.spec.ts new file mode 100644 index 000000000..3928cabd4 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.spec.ts @@ -0,0 +1,274 @@ +import { + resolveEffectiveEgressPolicy, + type EffectiveEgressPolicy, +} from '@forepath/agenstra/shared/util-opencode-config'; + +import { + computeEgressPolicyRevision, + parseEgressUpstreamProxy, + renderEgressGuardArtifacts, +} from './egress-guard-config.utils'; + +function policyFor(egress: Record): EffectiveEgressPolicy { + return resolveEffectiveEgressPolicy([{ source: 'global', overlay: { egress } }]); +} + +describe('renderEgressGuardArtifacts', () => { + it('renders every bundle file with a revision', () => { + const artifacts = renderEgressGuardArtifacts(policyFor({ mode: 'enforce' })); + + expect(Object.keys(artifacts.files).sort()).toEqual( + [ + 'allow-domains.txt', + 'allow-ips.txt', + 'deny-domains.txt', + 'deny-ips.txt', + 'deny-url-patterns.txt', + 'dnsmasq.conf', + 'policy.json', + 'private-ips.txt', + 'rules.v4', + 'rules.v6', + 'squid.conf', + ].sort(), + ); + expect(artifacts.revision).toMatch(/^[0-9a-f]{32}$/); + expect(JSON.parse(artifacts.files['policy.json'])).toEqual({ + revision: artifacts.revision, + mode: 'enforce', + logAllowed: true, + }); + }); + + it('renders deny lists, private networks and URL patterns in enforce mode', () => { + const { files } = renderEgressGuardArtifacts( + policyFor({ + mode: 'enforce', + deny_hosts: ['example.org', 'sub.example.org', '1.1.1.1'], + deny_url_patterns: ['/forbidden/'], + }), + ); + + expect(files['deny-domains.txt']).toBe('.example.org\n'); + expect(files['deny-ips.txt']).toBe('1.1.1.1\n'); + expect(files['deny-url-patterns.txt']).toBe('/forbidden/\n'); + expect(files['private-ips.txt']).toContain('169.254.0.0/16\n'); + expect(files['squid.conf']).toContain('http_access deny !CONNECT deny_domains'); + expect(files['squid.conf']).toContain('http_access deny CONNECT deny_sni'); + expect(files['squid.conf']).toContain('ssl_bump terminate deny_sni'); + expect(files['squid.conf']).toContain('ssl_bump terminate private_dst'); + expect(files['squid.conf']).toContain('http_access deny !CONNECT deny_url'); + expect(files['squid.conf']).toContain('note agenstra_rule deny_host CONNECT deny_sni'); + expect(files['squid.conf']).not.toContain('allow_domains'); + expect(files['squid.conf']).toContain('host_verify_strict on'); + expect(files['dnsmasq.conf']).toContain('address=/example.org/'); + expect(files['dnsmasq.conf']).toContain('stop-dns-rebind'); + expect(files['rules.v4']).toContain('-A AGENSTRA_EGRESS_NAT -p tcp --dport 443 -j REDIRECT --to-ports 3130'); + expect(files['rules.v4']).toContain('-m comment --comment "agenstra:quic" -j REJECT'); + expect(files['rules.v4']).toContain('-m comment --comment "agenstra:undeclared_protocol" -j REJECT'); + }); + + it('renders an allowlist with deny precedence and DNS restricted to allowed names', () => { + const { files } = renderEgressGuardArtifacts( + policyFor({ + mode: 'enforce', + allow_hosts: ['github.com', '*.githubusercontent.com', '10.20.0.0/16'], + deny_hosts: ['gist.github.com'], + }), + ); + + expect(files['allow-domains.txt']).toBe('.github.com\n.githubusercontent.com\n'); + expect(files['allow-ips.txt']).toBe('10.20.0.0/16\n'); + expect(files['squid.conf']).toContain('http_access deny CONNECT !allow_sni !allow_dst'); + expect(files['squid.conf']).toContain('ssl_bump terminate !allow_sni !allow_dst'); + // Explicitly allowed internal CIDRs are exempt from the private network block. + expect(files['squid.conf']).toContain('ssl_bump terminate private_dst !allow_sni !allow_dst'); + expect(files['squid.conf'].indexOf('ssl_bump terminate deny_sni')).toBeLessThan( + files['squid.conf'].indexOf('ssl_bump terminate !allow_sni !allow_dst'), + ); + expect(files['dnsmasq.conf']).toContain('address=/gist.github.com/'); + expect(files['dnsmasq.conf']).toContain('address=/#/'); + expect(files['dnsmasq.conf']).toContain('server=/github.com/#'); + expect(files['dnsmasq.conf']).toContain('rebind-domain-ok=/github.com/'); + }); + + it('renders network exceptions in NAT and filter chains', () => { + const { files } = renderEgressGuardArtifacts( + policyFor({ + mode: 'enforce', + network_allow: [ + { protocol: 'tcp', ports: '22', destinations: ['140.82.112.0/20'] }, + { protocol: 'tcp', ports: '5432-5433', destinations: ['2001:db8::/32'] }, + ], + }), + ); + + expect(files['rules.v4']).toContain('-A AGENSTRA_EGRESS_NAT -d 140.82.112.0/20 -p tcp --dport 22 -j RETURN'); + expect(files['rules.v4']).toContain( + '-A AGENSTRA_EGRESS_OUT -d 140.82.112.0/20 -p tcp --dport 22 -m comment --comment "agenstra:network_allow" -j ACCEPT', + ); + expect(files['rules.v4']).not.toContain('2001:db8'); + expect(files['rules.v6']).toContain('-d 2001:db8::/32 -p tcp --dport 5432:5433'); + }); + + it('drops blocked plain HTTP with a TCP reset and answers blocked CONNECT with a bare 403', () => { + const { files } = renderEgressGuardArtifacts( + policyFor({ mode: 'enforce', allow_hosts: ['example.org'], deny_hosts: ['203.0.113.7'] }), + ); + const denyLines = files['squid.conf'].split('\n').filter((line) => line.startsWith('http_access deny')); + + expect(files['squid.conf']).toContain('acl agenstra_drop src all\ndeny_info TCP_RESET agenstra_drop'); + expect(denyLines).toContain('http_access deny !CONNECT deny_dst agenstra_drop'); + expect(denyLines).toContain('http_access deny CONNECT deny_dst'); + expect(denyLines).toContain('http_access deny CONNECT !tls_ports'); + + for (const line of denyLines) { + // Every non-CONNECT denial ends with the reset ACL; CONNECT denials never do. + expect(line.endsWith(' agenstra_drop')).toBe(!line.startsWith('http_access deny CONNECT ')); + } + }); + + it('exempts only marked proxy traffic and resolver-bound DNS, never a bare UID', () => { + const { files } = renderEgressGuardArtifacts(policyFor({ mode: 'enforce' })); + + expect(files['squid.conf']).toContain('tcp_outgoing_mark 0x4147 all'); + expect(files['squid.conf']).toContain('dns_nameservers 127.0.0.1'); + expect(files['rules.v4']).toContain('-A AGENSTRA_EGRESS_NAT -m mark --mark 0x4147 -j RETURN'); + expect(files['rules.v4']).toContain('-A AGENSTRA_EGRESS_OUT -m mark --mark 0x4147 -j ACCEPT'); + expect(files['rules.v4']).toContain( + '-A AGENSTRA_EGRESS_NAT -m owner --uid-owner 10902 -p udp --dport 53 -j RETURN', + ); + expect(files['rules.v4']).toContain( + '-A AGENSTRA_EGRESS_OUT -m owner --uid-owner 10902 -j AGENSTRA_EGRESS_RESOLVERS', + ); + expect(files['rules.v4']).not.toContain('--uid-owner 10901'); + expect(files['rules.v4']).not.toMatch(/--uid-owner 10902 -j (ACCEPT|RETURN)/); + expect(files['rules.v6']).not.toContain('AGENSTRA_EGRESS_RESOLVERS'); + }); + + it('ignores network exceptions in offline mode', () => { + const { files } = renderEgressGuardArtifacts( + policyFor({ + mode: 'offline', + network_allow: [{ protocol: 'tcp', ports: '22', destinations: ['140.82.112.0/20'] }], + }), + ); + + expect(files['rules.v4']).not.toContain('140.82.112.0/20'); + }); + + it('only annotates (never denies) in monitor mode and counts without rejecting', () => { + const { files } = renderEgressGuardArtifacts(policyFor({ mode: 'monitor', deny_hosts: ['example.org'] })); + + expect(files['squid.conf']).toContain('note agenstra_rule deny_host CONNECT deny_sni'); + expect(files['squid.conf']).not.toContain('http_access deny'); + expect(files['squid.conf']).not.toContain('ssl_bump terminate'); + expect(files['dnsmasq.conf']).not.toContain('address=/'); + expect(files['rules.v4']).toContain( + '-A AGENSTRA_EGRESS_OUT -p udp --dport 443 -m comment --comment "agenstra:quic"\n', + ); + expect(files['rules.v4']).not.toContain('-j REJECT'); + }); + + it('blocks everything in offline mode and passes everything through when off', () => { + const offline = renderEgressGuardArtifacts(policyFor({ mode: 'offline' })).files; + + expect(offline['squid.conf']).toContain( + 'http_access deny !CONNECT all agenstra_drop\nhttp_access deny CONNECT all', + ); + expect(offline['squid.conf']).toContain('ssl_bump terminate all'); + expect(offline['dnsmasq.conf']).toContain('address=/#/'); + + const off = renderEgressGuardArtifacts(policyFor({ mode: 'off' })).files; + + expect(off['rules.v4']).not.toContain('REDIRECT'); + expect(off['rules.v4']).toContain('-A AGENSTRA_EGRESS_OUT -j ACCEPT'); + expect(off['rules.v6']).toContain('-A AGENSTRA_EGRESS_OUT -j ACCEPT'); + }); + + it('logs only annotated decisions when allowed traffic logging is disabled', () => { + const { files } = renderEgressGuardArtifacts(policyFor({ mode: 'enforce', log_allowed: false })); + + expect(files['squid.conf']).toContain('access_log stdio:/var/log/agenstra-egress/access.log agenstra has_rule'); + }); + + it('chains the configured upstream proxy and keeps NO_PROXY hosts direct', () => { + const upstream = parseEgressUpstreamProxy('http://user:secret@proxy.corp:8080', 'internal.corp, localhost'); + const { files } = renderEgressGuardArtifacts(policyFor({ mode: 'enforce' }), upstream); + + expect(files['squid.conf']).toContain( + 'cache_peer proxy.corp parent 8080 0 no-query no-digest default name=agenstra_upstream login=user:secret', + ); + expect(files['squid.conf']).toContain('acl upstream_bypass dstdomain -n .internal.corp'); + expect(files['squid.conf']).toContain('never_direct allow all'); + }); + + it('never renders unvalidated values (configuration injection)', () => { + const malicious = { + ...policyFor({ mode: 'enforce' }), + denyHosts: [ + { value: 'evil.example\nhttp_access allow all', source: 'global' }, + { value: 'ok.example', source: 'global' }, + ], + denyUrlPatterns: [{ value: 'a\nb', source: 'global' }], + networkAllow: [{ protocol: 'tcp', ports: '22 -j ACCEPT', destinations: ['1.1.1.1'], source: 'global' }], + } as unknown as EffectiveEgressPolicy; + const { files } = renderEgressGuardArtifacts(malicious); + + expect(files['deny-domains.txt']).toBe('.ok.example\n'); + expect(files['deny-url-patterns.txt']).toBe(''); + expect(files['rules.v4']).not.toContain('--dport 22 -j ACCEPT -j'); + expect(Object.values(files).join('\n')).not.toContain('http_access allow all\nhttp_access'); + }); +}); + +describe('computeEgressPolicyRevision', () => { + it('is stable and changes with policy or upstream', () => { + const policy = policyFor({ mode: 'enforce', deny_hosts: ['a.example'] }); + + expect(computeEgressPolicyRevision(policy)).toBe(computeEgressPolicyRevision({ ...policy })); + expect(computeEgressPolicyRevision(policy)).not.toBe( + computeEgressPolicyRevision(policyFor({ mode: 'enforce', deny_hosts: ['b.example'] })), + ); + expect(computeEgressPolicyRevision(policy)).not.toBe( + computeEgressPolicyRevision(policy, parseEgressUpstreamProxy('http://proxy:3128', null)), + ); + // Provenance does not change what the guard enforces. + expect(computeEgressPolicyRevision(policy)).toBe( + computeEgressPolicyRevision({ ...policy, scalarSources: { ...policy.scalarSources, mode: 'workspace' } }), + ); + }); +}); + +describe('parseEgressUpstreamProxy', () => { + it('parses host, port, credentials and bypass list', () => { + expect(parseEgressUpstreamProxy('proxy.corp:3128', 'a.corp,*.b.corp,10.0.0.1')).toEqual({ + host: 'proxy.corp', + port: 3128, + bypassHosts: ['a.corp', 'b.corp'], + }); + expect(parseEgressUpstreamProxy('http://u%40x:p%3Aw@proxy:80', null)).toEqual({ + host: 'proxy', + port: 80, + username: 'u@x', + password: 'p:w', + bypassHosts: [], + }); + }); + + it('rejects unsupported schemes and drops unsafe credentials', () => { + expect(parseEgressUpstreamProxy('http://u%zz:p@proxy:8080', null)).toEqual({ + host: 'proxy', + port: 8080, + bypassHosts: [], + }); + expect(parseEgressUpstreamProxy('https://proxy:443', null)).toBeNull(); + expect(parseEgressUpstreamProxy('', null)).toBeNull(); + expect(parseEgressUpstreamProxy('http://u:p%20w@proxy:8080', null)).toEqual({ + host: 'proxy', + port: 8080, + username: 'u', + bypassHosts: [], + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.ts new file mode 100644 index 000000000..1660a5155 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-config.utils.ts @@ -0,0 +1,635 @@ +import { createHash } from 'crypto'; + +import { + compactEgressHostEntries, + EGRESS_PRIVATE_NETWORK_CIDRS, + normalizeEffectiveEgressPolicy, + normalizeEgressHost, + parseEgressPortSpec, + stablePlatformPolicyJson, + type EffectiveEgressNetworkRule, + type EffectiveEgressPolicy, +} from '@forepath/agenstra/shared/util-opencode-config'; + +import { + EGRESS_GUARD_ACTIVE_DIR, + EGRESS_GUARD_COUNTER_COMMENT_PREFIX, + EGRESS_GUARD_DNS_PORT, + EGRESS_GUARD_DNS_UID, + EGRESS_GUARD_EXPLICIT_PROXY_PORT, + EGRESS_GUARD_HTTP_INTERCEPT_PORT, + EGRESS_GUARD_HTTPS_INTERCEPT_PORT, + EGRESS_GUARD_PROXY_MARK, + EGRESS_GUARD_RESOLVERS_CHAIN, + EGRESS_GUARD_STATE_DIR, + EGRESS_GUARD_WEB_LOG_MARKER, +} from '../constants/egress-guard.constants'; + +/** Parent proxy the guard chains to (from the layered `HTTPS_PROXY` / `HTTP_PROXY` network secrets). */ +export interface EgressUpstreamProxy { + host: string; + port: number; + username?: string; + password?: string; + /** Hosts that bypass the parent proxy (from `NO_PROXY`). */ + bypassHosts: string[]; +} + +export interface EgressGuardArtifacts { + revision: string; + /** File name → content, written into the guard's staged policy directory. */ + files: Record; +} + +/** Bump when the rendered artifact format changes so running guards are re-applied after upgrades. */ +export const EGRESS_GUARD_RENDER_VERSION = 1; + +const SQUID_LOG_FORMAT = [ + EGRESS_GUARD_WEB_LOG_MARKER, + '%ts.%03tu', + '%Ss', + '%>Hs', + '%rm', + '%#ru', + '%#>rd', + '%#ssl::>sni', + '%st', + '%tr', + '%#{agenstra_rule}note', + '%err_code', +].join(' '); + +function activeFile(name: string): string { + return `"${EGRESS_GUARD_ACTIVE_DIR}/${name}"`; +} + +function splitHosts(entries: readonly { value: string }[]): { domains: string[]; ips: string[] } { + const domains: string[] = []; + const ips: string[] = []; + + for (const entry of compactEgressHostEntries(entries)) { + const normalized = normalizeEgressHost(entry.value); + + if (!normalized) { + continue; + } + + if (normalized.kind === 'domain') { + domains.push(normalized.value); + } else { + ips.push(normalized.value); + } + } + + return { domains, ips }; +} + +function lines(values: readonly string[]): string { + return values.length ? `${values.join('\n')}\n` : ''; +} + +/** + * Parse a proxy URL (`http://user:pass@host:port`) into a parent proxy definition. + * Credentials containing characters Squid cannot represent in `login=` are dropped. + */ +export function parseEgressUpstreamProxy( + proxyUrl: string | undefined | null, + noProxy: string | undefined | null, +): EgressUpstreamProxy | null { + const raw = proxyUrl?.trim(); + + if (!raw) { + return null; + } + + let parsed: URL; + + try { + parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`); + } catch { + return null; + } + + if (parsed.protocol !== 'http:') { + // Squid speaks plain HTTP (CONNECT) to a parent; TLS-to-proxy is not supported here. + return null; + } + + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + + if (!host || /[\s"]/.test(host)) { + return null; + } + + const port = parsed.port ? Number(parsed.port) : 80; + const safeCredential = (value: string) => value && /^[^\s"'\\]+$/.test(value); + const decode = (value: string): string | undefined => { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } + }; + const username = parsed.username ? decode(parsed.username) : undefined; + const password = parsed.password ? decode(parsed.password) : undefined; + const bypassHosts = (noProxy ?? '') + .split(',') + .map((entry) => normalizeEgressHost(entry.trim())) + .filter((entry): entry is NonNullable => !!entry && entry.kind === 'domain') + .map((entry) => entry.value) + .filter((value) => value !== 'localhost'); + + return { + host, + port, + ...(username && safeCredential(username) ? { username } : {}), + ...(username && password && safeCredential(username) && safeCredential(password) ? { password } : {}), + bypassHosts: [...new Set(bypassHosts)], + }; +} + +/** Deterministic revision for a policy + upstream proxy (+ render format version). */ +export function computeEgressPolicyRevision( + input: EffectiveEgressPolicy, + upstream: EgressUpstreamProxy | null = null, +): string { + const policy = normalizeEffectiveEgressPolicy(input); + + return createHash('sha256') + .update( + stablePlatformPolicyJson({ + version: EGRESS_GUARD_RENDER_VERSION, + policy: { ...policy, scalarSources: undefined }, + upstream, + }), + ) + .digest('hex') + .slice(0, 32); +} + +/** + * `http_access deny` lines for one rule: non-CONNECT requests are reset (`agenstra_drop`), CONNECT + * requests receive a bare 403 (clients report a failed tunnel). + */ +function denyLines(acls: string): string[] { + if (acls.startsWith('CONNECT ')) { + return [`http_access deny ${acls}`]; + } + + if (acls.startsWith('!CONNECT ')) { + return [`http_access deny ${acls} agenstra_drop`]; + } + + return [`http_access deny !CONNECT ${acls} agenstra_drop`, `http_access deny CONNECT ${acls}`]; +} + +function renderSquidConfig( + policy: EffectiveEgressPolicy, + upstream: EgressUpstreamProxy | null, + hosts: { allow: { domains: string[]; ips: string[] }; deny: { domains: string[]; ips: string[] } }, + revision: string, +): string { + const out: string[] = [ + `# Generated by the Agenstra agent manager — revision ${revision}, mode ${policy.mode}. Do not edit.`, + `http_port 127.0.0.1:${EGRESS_GUARD_EXPLICIT_PROXY_PORT} name=explicit`, + `http_port 127.0.0.1:${EGRESS_GUARD_HTTP_INTERCEPT_PORT} intercept name=http_intercept`, + `https_port 127.0.0.1:${EGRESS_GUARD_HTTPS_INTERCEPT_PORT} intercept ssl-bump name=https_intercept tls-cert=${EGRESS_GUARD_STATE_DIR}/bootstrap.pem generate-host-certificates=off`, + 'pid_filename /run/agenstra-egress/squid.pid', + 'cache_effective_user egressproxy', + 'cache_effective_group egressproxy', + // Upstream sockets carry a mark (needs CAP_NET_ADMIN) — netfilter trusts the mark, not the UID. + `tcp_outgoing_mark ${EGRESS_GUARD_PROXY_MARK} all`, + // Resolve through the guard's DNS policy proxy (port 53 is redirected to dnsmasq). + 'dns_nameservers 127.0.0.1', + 'cache deny all', + 'cache_mem 8 MB', + 'cache_log /var/log/agenstra-egress/cache.log', + 'logfile_rotate 1', + 'coredump_dir /tmp', + 'strip_query_terms on', + 'host_verify_strict on', + 'forwarded_for delete', + 'via off', + 'httpd_suppress_version_string on', + 'shutdown_lifetime 1 seconds', + '', + 'acl step1 at_step SslBump1', + 'acl CONNECT method CONNECT', + 'acl tls_ports port 443', + 'acl intercepted_tls myportname https_intercept', + 'acl has_rule note agenstra_rule', + // Blocked plain HTTP is dropped with a TCP reset instead of a Squid error page (deny_info applies + // to the last ACL of the matching http_access line). Blocked CONNECT keeps the bare 403, which + // clients report as a failed tunnel; intercepted TLS is terminated during the handshake. + 'acl agenstra_drop src all', + 'deny_info TCP_RESET agenstra_drop', + ]; + + const allowDomains = hosts.allow.domains.length > 0; + const allowIps = hosts.allow.ips.length > 0; + const denyDomains = hosts.deny.domains.length > 0; + const denyIps = hosts.deny.ips.length > 0; + const urlPatterns = policy.denyUrlPatterns.length > 0; + const hasAllowlist = allowDomains || allowIps; + + if (allowDomains) { + out.push(`acl allow_domains dstdomain -n ${activeFile('allow-domains.txt')}`); + out.push(`acl allow_sni ssl::server_name ${activeFile('allow-domains.txt')}`); + } + + if (allowIps) { + out.push(`acl allow_dst dst ${activeFile('allow-ips.txt')}`); + } + + if (denyDomains) { + out.push(`acl deny_domains dstdomain -n ${activeFile('deny-domains.txt')}`); + out.push(`acl deny_sni ssl::server_name ${activeFile('deny-domains.txt')}`); + } + + if (denyIps) { + out.push(`acl deny_dst dst ${activeFile('deny-ips.txt')}`); + } + + if (policy.blockPrivateNetworks) { + out.push(`acl private_dst dst ${activeFile('private-ips.txt')}`); + } + + if (policy.blockIpLiterals) { + out.push('acl ip_literal_host dstdom_regex -n -i ^[0-9.]+$ ^\\[?[0-9a-f]*:[0-9a-f:.]*\\]?$'); + out.push('acl ip_literal_sni ssl::server_name_regex -i ^[0-9.]+$ ^\\[?[0-9a-f]*:[0-9a-f:.]*\\]?$'); + } + + if (urlPatterns) { + out.push(`acl deny_url url_regex -i ${activeFile('deny-url-patterns.txt')}`); + } + + const notAllowedHttp = [allowDomains ? '!allow_domains' : null, allowIps ? '!allow_dst' : null] + .filter(Boolean) + .join(' '); + const notAllowedTls = [allowDomains ? '!allow_sni' : null, allowIps ? '!allow_dst' : null].filter(Boolean).join(' '); + const withAllowExemption = (condition: string, exemption: string) => + exemption ? `${condition} ${exemption}` : condition; + + // Annotations explain decisions in the access log (also in monitor mode, where nothing is denied). + out.push(''); + + const notes: string[] = []; + + if (policy.mode === 'offline') { + notes.push('note agenstra_rule offline all'); + } else { + if (denyDomains) { + notes.push('note agenstra_rule deny_host !CONNECT deny_domains'); + notes.push('note agenstra_rule deny_host CONNECT deny_sni'); + } + + if (denyIps) { + notes.push('note agenstra_rule deny_host deny_dst'); + } + + if (policy.blockPrivateNetworks) { + notes.push(`note agenstra_rule private_network ${withAllowExemption('!CONNECT private_dst', notAllowedHttp)}`); + notes.push(`note agenstra_rule private_network ${withAllowExemption('CONNECT private_dst', notAllowedTls)}`); + } + + if (policy.blockIpLiterals) { + notes.push( + `note agenstra_rule ip_literal ${withAllowExemption('!CONNECT ip_literal_host', allowIps ? '!allow_dst' : '')}`, + ); + notes.push( + `note agenstra_rule ip_literal ${withAllowExemption('CONNECT ip_literal_sni', allowIps ? '!allow_dst' : '')}`, + ); + } + + if (urlPatterns) { + notes.push('note agenstra_rule deny_url !CONNECT deny_url'); + } + + if (hasAllowlist) { + notes.push(`note agenstra_rule not_allowlisted !CONNECT ${notAllowedHttp}`); + notes.push(`note agenstra_rule not_allowlisted CONNECT ${notAllowedTls}`); + } + + notes.push('note agenstra_rule undeclared_protocol CONNECT !tls_ports !intercepted_tls'); + } + + out.push(...notes, ''); + + // Intercepted TLS is decided after peeking the ClientHello (ssl_bump below); the first fake CONNECT + // only carries the destination IP, so it must not be judged by host name here. + out.push('http_access allow intercepted_tls'); + + if (policy.mode === 'offline') { + out.push(...denyLines('all')); + } else if (policy.mode === 'enforce') { + if (denyDomains) { + out.push(...denyLines('!CONNECT deny_domains')); + out.push(...denyLines('CONNECT deny_sni')); + } + + if (denyIps) { + out.push(...denyLines('deny_dst')); + } + + if (policy.blockPrivateNetworks) { + out.push(...denyLines(withAllowExemption('!CONNECT private_dst', notAllowedHttp))); + out.push(...denyLines(withAllowExemption('CONNECT private_dst', notAllowedTls))); + } + + if (policy.blockIpLiterals) { + out.push(...denyLines(withAllowExemption('!CONNECT ip_literal_host', allowIps ? '!allow_dst' : ''))); + out.push(...denyLines(withAllowExemption('CONNECT ip_literal_sni', allowIps ? '!allow_dst' : ''))); + } + + if (urlPatterns) { + out.push(...denyLines('!CONNECT deny_url')); + } + + if (hasAllowlist) { + out.push(...denyLines(`!CONNECT ${notAllowedHttp}`)); + out.push(...denyLines(`CONNECT ${notAllowedTls}`)); + } + + out.push(...denyLines('CONNECT !tls_ports')); + out.push('http_access allow all'); + } else { + out.push('http_access allow all'); + } + + out.push('', 'ssl_bump peek step1'); + + if (policy.mode === 'offline') { + out.push('ssl_bump terminate all'); + } else if (policy.mode === 'enforce') { + if (denyDomains) { + out.push('ssl_bump terminate deny_sni'); + } + + if (denyIps) { + out.push('ssl_bump terminate deny_dst'); + } + + if (policy.blockPrivateNetworks) { + out.push(`ssl_bump terminate ${withAllowExemption('private_dst', notAllowedTls)}`); + } + + if (policy.blockIpLiterals) { + out.push(`ssl_bump terminate ${withAllowExemption('ip_literal_sni', allowIps ? '!allow_dst' : '')}`); + } + + if (hasAllowlist) { + out.push(`ssl_bump terminate ${notAllowedTls}`); + } + } + + out.push('ssl_bump splice all', ''); + + if (upstream) { + const login = + upstream.username !== undefined + ? ` login=${upstream.username}${upstream.password !== undefined ? `:${upstream.password}` : ''}` + : ''; + + out.push( + `cache_peer ${upstream.host} parent ${upstream.port} 0 no-query no-digest default name=agenstra_upstream${login}`, + ); + + if (upstream.bypassHosts.length > 0) { + out.push(`acl upstream_bypass dstdomain -n ${upstream.bypassHosts.map((host) => `.${host}`).join(' ')}`); + out.push('always_direct allow upstream_bypass'); + } + + out.push('never_direct allow all', ''); + } + + out.push(`logformat agenstra ${SQUID_LOG_FORMAT}`); + out.push(`access_log stdio:/var/log/agenstra-egress/access.log agenstra${policy.logAllowed ? '' : ' has_rule'}`); + + return `${out.join('\n')}\n`; +} + +function renderDnsmasqConfig( + policy: EffectiveEgressPolicy, + hosts: { allow: { domains: string[] }; deny: { domains: string[] } }, +): string { + const out = [ + '# Generated by the Agenstra agent manager. Do not edit.', + `port=${EGRESS_GUARD_DNS_PORT}`, + 'listen-address=127.0.0.1', + 'bind-interfaces', + 'user=egressdns', + 'group=egressdns', + 'pid-file=/run/agenstra-egress/dnsmasq.pid', + 'cache-size=1000', + 'log-queries', + 'log-facility=-', + ]; + + if (policy.mode === 'off') { + return `${out.join('\n')}\n`; + } + + if (policy.blockPrivateNetworks && policy.mode !== 'monitor') { + out.push('stop-dns-rebind'); + + for (const domain of hosts.allow.domains) { + out.push(`rebind-domain-ok=/${domain}/`); + } + } + + if (policy.mode === 'offline') { + out.push('address=/#/'); + } else if (policy.mode === 'enforce') { + for (const domain of hosts.deny.domains) { + out.push(`address=/${domain}/`); + } + + if (hosts.allow.domains.length > 0) { + // Only allowlisted names resolve; IP / CIDR allow entries need no DNS. + out.push('address=/#/'); + + for (const domain of hosts.allow.domains) { + out.push(`server=/${domain}/#`); + } + } + } + + return `${out.join('\n')}\n`; +} + +function portMatch(protocol: 'tcp' | 'udp', ports: string): string | null { + const range = parseEgressPortSpec(ports); + + if (!range) { + return null; + } + + return `-p ${protocol} --dport ${range.from === range.to ? range.from : `${range.from}:${range.to}`}`; +} + +function counted(category: string): string { + return `-m comment --comment "${EGRESS_GUARD_COUNTER_COMMENT_PREFIX}${category}"`; +} + +function networkRuleDestinations(rule: EffectiveEgressNetworkRule, family: 4 | 6): string[] { + return rule.destinations.filter((destination) => { + const normalized = normalizeEgressHost(destination); + + return family === 4 ? normalized?.kind === 'ipv4' : normalized?.kind === 'ipv6'; + }); +} + +function renderFilterOutChain(policy: EffectiveEgressPolicy, family: 4 | 6): string[] { + const chain = 'AGENSTRA_EGRESS_OUT'; + const rules = [`-A ${chain} -o lo -j ACCEPT`, `-A ${chain} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`]; + + if (policy.mode === 'off') { + rules.push(`-A ${chain} -j ACCEPT`); + + return rules; + } + + if (family === 4) { + rules.push(`-A ${chain} -m conntrack --ctstate DNAT -d 127.0.0.1/32 -j ACCEPT`); + } + + rules.push(`-A ${chain} -m mark --mark ${EGRESS_GUARD_PROXY_MARK} -j ACCEPT`); + + if (family === 4) { + // dnsmasq may only reach the resolvers listed by the guard at boot (see `agenstra-egress lockdown`). + rules.push(`-A ${chain} -m owner --uid-owner ${EGRESS_GUARD_DNS_UID} -j ${EGRESS_GUARD_RESOLVERS_CHAIN}`); + } + + // Offline means offline: declared network exceptions are ignored. + for (const rule of policy.mode === 'offline' ? [] : policy.networkAllow) { + const match = portMatch(rule.protocol, rule.ports); + + if (!match) { + continue; + } + + for (const destination of networkRuleDestinations(rule, family)) { + rules.push(`-A ${chain} -d ${destination} ${match} ${counted('network_allow')} -j ACCEPT`); + } + } + + const reject = 'REJECT'; + const categories: Array<{ match: string; category: string; target: string }> = [ + { match: '-p udp --dport 443', category: 'quic', target: reject }, + { match: '-p tcp --dport 853', category: 'dns_over_tls', target: `${reject} --reject-with tcp-reset` }, + { match: '-p udp --dport 853', category: 'dns_over_tls', target: reject }, + ]; + + if (family === 6) { + // IPv6 web traffic is not intercepted: reject so clients fall back to IPv4 through the guard. + categories.push({ match: '-p tcp -m multiport --dports 80,443', category: 'undeclared_protocol', target: reject }); + } + + if (policy.mode === 'monitor') { + // Count would-be blocks, then let traffic through. + for (const category of categories) { + rules.push(`-A ${chain} ${category.match} ${counted(category.category)}`); + } + + rules.push(`-A ${chain} ${counted('undeclared_protocol')}`); + rules.push(`-A ${chain} -j ACCEPT`); + + return rules; + } + + for (const category of categories) { + rules.push(`-A ${chain} ${category.match} ${counted(category.category)} -j ${category.target}`); + } + + rules.push(`-A ${chain} ${counted('undeclared_protocol')} -j ${reject}`); + + return rules; +} + +function renderIptablesV4(policy: EffectiveEgressPolicy): string { + const nat = ['*nat', ':AGENSTRA_EGRESS_NAT - [0:0]']; + + if (policy.mode !== 'off') { + nat.push( + `-A AGENSTRA_EGRESS_NAT -m mark --mark ${EGRESS_GUARD_PROXY_MARK} -j RETURN`, + `-A AGENSTRA_EGRESS_NAT -m owner --uid-owner ${EGRESS_GUARD_DNS_UID} -p udp --dport 53 -j RETURN`, + `-A AGENSTRA_EGRESS_NAT -m owner --uid-owner ${EGRESS_GUARD_DNS_UID} -p tcp --dport 53 -j RETURN`, + `-A AGENSTRA_EGRESS_NAT -p udp --dport 53 -j REDIRECT --to-ports ${EGRESS_GUARD_DNS_PORT}`, + `-A AGENSTRA_EGRESS_NAT -p tcp --dport 53 -j REDIRECT --to-ports ${EGRESS_GUARD_DNS_PORT}`, + '-A AGENSTRA_EGRESS_NAT -d 127.0.0.0/8 -j RETURN', + ); + + // Declared L3/L4 exceptions bypass the proxy entirely (e.g. TCP/443 to an internal service). + for (const rule of policy.mode === 'offline' ? [] : policy.networkAllow) { + const match = portMatch(rule.protocol, rule.ports); + + if (!match || rule.protocol !== 'tcp') { + continue; + } + + for (const destination of networkRuleDestinations(rule, 4)) { + nat.push(`-A AGENSTRA_EGRESS_NAT -d ${destination} ${match} -j RETURN`); + } + } + + nat.push( + `-A AGENSTRA_EGRESS_NAT -p tcp --dport 80 -j REDIRECT --to-ports ${EGRESS_GUARD_HTTP_INTERCEPT_PORT}`, + `-A AGENSTRA_EGRESS_NAT -p tcp --dport 443 -j REDIRECT --to-ports ${EGRESS_GUARD_HTTPS_INTERCEPT_PORT}`, + ); + } + + nat.push('COMMIT'); + + const filter = [ + '*filter', + ':AGENSTRA_EGRESS_IN - [0:0]', + ':AGENSTRA_EGRESS_OUT - [0:0]', + '-A AGENSTRA_EGRESS_IN -i lo -j RETURN', + // Guard listeners are loopback-only; never expose them to peers on the Docker network. + `-A AGENSTRA_EGRESS_IN -p tcp -m multiport --dports ${EGRESS_GUARD_EXPLICIT_PROXY_PORT}:${EGRESS_GUARD_HTTPS_INTERCEPT_PORT},${EGRESS_GUARD_DNS_PORT} -j DROP`, + `-A AGENSTRA_EGRESS_IN -p udp --dport ${EGRESS_GUARD_DNS_PORT} -j DROP`, + ...renderFilterOutChain(policy, 4), + 'COMMIT', + ]; + + return `${[...nat, ...filter].join('\n')}\n`; +} + +function renderIptablesV6(policy: EffectiveEgressPolicy): string { + return `${['*filter', ':AGENSTRA_EGRESS_OUT - [0:0]', ...renderFilterOutChain(policy, 6), 'COMMIT'].join('\n')}\n`; +} + +/** + * Render the complete guard policy bundle for an effective egress policy. + * The guard validates every file before atomically activating the bundle (last known good is kept). + */ +export function renderEgressGuardArtifacts( + input: EffectiveEgressPolicy, + upstream: EgressUpstreamProxy | null = null, +): EgressGuardArtifacts { + // Never trust the caller: every value ends up in Squid / dnsmasq / netfilter configuration files. + const policy = normalizeEffectiveEgressPolicy(input); + const revision = computeEgressPolicyRevision(policy, upstream); + const allow = splitHosts(policy.allowHosts); + const deny = splitHosts(policy.denyHosts); + const hosts = { allow, deny }; + + return { + revision, + files: { + 'squid.conf': renderSquidConfig(policy, upstream, hosts, revision), + 'allow-domains.txt': lines(allow.domains.map((domain) => `.${domain}`)), + 'allow-ips.txt': lines(allow.ips), + 'deny-domains.txt': lines(deny.domains.map((domain) => `.${domain}`)), + 'deny-ips.txt': lines(deny.ips), + 'deny-url-patterns.txt': lines(policy.denyUrlPatterns.map((pattern) => pattern.value)), + 'private-ips.txt': lines([...EGRESS_PRIVATE_NETWORK_CIDRS]), + 'dnsmasq.conf': renderDnsmasqConfig(policy, hosts), + 'rules.v4': renderIptablesV4(policy), + 'rules.v6': renderIptablesV6(policy), + 'policy.json': `${JSON.stringify({ revision, mode: policy.mode, logAllowed: policy.logAllowed })}\n`, + }, + }; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.spec.ts new file mode 100644 index 000000000..31b54d1f0 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.spec.ts @@ -0,0 +1,280 @@ +import { resolveEffectiveEgressPolicy } from '@forepath/agenstra/shared/util-opencode-config'; + +import { + aggregateEgressObservations, + compareDockerTimestamps, + diffEgressCounters, + parseDnsmasqLine, + parseEgressCounters, + parseGuardEventLine, + parseGuardLogLine, + parseSquidAccessLine, + splitDockerTimestamp, +} from './egress-guard-log.utils'; + +const enforcePolicy = resolveEffectiveEgressPolicy([ + { + source: 'workspace', + overlay: { egress: { mode: 'enforce', deny_hosts: ['example.org'], allow_hosts: ['github.com', 'example.org'] } }, + }, +]); + +describe('parseSquidAccessLine', () => { + it('classifies spliced tunnels as allowed and skips their peek records', () => { + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1791822321.645 NONE_NONE 0 CONNECT 140.82.121.4:443 140.82.121.4 github.com - 0 0 5 - -', + 'enforce', + enforcePolicy, + ), + ).toBeNull(); + + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1791822321.883 TCP_TUNNEL 200 CONNECT github.com:443 github.com github.com 140.82.121.4 587751 1898 237 - -', + 'enforce', + enforcePolicy, + ), + ).toEqual( + expect.objectContaining({ + kind: 'web', + decision: 'allowed', + host: 'github.com', + rule: null, + method: 'CONNECT', + httpStatus: 200, + url: null, + bytesIn: 587751, + bytesOut: 1898, + durationMs: 237, + occurredAt: new Date(1791822321883), + }), + ); + }); + + it('classifies terminated TLS and denied requests as blocked with provenance', () => { + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1791822322.256 NONE_NONE 0 CONNECT 172.66.147.243:443 172.66.147.243 www.example.org - 0 0 2 deny_host -', + 'enforce', + enforcePolicy, + ), + ).toEqual( + expect.objectContaining({ + decision: 'blocked', + host: 'www.example.org', + rule: 'deny_host', + matchedEntry: 'example.org', + matchedSource: 'workspace', + }), + ); + + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1791822322.588 TCP_DENIED 403 GET http://example.com/forbidden/x example.com - - 3366 86 0 deny_url ERR_ACCESS_DENIED', + 'enforce', + enforcePolicy, + ), + ).toEqual( + expect.objectContaining({ + decision: 'blocked', + rule: 'deny_url', + method: 'GET', + httpStatus: 403, + url: 'http://example.com/forbidden/x', + }), + ); + }); + + it('reports observed decisions in monitor mode and ignores monitor peek records', () => { + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1791822358.741 NONE_NONE 0 CONNECT 104.20.26.136:443 104.20.26.136 www.example.org - 0 0 4 deny_host -', + 'monitor', + enforcePolicy, + ), + ).toBeNull(); + + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1791822358.824 TCP_TUNNEL 200 CONNECT www.example.org:443 www.example.org www.example.org 104.20.26.136 6479 1924 82 deny_host -', + 'monitor', + enforcePolicy, + ), + ).toEqual(expect.objectContaining({ decision: 'observed', rule: 'deny_host' })); + }); + + it('decodes URL-encoded fields, takes the first of multiple annotations and redacts URLs', () => { + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1.0 TCP_DENIED 403 GET http://h.example/a%3Ftoken%3Dx h.example - - 1 1 0 not_allowlisted%2Cprivate_network ERR_ACCESS_DENIED', + 'enforce', + null, + ), + ).toEqual(expect.objectContaining({ rule: 'not_allowlisted', url: 'http://h.example/a' })); + }); + + it('ignores malformed lines', () => { + expect(parseSquidAccessLine('AGENSTRA_EGRESS garbage', 'enforce', null)).toBeNull(); + // Blocked plain HTTP is reset (no status code, error code TCP_RESET). + expect( + parseSquidAccessLine( + 'AGENSTRA_EGRESS 1791868671.354 TCP_DENIED 0 GET http://example.com/ example.com - - 0 123 0 deny_host TCP_RESET', + 'enforce', + null, + ), + ).toEqual( + expect.objectContaining({ decision: 'blocked', rule: 'deny_host', httpStatus: null, host: 'example.com' }), + ); + // Markers inside other lines (e.g. dnsmasq echoing an agent-chosen query name) are never trusted. + expect( + parseSquidAccessLine( + 'dnsmasq[1]: query[A] AGENSTRA_EGRESS 1.0 TCP_DENIED 403 GET http://x/ x - - 1 1 0 deny_host ERR_ACCESS_DENIED', + 'enforce', + null, + ), + ).toBeNull(); + expect(parseSquidAccessLine('unrelated', 'enforce', null)).toBeNull(); + }); +}); + +describe('parseDnsmasqLine', () => { + const at = new Date('2026-10-12T16:25:44.224Z'); + + it('records blocked lookups with the rule explaining them', () => { + expect(parseDnsmasqLine('dnsmasq[736]: config gitlab.com is NXDOMAIN', at, 'enforce', enforcePolicy)).toEqual({ + kind: 'dns', + decision: 'blocked', + occurredAt: at, + host: 'gitlab.com', + rule: 'not_allowlisted', + matchedEntry: null, + matchedSource: null, + count: 1, + }); + expect(parseDnsmasqLine('dnsmasq[1]: config www.example.org is NXDOMAIN', at, 'enforce', enforcePolicy)).toEqual( + expect.objectContaining({ rule: 'deny_host', matchedEntry: 'example.org', matchedSource: 'workspace' }), + ); + expect( + parseDnsmasqLine('dnsmasq[1]: possible DNS-rebind attack detected: evil.example', at, 'enforce', null), + ).toEqual(expect.objectContaining({ rule: 'private_network', host: 'evil.example' })); + expect(parseDnsmasqLine('dnsmasq[1]: config any.example is NXDOMAIN', at, 'offline', null)?.rule).toBe('offline'); + }); + + it('ignores allowed lookups and monitor / off modes', () => { + expect(parseDnsmasqLine('dnsmasq[1]: query[A] github.com from 127.0.0.1', at, 'enforce', null)).toBeNull(); + expect(parseDnsmasqLine('dnsmasq[1]: config gitlab.com is NXDOMAIN', at, 'monitor', null)).toBeNull(); + }); +}); + +describe('parseGuardEventLine', () => { + const at = new Date(); + + it('turns supervisor failures into guard error observations', () => { + expect( + parseGuardEventLine('AGENSTRA_GUARD {"event":"process_restarted","detail":"squid exited","ts":1}', at), + ).toEqual( + expect.objectContaining({ kind: 'guard', decision: 'error', message: 'process_restarted: squid exited' }), + ); + }); + + it('ignores informational events and malformed JSON', () => { + expect(parseGuardEventLine('AGENSTRA_GUARD {"event":"policy_applied","detail":"abc"}', at)).toBeNull(); + expect( + parseGuardEventLine('dnsmasq[1]: query[A] x AGENSTRA_GUARD {"event":"process_restarted","detail":"forged"}', at), + ).toBeNull(); + expect(parseGuardEventLine('AGENSTRA_GUARD {nope', at)).toBeNull(); + }); +}); + +describe('splitDockerTimestamp / compareDockerTimestamps / parseGuardLogLine', () => { + it('splits Docker timestamps and dispatches by source', () => { + const entry = splitDockerTimestamp('2026-10-12T16:25:44.224905981Z dnsmasq[736]: config gitlab.com is NXDOMAIN\r'); + + expect(entry).toEqual({ + timestamp: '2026-10-12T16:25:44.224905981Z', + line: 'dnsmasq[736]: config gitlab.com is NXDOMAIN', + }); + expect(parseGuardLogLine(entry!, 'enforce', enforcePolicy)?.kind).toBe('dns'); + expect(splitDockerTimestamp('no timestamp here')).toBeNull(); + }); + + it('compares nanosecond timestamps with different precision', () => { + expect(compareDockerTimestamps('2026-10-12T16:25:44.2Z', '2026-10-12T16:25:44.200000000Z')).toBe(0); + expect(compareDockerTimestamps('2026-10-12T16:25:44.2249Z', '2026-10-12T16:25:44.22491Z')).toBe(-1); + expect(compareDockerTimestamps('2026-10-12T16:25:45Z', '2026-10-12T16:25:44.9Z')).toBe(1); + }); +}); + +describe('counters', () => { + const output = [ + '[1:60] -A AGENSTRA_EGRESS_OUT -d 140.82.112.0/20 -p tcp -m tcp --dport 22 -m comment --comment "agenstra:network_allow" -j ACCEPT', + '[3:210] -A AGENSTRA_EGRESS_OUT -p udp -m udp --dport 443 -m comment --comment "agenstra:quic" -j REJECT --reject-with icmp-port-unreachable', + 'v6 [0:0] -A AGENSTRA_EGRESS_OUT -m comment --comment "agenstra:undeclared_protocol" -j REJECT', + 'garbage', + ].join('\n'); + + it('parses counted rules', () => { + expect(parseEgressCounters(output)).toEqual([ + expect.objectContaining({ + category: 'network_allow', + packets: 1, + protocol: 'tcp', + port: '22', + destination: '140.82.112.0/20', + }), + expect.objectContaining({ category: 'quic', packets: 3, protocol: 'udp', port: '443', destination: null }), + expect.objectContaining({ category: 'undeclared_protocol', packets: 0 }), + ]); + }); + + it('diffs against the previous snapshot (baseline first, resets counted fully)', () => { + const samples = parseEgressCounters(output); + const first = diffEgressCounters(null, samples, 'enforce', new Date()); + + expect(first.observations).toEqual([]); + + const grown = parseEgressCounters(output.replace('[3:210]', '[5:350]')); + const second = diffEgressCounters(first.snapshot, grown, 'enforce', new Date()); + + expect(second.observations).toEqual([ + expect.objectContaining({ kind: 'network', decision: 'blocked', rule: 'quic', count: 2, method: 'udp/443' }), + ]); + + const reset = parseEgressCounters(output.replace('[3:210]', '[1:70]')); + + expect(diffEgressCounters(second.snapshot, reset, 'monitor', new Date()).observations).toEqual([ + expect.objectContaining({ decision: 'observed', count: 1 }), + ]); + }); +}); + +describe('aggregateEgressObservations', () => { + it('collapses identical decisions and sums counters', () => { + const base = { + kind: 'web' as const, + decision: 'allowed' as const, + host: 'github.com', + rule: null, + method: 'CONNECT', + count: 1, + }; + const aggregated = aggregateEgressObservations([ + { ...base, occurredAt: new Date(2000), bytesIn: 10, bytesOut: 1 }, + { ...base, occurredAt: new Date(1000), bytesIn: 5, bytesOut: 2 }, + { ...base, host: 'gitlab.com', occurredAt: new Date(1500) }, + ]); + + expect(aggregated).toHaveLength(2); + expect(aggregated[0]).toEqual( + expect.objectContaining({ + host: 'github.com', + count: 2, + bytesIn: 15, + bytesOut: 3, + firstSeenAt: new Date(1000), + lastSeenAt: new Date(2000), + }), + ); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.ts new file mode 100644 index 000000000..622b96a7d --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/egress-guard-log.utils.ts @@ -0,0 +1,469 @@ +import { + EGRESS_RULES, + evaluateEgressDestination, + explainEgressRule, + redactEgressUrl, + type EffectiveEgressPolicy, + type EgressDecision, + type EgressEventKind, + type EgressMode, + type EgressRule, + type PlatformPolicyLayer, +} from '@forepath/agenstra/shared/util-opencode-config'; + +import { + EGRESS_GUARD_COUNTER_COMMENT_PREFIX, + EGRESS_GUARD_EVENT_LOG_MARKER, + EGRESS_GUARD_WEB_LOG_MARKER, +} from '../constants/egress-guard.constants'; + +/** One parsed guard decision (before aggregation). */ +export interface EgressGuardObservation { + kind: EgressEventKind; + decision: EgressDecision; + occurredAt: Date; + host: string | null; + rule: EgressRule | null; + matchedEntry?: string | null; + matchedSource?: PlatformPolicyLayer | null; + method?: string | null; + httpStatus?: number | null; + url?: string | null; + bytesIn?: number; + bytesOut?: number; + durationMs?: number; + count?: number; + message?: string | null; +} + +/** Parsed `docker logs --timestamps` line. */ +export interface TimestampedLogLine { + /** RFC 3339 nano timestamp as emitted by Docker (used as the collection cursor). */ + timestamp: string; + line: string; +} + +const RULE_SET = new Set(EGRESS_RULES); + +function toRule(raw: string | null | undefined): EgressRule | null { + if (!raw || raw === '-') { + return null; + } + + // Squid joins multiple matching annotations with commas; the first one is the decisive rule. + const first = decodeLogField(raw).split(',')[0]?.trim(); + + return first && RULE_SET.has(first) ? (first as EgressRule) : null; +} + +function decodeLogField(raw: string | undefined): string { + if (!raw || raw === '-') { + return ''; + } + + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +function toInt(raw: string | undefined): number { + const value = raw ? Number.parseInt(raw, 10) : NaN; + + return Number.isFinite(value) && value >= 0 ? value : 0; +} + +function stripPort(host: string): string { + if (host.startsWith('[')) { + const end = host.indexOf(']'); + + return end > 0 ? host.slice(1, end) : host; + } + + const colonCount = (host.match(/:/g) ?? []).length; + + return colonCount === 1 ? host.slice(0, host.indexOf(':')) : host; +} + +/** Split Docker's `--timestamps` prefix from a log line. */ +export function splitDockerTimestamp(rawLine: string): TimestampedLogLine | null { + const line = rawLine.replace(/\r$/, ''); + const space = line.indexOf(' '); + + if (space <= 0) { + return null; + } + + const timestamp = line.slice(0, space); + + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(timestamp)) { + return null; + } + + return { timestamp, line: line.slice(space + 1) }; +} + +/** Compare Docker RFC 3339 nano timestamps (lexicographic after zero-padding fractions). */ +export function compareDockerTimestamps(left: string, right: string): number { + const normalize = (value: string) => { + const [base, fraction = ''] = value.replace(/Z$/, '').split('.'); + + return `${base}.${fraction.padEnd(9, '0')}`; + }; + + const a = normalize(left); + const b = normalize(right); + + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Parse one Squid access log line (`AGENSTRA_EGRESS …`, see the guard `logformat`). + * Returns null for lines that are not decisions (e.g. TLS peek records of spliced connections). + */ +export function parseSquidAccessLine( + line: string, + mode: EgressMode, + policy: EffectiveEgressPolicy | null, +): EgressGuardObservation | null { + // Markers are only trusted at the start of a line: dnsmasq echoes agent-chosen query names. + if (!line.startsWith(`${EGRESS_GUARD_WEB_LOG_MARKER} `)) { + return null; + } + + const fields = line + .slice(EGRESS_GUARD_WEB_LOG_MARKER.length + 1) + .trim() + .split(/\s+/); + + if (fields.length < 13) { + return null; + } + + const [ + ts, + status, + httpStatusRaw, + method, + urlRaw, + domainRaw, + sniRaw, + , + bytesInRaw, + bytesOutRaw, + durationRaw, + noteRaw, + errCode, + ] = fields; + const seconds = Number.parseFloat(ts); + + if (!Number.isFinite(seconds)) { + return null; + } + + const rule = toRule(noteRaw); + const isPeekRecord = status.startsWith('NONE_NONE') || status.startsWith('TAG_NONE'); + const denied = status.startsWith('TCP_DENIED') || errCode === 'ERR_ACCESS_DENIED'; + let decision: EgressDecision; + + if (denied) { + decision = 'blocked'; + } else if (isPeekRecord) { + // Terminated intercepted TLS only produces the peek record; spliced connections log a tunnel too. + if (!rule || mode === 'monitor' || mode === 'off') { + return null; + } + + decision = 'blocked'; + } else if (rule && mode === 'monitor') { + decision = 'observed'; + } else { + decision = 'allowed'; + } + + const sni = decodeLogField(sniRaw); + const domain = decodeLogField(domainRaw); + const url = decodeLogField(urlRaw); + const host = stripPort((sni || domain || url).toLowerCase()) || null; + const effectiveRule: EgressRule | null = decision === 'allowed' ? null : (rule ?? (denied ? 'deny_host' : null)); + const provenance = policy && effectiveRule ? explainEgressRule(policy, effectiveRule, host) : {}; + const httpStatus = toInt(httpStatusRaw); + + return { + kind: 'web', + decision, + occurredAt: new Date(Math.round(seconds * 1000)), + host, + rule: effectiveRule, + matchedEntry: provenance.matchedEntry ?? null, + matchedSource: provenance.source ?? null, + method: method && method !== '-' ? method.slice(0, 16) : null, + httpStatus: httpStatus > 0 ? httpStatus : null, + url: method === 'CONNECT' ? null : redactEgressUrl(url), + bytesIn: toInt(bytesInRaw), + bytesOut: toInt(bytesOutRaw), + durationMs: toInt(durationRaw), + count: 1, + }; +} + +const DNSMASQ_CONFIG_BLOCK = /^dnsmasq(?:\[\d+\])?: config (\S+) is (NXDOMAIN|NODATA|0\.0\.0\.0|::)\s*$/; +const DNSMASQ_REBIND = /^dnsmasq(?:\[\d+\])?: possible DNS-rebind attack detected: (\S+)\s*$/; + +/** Parse dnsmasq query log lines into DNS block observations (allowed lookups are not recorded). */ +export function parseDnsmasqLine( + line: string, + occurredAt: Date, + mode: EgressMode, + policy: EffectiveEgressPolicy | null, +): EgressGuardObservation | null { + if (mode === 'off' || mode === 'monitor') { + return null; + } + + const rebind = DNSMASQ_REBIND.exec(line); + + if (rebind) { + return { + kind: 'dns', + decision: 'blocked', + occurredAt, + host: rebind[1].toLowerCase(), + rule: 'private_network', + matchedSource: policy?.scalarSources.blockPrivateNetworks ?? null, + count: 1, + }; + } + + const blocked = DNSMASQ_CONFIG_BLOCK.exec(line); + + if (!blocked) { + return null; + } + + const host = blocked[1].toLowerCase(); + let rule: EgressRule = mode === 'offline' ? 'offline' : 'deny_host'; + let matchedEntry: string | null = null; + let matchedSource: PlatformPolicyLayer | null = null; + + if (policy) { + const evaluation = evaluateEgressDestination(policy, host); + + if (evaluation.rule) { + rule = evaluation.rule; + matchedEntry = evaluation.matchedEntry ?? null; + matchedSource = evaluation.source ?? null; + } + } + + return { kind: 'dns', decision: 'blocked', occurredAt, host, rule, matchedEntry, matchedSource, count: 1 }; +} + +/** Parse a guard supervisor event line (`AGENSTRA_GUARD {json}`); only failures become observations. */ +export function parseGuardEventLine(line: string, occurredAt: Date): EgressGuardObservation | null { + if (!line.startsWith(`${EGRESS_GUARD_EVENT_LOG_MARKER} `)) { + return null; + } + + try { + const payload = JSON.parse(line.slice(EGRESS_GUARD_EVENT_LOG_MARKER.length + 1)) as { + event?: string; + detail?: string; + }; + const event = typeof payload.event === 'string' ? payload.event : ''; + + if (!['lockdown_failed', 'activate_failed', 'process_restarted'].includes(event)) { + return null; + } + + const detail = typeof payload.detail === 'string' ? payload.detail.slice(0, 500) : ''; + + return { + kind: 'guard', + decision: 'error', + occurredAt, + host: null, + rule: 'guard', + message: detail ? `${event}: ${detail}` : event, + count: 1, + }; + } catch { + return null; + } +} + +/** Parse one timestamped guard log line (Squid, dnsmasq, or supervisor). */ +export function parseGuardLogLine( + entry: TimestampedLogLine, + mode: EgressMode, + policy: EffectiveEgressPolicy | null, +): EgressGuardObservation | null { + const occurredAt = new Date(entry.timestamp); + + if (entry.line.startsWith(`${EGRESS_GUARD_WEB_LOG_MARKER} `)) { + return parseSquidAccessLine(entry.line, mode, policy); + } + + if (entry.line.startsWith(`${EGRESS_GUARD_EVENT_LOG_MARKER} `)) { + return parseGuardEventLine(entry.line, occurredAt); + } + + if (entry.line.startsWith('dnsmasq')) { + return parseDnsmasqLine(entry.line, occurredAt, mode, policy); + } + + return null; +} + +export interface EgressCounterSample { + /** Stable identity of the counted rule (family + rule text without counters). */ + key: string; + category: string; + packets: number; + protocol: string | null; + port: string | null; + destination: string | null; +} + +const COUNTER_LINE = /^(v6 )?\[(\d+):(\d+)\] (.+)$/; + +/** Parse `agenstra-egress counters` output (`iptables-save -c` lines of counted categories). */ +export function parseEgressCounters(output: string): EgressCounterSample[] { + const samples: EgressCounterSample[] = []; + + for (const rawLine of output.split('\n')) { + const match = COUNTER_LINE.exec(rawLine.trim()); + + if (!match) { + continue; + } + + const rule = match[4]; + const category = new RegExp(`--comment "?${EGRESS_GUARD_COUNTER_COMMENT_PREFIX}([a-z_]+)"?`).exec(rule)?.[1]; + + if (!category) { + continue; + } + + samples.push({ + key: `${match[1] ? 'v6' : 'v4'} ${rule}`, + category, + packets: Number.parseInt(match[2], 10), + protocol: /-p (tcp|udp)/.exec(rule)?.[1] ?? null, + port: /--dports? (\S+)/.exec(rule)?.[1]?.replace(':', '-') ?? null, + destination: /-d (\S+)/.exec(rule)?.[1]?.replace(/\/(32|128)$/, '') ?? null, + }); + } + + return samples; +} + +/** + * Turn counter samples into observations using the previous snapshot (packets since last poll). + * Counters reset when the guard re-applies its rules; a decrease is treated as a fresh count. + */ +export function diffEgressCounters( + previous: ReadonlyMap | null, + samples: readonly EgressCounterSample[], + mode: EgressMode, + occurredAt: Date, +): { observations: EgressGuardObservation[]; snapshot: Map } { + const snapshot = new Map(); + const observations: EgressGuardObservation[] = []; + + for (const sample of samples) { + snapshot.set(sample.key, sample.packets); + + if (!previous) { + continue; + } + + const before = previous.get(sample.key); + const delta = before === undefined || sample.packets < before ? sample.packets : sample.packets - before; + + if (delta <= 0) { + continue; + } + + const allowedException = sample.category === 'network_allow'; + const rule = allowedException + ? null + : RULE_SET.has(sample.category) + ? (sample.category as EgressRule) + : 'undeclared_protocol'; + + observations.push({ + kind: 'network', + decision: allowedException ? 'allowed' : mode === 'monitor' ? 'observed' : 'blocked', + occurredAt, + host: sample.destination, + rule, + method: sample.protocol && sample.port ? `${sample.protocol}/${sample.port}` : sample.protocol, + count: delta, + }); + } + + return { observations, snapshot }; +} + +/** Aggregation key: identical decisions per collection cycle collapse into one row with a count. */ +export function egressObservationKey(observation: EgressGuardObservation): string { + return [ + observation.kind, + observation.decision, + observation.host ?? '', + observation.rule ?? '', + observation.matchedEntry ?? '', + observation.matchedSource ?? '', + observation.decision === 'allowed' ? '' : (observation.method ?? ''), + observation.message ?? '', + ].join('|'); +} + +export interface AggregatedEgressObservation extends EgressGuardObservation { + count: number; + firstSeenAt: Date; + lastSeenAt: Date; +} + +/** Collapse observations (bounded row volume even for chatty agents). */ +export function aggregateEgressObservations( + observations: readonly EgressGuardObservation[], +): AggregatedEgressObservation[] { + const byKey = new Map(); + + for (const observation of observations) { + const key = egressObservationKey(observation); + const existing = byKey.get(key); + const count = observation.count ?? 1; + + if (!existing) { + byKey.set(key, { + ...observation, + count, + bytesIn: observation.bytesIn ?? 0, + bytesOut: observation.bytesOut ?? 0, + firstSeenAt: observation.occurredAt, + lastSeenAt: observation.occurredAt, + }); + continue; + } + + existing.count += count; + existing.bytesIn = (existing.bytesIn ?? 0) + (observation.bytesIn ?? 0); + existing.bytesOut = (existing.bytesOut ?? 0) + (observation.bytesOut ?? 0); + + if (observation.occurredAt < existing.firstSeenAt) { + existing.firstSeenAt = observation.occurredAt; + } + + if (observation.occurredAt > existing.lastSeenAt) { + existing.lastSeenAt = observation.occurredAt; + existing.httpStatus = observation.httpStatus ?? existing.httpStatus; + existing.url = observation.url ?? existing.url; + } + } + + return [...byKey.values()]; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.spec.ts deleted file mode 100644 index 461874041..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; - -import type { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; - -import { - applyReplace, - assertReplaceContentForFilterType, - compileRegexOrThrow, - normalizeRegexFlags, - ruleMatchesMessage, -} from './regex-filter-rule.utils'; - -describe('regex-filter-rule.utils', () => { - it('normalizeRegexFlags dedupes and orders conservatively', () => { - expect(normalizeRegexFlags('ggi')).toBe('gi'); - }); - - it('normalizeRegexFlags rejects invalid chars', () => { - expect(() => normalizeRegexFlags('x')).toThrow(BadRequestException); - }); - - it('compileRegexOrThrow throws on invalid pattern', () => { - expect(() => compileRegexOrThrow('(', 'g')).toThrow(BadRequestException); - }); - - it('assertReplaceContentForFilterType requires replace for filter', () => { - expect(() => assertReplaceContentForFilterType('filter', null)).toThrow(BadRequestException); - }); - - it('ruleMatchesMessage and applyReplace', () => { - const rule = { - pattern: 'foo', - regexFlags: 'g', - direction: 'incoming', - filterType: 'filter', - replaceContent: 'bar', - priority: 0, - } as RegexFilterRuleEntity; - - expect(ruleMatchesMessage(rule, 'hello foo')).toBe(true); - expect(applyReplace(rule, 'hello foo')).toBe('hello bar'); - }); -}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.ts deleted file mode 100644 index f779c53f8..000000000 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/utils/regex-filter-rule.utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; - -import type { RegexFilterRuleEntity, RegexFilterRuleType } from '../entities/regex-filter-rule.entity'; - -const ALLOWED_FLAG_CHARS = new Set(['g', 'i', 'm', 's', 'u', 'y', 'd']); - -/** - * Normalize and validate RegExp flags string (subset allowed by JS). - */ -export function normalizeRegexFlags(flags: string | undefined | null): string { - const raw = (flags ?? 'g').trim(); - - if (raw.length > 16) { - throw new BadRequestException('regexFlags is too long'); - } - - const seen = new Set(); - let out = ''; - - for (const c of raw) { - if (!ALLOWED_FLAG_CHARS.has(c)) { - throw new BadRequestException(`Invalid regex flag character: ${c}`); - } - - if (!seen.has(c)) { - seen.add(c); - out += c; - } - } - - return out; -} - -/** - * Try to compile pattern + flags; throws BadRequestException on invalid regex. - */ -export function compileRegexOrThrow(pattern: string, flags: string): RegExp { - try { - return new RegExp(pattern, flags); - } catch { - throw new BadRequestException('Invalid regular expression'); - } -} - -/** - * Whether message matches the rule pattern (uses search to avoid lastIndex issues). - */ -export function ruleMatchesMessage(rule: RegexFilterRuleEntity, message: string): boolean { - const flags = normalizeRegexFlags(rule.regexFlags); - const re = compileRegexOrThrow(rule.pattern, flags); - - return re.test(message); -} - -/** - * Apply replace for filter type; uses same RegExp as match check. - */ -export function applyReplace(rule: RegexFilterRuleEntity, message: string): string { - const flags = normalizeRegexFlags(rule.regexFlags); - const re = compileRegexOrThrow(rule.pattern, flags); - const replacement = rule.replaceContent ?? ''; - - return message.replace(re, replacement); -} - -export function assertReplaceContentForFilterType( - filterType: RegexFilterRuleType, - replaceContent: string | null | undefined, -): void { - if (filterType === 'filter' && (replaceContent === undefined || replaceContent === null)) { - throw new BadRequestException('replaceContent is required when filterType is filter'); - } -} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts index 5b00157a6..e9aec0872 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts @@ -161,14 +161,7 @@ export * from './lib/state/env/env.facade'; export * from './lib/state/env/env.reducer'; export * from './lib/state/env/env.selectors'; export * from './lib/state/env/env.types'; -export * from './lib/services/filter-rules.service'; export * from './lib/services/knowledge.service'; -export * from './lib/state/filter-rules/filter-rules.actions'; -export * from './lib/state/filter-rules/filter-rules.effects'; -export * from './lib/state/filter-rules/filter-rules.facade'; -export * from './lib/state/filter-rules/filter-rules.reducer'; -export * from './lib/state/filter-rules/filter-rules.selectors'; -export * from './lib/state/filter-rules/filter-rules.types'; export * from './lib/services/context-import-admin.service'; export * from './lib/state/context-import/context-import.actions'; export * from './lib/state/context-import/context-import.effects'; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/egress-http.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/egress-http.service.spec.ts new file mode 100644 index 000000000..a159b1a68 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/egress-http.service.spec.ts @@ -0,0 +1,59 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENVIRONMENT } from '@forepath/agenstra/frontend/util-configuration'; + +import { OpencodeConfigService } from './opencode-config.service'; +import { StatisticsService } from './statistics.service'; + +describe('Egress HTTP endpoints', () => { + const apiUrl = 'http://localhost:3100/api'; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [{ provide: ENVIRONMENT, useValue: { console: { urls: { restApi: apiUrl } } } }], + }); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('loads aggregate and client-scoped egress events with filters', () => { + const service = TestBed.inject(StatisticsService); + + service.getEgressEvents({ clientId: 'c1', decision: 'blocked', kind: 'web', search: 'example' }).subscribe(); + const aggregate = httpMock.expectOne((request) => request.url === `${apiUrl}/statistics/egress-events`); + + expect(aggregate.request.params.get('clientId')).toBe('c1'); + expect(aggregate.request.params.get('decision')).toBe('blocked'); + expect(aggregate.request.params.get('kind')).toBe('web'); + expect(aggregate.request.params.get('search')).toBe('example'); + expect(aggregate.request.params.get('limit')).toBe('10'); + aggregate.flush({ data: [], total: 0, limit: 10, offset: 0 }); + + service.getClientEgressEvents('c1', { clientId: 'c1', agentId: 'a1', offset: 20 }).subscribe(); + const scoped = httpMock.expectOne((request) => request.url === `${apiUrl}/clients/c1/statistics/egress-events`); + + expect(scoped.request.params.get('agentId')).toBe('a1'); + expect(scoped.request.params.get('offset')).toBe('20'); + expect(scoped.request.params.has('decision')).toBe(false); + scoped.flush({ data: [], total: 0, limit: 10, offset: 20 }); + }); + + it('reads the environment egress details and evaluates destinations', () => { + const service = TestBed.inject(OpencodeConfigService); + let evaluated: unknown; + + service.getAgentEgress('c1', 'a1').subscribe(); + httpMock.expectOne(`${apiUrl}/clients/c1/agents/a1/egress`).flush({ agentId: 'a1' }); + + service.evaluateAgentEgress('c1', 'a1', 'https://example.org').subscribe((result) => (evaluated = result)); + const request = httpMock.expectOne(`${apiUrl}/clients/c1/agents/a1/egress/evaluate`); + + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ destination: 'https://example.org' }); + request.flush({ host: 'example.org', decision: 'blocked', rule: 'deny_host', mode: 'enforce' }); + expect(evaluated).toEqual(expect.objectContaining({ decision: 'blocked' })); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.spec.ts deleted file mode 100644 index 6fd483c6a..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { ENVIRONMENT } from '@forepath/agenstra/frontend/util-configuration'; - -import type { - CreateFilterRuleDto, - FilterRuleResponseDto, - UpdateFilterRuleDto, -} from '../state/filter-rules/filter-rules.types'; - -import { FilterRulesService } from './filter-rules.service'; - -describe('FilterRulesService', () => { - let service: FilterRulesService; - let httpMock: HttpTestingController; - const apiUrl = 'http://localhost:3100/api'; - const mockRule: FilterRuleResponseDto = { - id: '11111111-1111-1111-1111-111111111111', - pattern: 'x', - regexFlags: 'g', - direction: 'incoming', - filterType: 'drop', - priority: 0, - enabled: true, - isGlobal: true, - workspaceIds: [], - sync: { pending: 0, synced: 1, failed: 0 }, - workspaceSync: [], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [ - { - provide: ENVIRONMENT, - useValue: { - console: { - urls: { - restApi: apiUrl, - }, - }, - }, - }, - ], - }); - service = TestBed.inject(FilterRulesService); - httpMock = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('lists filter rules', (done) => { - service.list().subscribe((rules) => { - expect(rules).toEqual([mockRule]); - done(); - }); - const req = httpMock.expectOne(`${apiUrl}/filter-rules`); - - expect(req.request.method).toBe('GET'); - expect(req.request.params.keys().length).toBe(0); - req.flush([mockRule]); - }); - - it('lists filter rules with pagination query params', (done) => { - service.list({ limit: 10, offset: 20 }).subscribe((rules) => { - expect(rules).toEqual([mockRule]); - done(); - }); - const req = httpMock.expectOne( - (r) => r.url === `${apiUrl}/filter-rules` && r.params.get('limit') === '10' && r.params.get('offset') === '20', - ); - - expect(req.request.method).toBe('GET'); - req.flush([mockRule]); - }); - - it('creates a filter rule', (done) => { - const dto: CreateFilterRuleDto = { - pattern: 'a', - direction: 'incoming', - filterType: 'none', - isGlobal: true, - }; - - service.create(dto).subscribe((r) => { - expect(r).toEqual(mockRule); - done(); - }); - const req = httpMock.expectOne(`${apiUrl}/filter-rules`); - - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(dto); - req.flush(mockRule); - }); - - it('updates a filter rule', (done) => { - const dto: UpdateFilterRuleDto = { pattern: 'b' }; - - service.update(mockRule.id, dto).subscribe((r) => { - expect(r.pattern).toBe('b'); - done(); - }); - const req = httpMock.expectOne(`${apiUrl}/filter-rules/${mockRule.id}`); - - expect(req.request.method).toBe('PUT'); - req.flush({ ...mockRule, pattern: 'b' }); - }); - - it('deletes a filter rule', (done) => { - service.delete(mockRule.id).subscribe(() => done()); - const req = httpMock.expectOne(`${apiUrl}/filter-rules/${mockRule.id}`); - - expect(req.request.method).toBe('DELETE'); - req.flush(null); - }); -}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts deleted file mode 100644 index 6f94e116e..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/filter-rules.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import type { Environment } from '@forepath/agenstra/frontend/util-configuration'; -import { ENVIRONMENT } from '@forepath/agenstra/frontend/util-configuration'; -import { Observable } from 'rxjs'; - -import type { - CreateFilterRuleDto, - FilterRuleResponseDto, - ListFilterRulesParams, - UpdateFilterRuleDto, -} from '../state/filter-rules/filter-rules.types'; - -@Injectable({ - providedIn: 'root', -}) -export class FilterRulesService { - private readonly http = inject(HttpClient); - private readonly environment = inject(ENVIRONMENT); - - private get apiUrl(): string { - return this.environment.console.urls.restApi; - } - - list(params?: ListFilterRulesParams): Observable { - let httpParams = new HttpParams(); - - if (params?.limit !== undefined) { - httpParams = httpParams.set('limit', params.limit.toString()); - } - - if (params?.offset !== undefined) { - httpParams = httpParams.set('offset', params.offset.toString()); - } - - if (params?.search?.trim()) { - httpParams = httpParams.set('search', params.search.trim()); - } - - return this.http.get(`${this.apiUrl}/filter-rules`, { params: httpParams }); - } - - get(id: string): Observable { - return this.http.get(`${this.apiUrl}/filter-rules/${id}`); - } - - create(dto: CreateFilterRuleDto): Observable { - return this.http.post(`${this.apiUrl}/filter-rules`, dto); - } - - update(id: string, dto: UpdateFilterRuleDto): Observable { - return this.http.put(`${this.apiUrl}/filter-rules/${id}`, dto); - } - - delete(id: string): Observable { - return this.http.delete(`${this.apiUrl}/filter-rules/${id}`); - } -} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts index 34dade79c..edd3d84a2 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/opencode-config.service.ts @@ -1,6 +1,11 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import type { Environment } from '@forepath/agenstra/frontend/util-configuration'; +import type { + EffectiveEgressPolicy, + EffectiveMessageFilterRule, + EffectivePlatformSecurityPolicy, +} from '@forepath/agenstra/shared/util-opencode-config'; import { ENVIRONMENT } from '@forepath/agenstra/frontend/util-configuration'; import { Observable } from 'rxjs'; @@ -20,9 +25,71 @@ export interface OpencodeConfigDto { effective?: Record; lockedPaths?: string[]; inheritedAdditive?: InheritedAdditiveDto[]; + /** Effective platform security policy (Security → Web traffic / Message filters) with provenance. */ + platformPolicy?: EffectivePlatformSecurityPolicy; + /** Egress guard status (environment layer only). */ + egress?: AgentEgressStatusDto; updatedAt?: string; } +/** Egress guard (web traffic filtering) status of an environment. */ +export interface AgentEgressStatusDto { + status: 'inactive' | 'active' | 'failed'; + mode: EffectiveEgressPolicy['mode']; + guardContainerId: string | null; + policyRevision: string | null; + appliedAt: string | null; + lastError: string | null; + guard?: { + running: boolean; + startedAt: string | null; + activeRevision: string | null; + proxyRunning: boolean; + dnsRunning: boolean; + }; +} + +export interface AgentEgressEventDto { + seq: string; + agentId: string; + kind: 'web' | 'dns' | 'network' | 'guard'; + decision: 'allowed' | 'blocked' | 'observed' | 'error'; + host: string | null; + rule: string | null; + matchedEntry: string | null; + matchedSource: string | null; + method: string | null; + httpStatus: number | null; + sampleUrl: string | null; + count: number; + bytesIn: number; + bytesOut: number; + firstSeenAt: string; + lastSeenAt: string; + mode: string | null; + policyRevision: string | null; + message: string | null; +} + +/** Egress status, policy in force, message filters and recent guard decisions of an environment. */ +export interface AgentEgressDetailsDto { + agentId: string; + status: AgentEgressStatusDto; + policy: EffectiveEgressPolicy | null; + messageFilters: EffectiveMessageFilterRule[]; + recentEvents: AgentEgressEventDto[]; +} + +export interface EgressEvaluationDto { + host: string; + decision: 'allowed' | 'blocked' | 'observed'; + rule: string | null; + matchedEntry?: string | null; + source?: string | null; + mode: string; + scheme?: 'http' | 'https' | null; +} + export interface UpsertOpencodeConfigPayload { config?: Record | null; /** Raw JSON patch merged over `config`; empty object clears overrides when provided. */ @@ -198,6 +265,18 @@ export class OpencodeConfigService { ); } + /** Egress guard status, policy in force and recent decisions of an environment. */ + getAgentEgress(clientId: string, agentId: string): Observable { + return this.http.get(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/egress`); + } + + /** Evaluate a destination (hostname, IP or URL) against the environment's effective egress policy. */ + evaluateAgentEgress(clientId: string, agentId: string, destination: string): Observable { + return this.http.post(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/egress/evaluate`, { + destination, + }); + } + listAgentAgents(clientId: string, agentId: string): Observable { return this.http.get( `${this.apiUrl}/clients/${clientId}/agents/${agentId}/opencode/config/agents`, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/statistics.service.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/statistics.service.ts index 18962edd0..f0acc0d92 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/statistics.service.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/statistics.service.ts @@ -8,6 +8,7 @@ import type { StatisticsAggregateParams, StatisticsChatIoListDto, StatisticsClientScopeParams, + StatisticsEgressEventListDto, StatisticsEntityEventListDto, StatisticsFilterDropListDto, StatisticsFilterFlagListDto, @@ -213,4 +214,44 @@ export class StatisticsService { params: httpParams, }); } + + /** Client-scoped: egress guard decisions (web traffic filtering) for a client */ + getClientEgressEvents( + clientId: string, + params?: StatisticsClientScopeParams, + ): Observable { + const httpParams = this.buildParams({ + agentId: params?.agentId, + kind: params?.kind, + decision: params?.decision, + from: params?.from, + to: params?.to, + search: params?.search, + limit: params?.limit ?? 10, + offset: params?.offset ?? 0, + }); + + return this.http.get(`${this.apiUrl}/clients/${clientId}/statistics/egress-events`, { + params: httpParams, + }); + } + + /** Aggregate: egress guard decisions (web traffic filtering) */ + getEgressEvents(params?: StatisticsAggregateParams): Observable { + const httpParams = this.buildParams({ + clientId: params?.clientId, + agentId: params?.agentId, + kind: params?.kind, + decision: params?.decision, + from: params?.from, + to: params?.to, + search: params?.search, + limit: params?.limit ?? 10, + offset: params?.offset ?? 0, + }); + + return this.http.get(`${this.apiUrl}/statistics/egress-events`, { + params: httpParams, + }); + } } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.actions.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.actions.ts deleted file mode 100644 index 6505d4341..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.actions.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createAction, props } from '@ngrx/store'; - -import type { - CreateFilterRuleDto, - FilterRuleResponseDto, - ListFilterRulesParams, - UpdateFilterRuleDto, -} from './filter-rules.types'; - -export const loadFilterRules = createAction('[Filter Rules] Load', props<{ params?: ListFilterRulesParams }>()); - -export const loadFilterRulesSuccess = createAction( - '[Filter Rules] Load Success', - props<{ rules: FilterRuleResponseDto[]; hasMore: boolean; nextOffset: number }>(), -); - -export const loadFilterRulesFailure = createAction('[Filter Rules] Load Failure', props<{ error: string }>()); - -export const loadMoreFilterRules = createAction('[Filter Rules] Load More'); - -export const loadMoreFilterRulesSuccess = createAction( - '[Filter Rules] Load More Success', - props<{ rules: FilterRuleResponseDto[]; hasMore: boolean; nextOffset: number }>(), -); - -export const loadMoreFilterRulesFailure = createAction('[Filter Rules] Load More Failure', props<{ error: string }>()); - -export const createFilterRule = createAction('[Filter Rules] Create', props<{ dto: CreateFilterRuleDto }>()); - -export const createFilterRuleSuccess = createAction( - '[Filter Rules] Create Success', - props<{ rule: FilterRuleResponseDto }>(), -); - -export const createFilterRuleFailure = createAction('[Filter Rules] Create Failure', props<{ error: string }>()); - -export const updateFilterRule = createAction( - '[Filter Rules] Update', - props<{ id: string; dto: UpdateFilterRuleDto }>(), -); - -export const updateFilterRuleSuccess = createAction( - '[Filter Rules] Update Success', - props<{ rule: FilterRuleResponseDto }>(), -); - -export const updateFilterRuleFailure = createAction('[Filter Rules] Update Failure', props<{ error: string }>()); - -export const deleteFilterRule = createAction('[Filter Rules] Delete', props<{ id: string }>()); - -export const deleteFilterRuleSuccess = createAction('[Filter Rules] Delete Success', props<{ id: string }>()); - -export const deleteFilterRuleFailure = createAction('[Filter Rules] Delete Failure', props<{ error: string }>()); - -export const clearFilterRulesError = createAction('[Filter Rules] Clear Error'); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.spec.ts deleted file mode 100644 index dae5784e8..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { of, throwError } from 'rxjs'; -import { provideMockStore } from '@ngrx/store/testing'; -import { TestBed } from '@angular/core/testing'; -import { Store } from '@ngrx/store'; - -import { FilterRulesService } from '../../services/filter-rules.service'; - -import { - loadFilterRules, - loadFilterRulesFailure, - loadFilterRulesSuccess, - loadMoreFilterRules, - loadMoreFilterRulesFailure, - loadMoreFilterRulesSuccess, -} from './filter-rules.actions'; -import { loadFilterRules$, loadMoreFilterRules$ } from './filter-rules.effects'; -import { initialFilterRulesState } from './filter-rules.reducer'; -import type { FilterRuleResponseDto } from './filter-rules.types'; - -describe('FilterRulesEffects', () => { - const mockRule = (id: string): FilterRuleResponseDto => ({ - id, - pattern: 'p', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - priority: 0, - enabled: true, - isGlobal: true, - workspaceIds: [], - sync: { pending: 0, synced: 0, failed: 0 }, - workspaceSync: [], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - }); - - describe('loadFilterRules$', () => { - it('dispatches success with empty list when API returns no rows', (done) => { - const svc = { list: jest.fn().mockReturnValue(of([])) } as unknown as FilterRulesService; - - loadFilterRules$(of(loadFilterRules()), svc).subscribe((result) => { - expect(result).toEqual(loadFilterRulesSuccess({ rules: [], hasMore: false, nextOffset: 0 })); - expect(svc.list).toHaveBeenCalledWith({ limit: 10, offset: 0 }); - done(); - }); - }); - - it('dispatches success when first page is partial', (done) => { - const rules = [mockRule('a')]; - const svc = { list: jest.fn().mockReturnValue(of(rules)) } as unknown as FilterRulesService; - - loadFilterRules$(of(loadFilterRules()), svc).subscribe((result) => { - expect(result).toEqual(loadFilterRulesSuccess({ rules, hasMore: false, nextOffset: 1 })); - expect(svc.list).toHaveBeenCalledWith({ limit: 10, offset: 0 }); - done(); - }); - }); - - it('sets hasMore when first page is full', (done) => { - const rules = Array.from({ length: 10 }, (_, i) => mockRule(`id-${i}`)); - const svc = { list: jest.fn().mockReturnValue(of(rules)) } as unknown as FilterRulesService; - - loadFilterRules$(of(loadFilterRules()), svc).subscribe((result) => { - expect(result).toEqual(loadFilterRulesSuccess({ rules, hasMore: true, nextOffset: 10 })); - expect(svc.list).toHaveBeenCalledWith({ limit: 10, offset: 0 }); - done(); - }); - }); - - it('dispatches failure on error', (done) => { - const svc = { - list: jest.fn().mockReturnValue(throwError(() => new Error('network'))), - } as unknown as FilterRulesService; - - loadFilterRules$(of(loadFilterRules()), svc).subscribe((result) => { - expect(result).toEqual(loadFilterRulesFailure({ error: 'network' })); - done(); - }); - }); - }); - - describe('loadMoreFilterRules$', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideMockStore({ - initialState: { - filterRules: { - ...initialFilterRulesState, - hasMore: true, - nextOffset: 10, - loading: false, - appendLoading: false, - }, - }, - }), - ], - }); - }); - - it('appends the next page', (done) => { - const page = [mockRule('b')]; - const svc = { list: jest.fn().mockReturnValue(of(page)) } as unknown as FilterRulesService; - const store = TestBed.inject(Store); - - loadMoreFilterRules$(of(loadMoreFilterRules()), svc, store).subscribe((result) => { - expect(result).toEqual(loadMoreFilterRulesSuccess({ rules: page, hasMore: false, nextOffset: 11 })); - expect(svc.list).toHaveBeenCalledWith({ limit: 10, offset: 10 }); - done(); - }); - }); - - it('dispatches failure on error', (done) => { - const svc = { - list: jest.fn().mockReturnValue(throwError(() => new Error('network'))), - } as unknown as FilterRulesService; - const store = TestBed.inject(Store); - - loadMoreFilterRules$(of(loadMoreFilterRules()), svc, store).subscribe((result) => { - expect(result).toEqual(loadMoreFilterRulesFailure({ error: 'network' })); - done(); - }); - }); - }); -}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.ts deleted file mode 100644 index ab81b1c27..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.effects.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { inject } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; -import { catchError, exhaustMap, filter, map, of, switchMap, withLatestFrom } from 'rxjs'; - -import { FilterRulesService } from '../../services/filter-rules.service'; - -import { - createFilterRule, - createFilterRuleFailure, - createFilterRuleSuccess, - deleteFilterRule, - deleteFilterRuleFailure, - deleteFilterRuleSuccess, - loadFilterRules, - loadFilterRulesFailure, - loadFilterRulesSuccess, - loadMoreFilterRules, - loadMoreFilterRulesFailure, - loadMoreFilterRulesSuccess, - updateFilterRule, - updateFilterRuleFailure, - updateFilterRuleSuccess, -} from './filter-rules.actions'; -import { selectFilterRulesState } from './filter-rules.selectors'; - -function normalizeError(error: unknown): string { - if (error instanceof HttpErrorResponse) { - return error.error?.message ?? error.message ?? String(error.status); - } - - if (error instanceof Error) { - return error.message; - } - - if (typeof error === 'string') { - return error; - } - - return 'An unexpected error occurred'; -} - -const FILTER_RULES_BATCH_SIZE = 10; - -export const loadFilterRules$ = createEffect( - (actions$ = inject(Actions), svc = inject(FilterRulesService)) => { - return actions$.pipe( - ofType(loadFilterRules), - switchMap(({ params }) => { - const batchParams = { - limit: FILTER_RULES_BATCH_SIZE, - offset: 0, - search: params?.search?.trim() || undefined, - }; - - return svc.list(batchParams).pipe( - map((rules) => - loadFilterRulesSuccess({ - rules, - hasMore: rules.length === FILTER_RULES_BATCH_SIZE, - nextOffset: rules.length, - }), - ), - catchError((error) => of(loadFilterRulesFailure({ error: normalizeError(error) }))), - ); - }), - ); - }, - { functional: true }, -); - -export const loadMoreFilterRules$ = createEffect( - (actions$ = inject(Actions), svc = inject(FilterRulesService), store = inject(Store)) => { - return actions$.pipe( - ofType(loadMoreFilterRules), - withLatestFrom(store.select(selectFilterRulesState)), - filter(([, state]) => state.hasMore && !state.loading && !state.appendLoading), - exhaustMap(([, state]) => { - const batchParams = { - limit: FILTER_RULES_BATCH_SIZE, - offset: state.nextOffset, - search: state.search ?? undefined, - }; - - return svc.list(batchParams).pipe( - map((rules) => - loadMoreFilterRulesSuccess({ - rules, - hasMore: rules.length === FILTER_RULES_BATCH_SIZE, - nextOffset: state.nextOffset + rules.length, - }), - ), - catchError((error) => of(loadMoreFilterRulesFailure({ error: normalizeError(error) }))), - ); - }), - ); - }, - { functional: true }, -); - -export const createFilterRule$ = createEffect( - (actions$ = inject(Actions), svc = inject(FilterRulesService)) => { - return actions$.pipe( - ofType(createFilterRule), - switchMap(({ dto }) => - svc.create(dto).pipe( - map((rule) => createFilterRuleSuccess({ rule })), - catchError((error) => of(createFilterRuleFailure({ error: normalizeError(error) }))), - ), - ), - ); - }, - { functional: true }, -); - -export const updateFilterRule$ = createEffect( - (actions$ = inject(Actions), svc = inject(FilterRulesService)) => { - return actions$.pipe( - ofType(updateFilterRule), - switchMap(({ id, dto }) => - svc.update(id, dto).pipe( - map((rule) => updateFilterRuleSuccess({ rule })), - catchError((error) => of(updateFilterRuleFailure({ error: normalizeError(error) }))), - ), - ), - ); - }, - { functional: true }, -); - -export const deleteFilterRule$ = createEffect( - (actions$ = inject(Actions), svc = inject(FilterRulesService)) => { - return actions$.pipe( - ofType(deleteFilterRule), - switchMap(({ id }) => - svc.delete(id).pipe( - map(() => deleteFilterRuleSuccess({ id })), - catchError((error) => of(deleteFilterRuleFailure({ error: normalizeError(error) }))), - ), - ), - ); - }, - { functional: true }, -); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.facade.ts deleted file mode 100644 index c35b264d5..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.facade.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; - -import { - clearFilterRulesError, - createFilterRule, - deleteFilterRule, - loadFilterRules, - loadMoreFilterRules, - updateFilterRule, -} from './filter-rules.actions'; -import { - selectFilterRules, - selectFilterRulesAppendError, - selectFilterRulesAppendLoading, - selectFilterRulesDeleting, - selectFilterRulesError, - selectFilterRulesHasMore, - selectFilterRulesLoading, - selectFilterRulesSaving, -} from './filter-rules.selectors'; -import type { - ListFilterRulesParams, - CreateFilterRuleDto, - FilterRuleResponseDto, - UpdateFilterRuleDto, -} from './filter-rules.types'; - -@Injectable({ - providedIn: 'root', -}) -export class FilterRulesFacade { - private readonly store = inject(Store); - - readonly rules$: Observable = this.store.select(selectFilterRules); - readonly loading$: Observable = this.store.select(selectFilterRulesLoading); - readonly error$: Observable = this.store.select(selectFilterRulesError); - readonly saving$: Observable = this.store.select(selectFilterRulesSaving); - readonly deleting$: Observable = this.store.select(selectFilterRulesDeleting); - readonly hasMore$: Observable = this.store.select(selectFilterRulesHasMore); - readonly appendLoading$: Observable = this.store.select(selectFilterRulesAppendLoading); - readonly appendError$: Observable = this.store.select(selectFilterRulesAppendError); - - load(params?: ListFilterRulesParams): void { - this.store.dispatch(loadFilterRules({ params })); - } - - loadMore(): void { - this.store.dispatch(loadMoreFilterRules()); - } - - retryLoadMore(): void { - this.store.dispatch(loadMoreFilterRules()); - } - - create(dto: CreateFilterRuleDto): void { - this.store.dispatch(createFilterRule({ dto })); - } - - update(id: string, dto: UpdateFilterRuleDto): void { - this.store.dispatch(updateFilterRule({ id, dto })); - } - - delete(id: string): void { - this.store.dispatch(deleteFilterRule({ id })); - } - - clearError(): void { - this.store.dispatch(clearFilterRulesError()); - } -} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.spec.ts deleted file mode 100644 index 1fdba1ceb..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - clearFilterRulesError, - createFilterRule, - createFilterRuleSuccess, - deleteFilterRule, - deleteFilterRuleFailure, - deleteFilterRuleSuccess, - loadFilterRules, - loadMoreFilterRules, - loadMoreFilterRulesFailure, - loadMoreFilterRulesSuccess, - loadFilterRulesFailure, - loadFilterRulesSuccess, - updateFilterRuleFailure, - updateFilterRuleSuccess, -} from './filter-rules.actions'; -import { filterRulesReducer, initialFilterRulesState } from './filter-rules.reducer'; -import type { CreateFilterRuleDto, FilterRuleResponseDto } from './filter-rules.types'; - -describe('filterRulesReducer', () => { - const baseRule = (over: Partial = {}): FilterRuleResponseDto => ({ - id: '11111111-1111-1111-1111-111111111111', - pattern: 'foo', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - priority: 0, - enabled: true, - isGlobal: true, - workspaceIds: [], - sync: { pending: 0, synced: 0, failed: 0 }, - workspaceSync: [], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ...over, - }); - - it('returns initial state for unknown action', () => { - expect(filterRulesReducer(undefined, { type: 'UNKNOWN' } as never)).toEqual(initialFilterRulesState); - }); - - it('handles load lifecycle', () => { - const seeded = { ...initialFilterRulesState, rules: [baseRule({ id: 'seed' })] }; - let s = filterRulesReducer(seeded, loadFilterRules()); - - expect(s.loading).toBe(true); - expect(s.rules).toEqual([]); - expect(s.error).toBeNull(); - const rules = [baseRule({ id: 'a', priority: 1 }), baseRule({ id: 'b', priority: 0 })]; - - s = filterRulesReducer(s, loadFilterRulesSuccess({ rules, hasMore: false, nextOffset: 2 })); - expect(s.loading).toBe(false); - expect(s.rules).toEqual(rules); - expect(s.hasMore).toBe(false); - expect(s.nextOffset).toBe(2); - s = filterRulesReducer(s, loadFilterRulesFailure({ error: 'x' })); - expect(s.loading).toBe(false); - expect(s.error).toBe('x'); - }); - - it('appends rules on loadMore success and sets appendError on failure', () => { - const existing = [baseRule({ id: '1' })]; - let s = filterRulesReducer( - { ...initialFilterRulesState, rules: existing, hasMore: true, nextOffset: 1 }, - loadMoreFilterRules(), - ); - - expect(s.appendLoading).toBe(true); - s = filterRulesReducer( - s, - loadMoreFilterRulesSuccess({ rules: [baseRule({ id: '2' })], hasMore: false, nextOffset: 2 }), - ); - expect(s.rules.map((r) => r.id)).toEqual(['1', '2']); - expect(s.appendLoading).toBe(false); - expect(s.hasMore).toBe(false); - s = filterRulesReducer({ ...s, appendLoading: true }, loadMoreFilterRulesFailure({ error: 'append' })); - expect(s.appendLoading).toBe(false); - expect(s.appendError).toBe('append'); - }); - - it('inserts created rule sorted by priority then createdAt', () => { - const existing = baseRule({ id: 'a', priority: 5, createdAt: '2024-01-02T00:00:00Z' }); - const dto: CreateFilterRuleDto = { - pattern: 'p', - direction: 'incoming', - filterType: 'none', - isGlobal: true, - }; - let s = filterRulesReducer({ ...initialFilterRulesState, rules: [existing] }, createFilterRule({ dto })); - - expect(s.saving).toBe(true); - const inserted = baseRule({ id: 'b', priority: 1, createdAt: '2024-01-03T00:00:00Z' }); - - s = filterRulesReducer(s, createFilterRuleSuccess({ rule: inserted })); - expect(s.saving).toBe(false); - expect(s.rules.map((r) => r.id)).toEqual(['b', 'a']); - }); - - it('replaces rule on update success', () => { - const r1 = baseRule({ id: 'a', pattern: 'old' }); - let s = filterRulesReducer( - { ...initialFilterRulesState, rules: [r1] }, - updateFilterRuleSuccess({ rule: { ...r1, pattern: 'new' } }), - ); - - expect(s.rules[0].pattern).toBe('new'); - s = filterRulesReducer({ ...s, saving: true }, updateFilterRuleFailure({ error: 'e' })); - expect(s.saving).toBe(false); - expect(s.error).toBe('e'); - }); - - it('removes rule on delete success', () => { - const r1 = baseRule({ id: 'a' }); - let s = filterRulesReducer({ ...initialFilterRulesState, rules: [r1] }, deleteFilterRule({ id: 'a' })); - - expect(s.deleting).toBe(true); - s = filterRulesReducer(s, deleteFilterRuleSuccess({ id: 'a' })); - expect(s.deleting).toBe(false); - expect(s.rules).toEqual([]); - s = filterRulesReducer({ ...s, deleting: true }, deleteFilterRuleFailure({ error: 'd' })); - expect(s.deleting).toBe(false); - expect(s.error).toBe('d'); - }); - - it('clears error', () => { - const s = filterRulesReducer({ ...initialFilterRulesState, error: 'x' }, clearFilterRulesError()); - - expect(s.error).toBeNull(); - }); -}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.ts deleted file mode 100644 index 65a512900..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.reducer.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { createReducer, on } from '@ngrx/store'; - -import { - clearFilterRulesError, - createFilterRule, - createFilterRuleFailure, - createFilterRuleSuccess, - deleteFilterRule, - deleteFilterRuleFailure, - deleteFilterRuleSuccess, - loadFilterRules, - loadFilterRulesFailure, - loadFilterRulesSuccess, - loadMoreFilterRules, - loadMoreFilterRulesFailure, - loadMoreFilterRulesSuccess, - updateFilterRule, - updateFilterRuleFailure, - updateFilterRuleSuccess, -} from './filter-rules.actions'; -import type { FilterRuleResponseDto } from './filter-rules.types'; - -export interface FilterRulesState { - rules: FilterRuleResponseDto[]; - loading: boolean; - saving: boolean; - deleting: boolean; - error: string | null; - hasMore: boolean; - nextOffset: number; - appendLoading: boolean; - appendError: string | null; - search: string | null; -} - -export const initialFilterRulesState: FilterRulesState = { - rules: [], - loading: false, - saving: false, - deleting: false, - error: null, - hasMore: false, - nextOffset: 0, - appendLoading: false, - appendError: null, - search: null, -}; - -export const filterRulesReducer = createReducer( - initialFilterRulesState, - on(loadFilterRules, (state, { params }) => ({ - ...state, - rules: [], - loading: true, - error: null, - hasMore: false, - nextOffset: 0, - appendLoading: false, - appendError: null, - search: params?.search?.trim() ? params.search.trim() : null, - })), - on(loadFilterRulesSuccess, (state, { rules, hasMore, nextOffset }) => ({ - ...state, - loading: false, - rules, - error: null, - hasMore, - nextOffset, - appendLoading: false, - appendError: null, - })), - on(loadFilterRulesFailure, (state, { error }) => ({ - ...state, - loading: false, - error, - hasMore: false, - appendLoading: false, - })), - on(loadMoreFilterRules, (state) => ({ - ...state, - appendLoading: true, - appendError: null, - })), - on(loadMoreFilterRulesSuccess, (state, { rules, hasMore, nextOffset }) => ({ - ...state, - rules: [...state.rules, ...rules], - hasMore, - nextOffset, - appendLoading: false, - appendError: null, - })), - on(loadMoreFilterRulesFailure, (state, { error }) => ({ - ...state, - appendLoading: false, - appendError: error, - })), - on(createFilterRule, (state) => ({ ...state, saving: true, error: null })), - on(createFilterRuleSuccess, (state, { rule }) => ({ - ...state, - saving: false, - rules: [...state.rules, rule].sort((a, b) => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt)), - error: null, - })), - on(createFilterRuleFailure, (state, { error }) => ({ ...state, saving: false, error })), - on(updateFilterRule, (state) => ({ ...state, saving: true, error: null })), - on(updateFilterRuleSuccess, (state, { rule }) => ({ - ...state, - saving: false, - rules: state.rules.map((r) => (r.id === rule.id ? rule : r)), - error: null, - })), - on(updateFilterRuleFailure, (state, { error }) => ({ ...state, saving: false, error })), - on(deleteFilterRule, (state) => ({ ...state, deleting: true, error: null })), - on(deleteFilterRuleSuccess, (state, { id }) => ({ - ...state, - deleting: false, - rules: state.rules.filter((r) => r.id !== id), - error: null, - })), - on(deleteFilterRuleFailure, (state, { error }) => ({ ...state, deleting: false, error })), - on(clearFilterRulesError, (state) => ({ ...state, error: null })), -); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.selectors.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.selectors.ts deleted file mode 100644 index cad4d626a..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.selectors.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createFeatureSelector, createSelector } from '@ngrx/store'; - -import type { FilterRulesState } from './filter-rules.reducer'; - -export const selectFilterRulesState = createFeatureSelector('filterRules'); - -export const selectFilterRules = createSelector(selectFilterRulesState, (s) => s.rules); - -export const selectFilterRulesLoading = createSelector(selectFilterRulesState, (s) => s.loading); - -export const selectFilterRulesError = createSelector(selectFilterRulesState, (s) => s.error); - -export const selectFilterRulesSaving = createSelector(selectFilterRulesState, (s) => s.saving); - -export const selectFilterRulesDeleting = createSelector(selectFilterRulesState, (s) => s.deleting); - -export const selectFilterRulesHasMore = createSelector(selectFilterRulesState, (s) => s.hasMore); - -export const selectFilterRulesAppendLoading = createSelector(selectFilterRulesState, (s) => s.appendLoading); - -export const selectFilterRulesAppendError = createSelector(selectFilterRulesState, (s) => s.appendError); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.types.ts deleted file mode 100644 index de05996f8..000000000 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/filter-rules/filter-rules.types.ts +++ /dev/null @@ -1,64 +0,0 @@ -export type FilterRuleDirection = 'incoming' | 'outgoing' | 'bidirectional'; -export type FilterRuleType = 'none' | 'filter' | 'drop'; - -export interface ListFilterRulesParams { - limit?: number; - offset?: number; - search?: string; -} - -export interface FilterRuleSyncSummary { - pending: number; - synced: number; - failed: number; -} - -export type FilterRuleWorkspaceSyncStatus = 'pending' | 'synced' | 'failed'; - -export interface FilterRuleWorkspaceSyncDto { - clientId: string; - syncStatus: FilterRuleWorkspaceSyncStatus; - lastError?: string | null; -} - -export interface FilterRuleResponseDto { - id: string; - pattern: string; - regexFlags: string; - direction: FilterRuleDirection; - filterType: FilterRuleType; - replaceContent?: string | null; - priority: number; - enabled: boolean; - isGlobal: boolean; - workspaceIds: string[]; - sync: FilterRuleSyncSummary; - /** Per-workspace sync row (same order as agent-controller). */ - workspaceSync: FilterRuleWorkspaceSyncDto[]; - createdAt: string; - updatedAt: string; -} - -export interface CreateFilterRuleDto { - pattern: string; - regexFlags?: string; - direction: FilterRuleDirection; - filterType: FilterRuleType; - replaceContent?: string; - priority?: number; - enabled?: boolean; - isGlobal: boolean; - workspaceIds?: string[]; -} - -export interface UpdateFilterRuleDto { - pattern?: string; - regexFlags?: string; - direction?: FilterRuleDirection; - filterType?: FilterRuleType; - replaceContent?: string | null; - priority?: number; - enabled?: boolean; - isGlobal?: boolean; - workspaceIds?: string[]; -} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics-egress.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics-egress.spec.ts new file mode 100644 index 000000000..ab81f3a51 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics-egress.spec.ts @@ -0,0 +1,189 @@ +import { TestBed } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store } from '@ngrx/store'; +import { firstValueFrom, of, throwError } from 'rxjs'; + +import { StatisticsService } from '../../services/statistics.service'; + +import { + loadClientStatisticsEgressEvents, + loadClientStatisticsEgressEventsFailure, + loadClientStatisticsEgressEventsSuccess, + loadStatisticsEgressEvents, + loadStatisticsEgressEventsFailure, + loadStatisticsEgressEventsSuccess, +} from './statistics.actions'; +import { loadClientStatisticsEgressEvents$, loadStatisticsEgressEvents$ } from './statistics.effects'; +import { StatisticsFacade } from './statistics.facade'; +import { initialStatisticsState, statisticsReducer } from './statistics.reducer'; +import { + selectClientEgressEvents, + selectEgressEvents, + selectLoadingClientEgressEvents, + selectLoadingEgressEvents, +} from './statistics.selectors'; +import type { StatisticsEgressEventListDto } from './statistics.types'; + +const list: StatisticsEgressEventListDto = { + data: [ + { + id: 'e1', + clientId: 'client-1', + agentId: 'agent-1', + kind: 'web', + decision: 'blocked', + host: 'example.org', + rule: 'deny_host', + count: 2, + bytesIn: 0, + bytesOut: 10, + firstSeenAt: '2026-10-12T10:00:00.000Z', + occurredAt: '2026-10-12T10:00:05.000Z', + }, + ], + total: 1, + limit: 10, + offset: 0, +}; + +describe('Statistics egress events state', () => { + describe('reducer', () => { + it('tracks aggregate loading, data and errors', () => { + let state = statisticsReducer(initialStatisticsState, loadStatisticsEgressEvents({ params: {} })); + + expect(state.loadingEgressEvents).toBe(true); + + state = statisticsReducer(state, loadStatisticsEgressEventsSuccess({ data: list })); + expect(state.egressEvents).toEqual(list); + expect(state.loadingEgressEvents).toBe(false); + + state = statisticsReducer(state, loadStatisticsEgressEventsFailure({ error: 'boom' })); + expect(state.loadingEgressEvents).toBe(false); + expect(state.error).toBe('boom'); + expect(state.egressEvents).toEqual(list); + }); + + it('tracks client-scoped egress events per client', () => { + let state = statisticsReducer( + initialStatisticsState, + loadClientStatisticsEgressEvents({ params: { clientId: 'client-1' } }), + ); + + expect(state.loadingClientEgressEvents['client-1']).toBe(true); + + state = statisticsReducer(state, loadClientStatisticsEgressEventsSuccess({ clientId: 'client-1', data: list })); + expect(state.clientEgressEvents['client-1']).toEqual(list); + expect(state.loadingClientEgressEvents['client-1']).toBe(false); + + state = statisticsReducer(state, loadClientStatisticsEgressEventsFailure({ clientId: 'client-1', error: 'x' })); + expect(state.loadingClientEgressEvents['client-1']).toBe(false); + expect(state.error).toBe('x'); + }); + }); + + describe('selectors', () => { + it('select aggregate and client egress state', () => { + const state = { + statistics: { + ...initialStatisticsState, + egressEvents: list, + loadingEgressEvents: true, + clientEgressEvents: { 'client-1': list }, + loadingClientEgressEvents: { 'client-1': true }, + }, + }; + + expect(selectEgressEvents(state)).toEqual(list); + expect(selectLoadingEgressEvents(state)).toBe(true); + expect(selectClientEgressEvents('client-1')(state)).toEqual(list); + expect(selectLoadingClientEgressEvents('client-1')(state)).toBe(true); + expect(selectLoadingClientEgressEvents('other')(state)).toBe(false); + }); + }); + + describe('effects', () => { + let actions$: Actions; + let statisticsService: { getEgressEvents: jest.Mock; getClientEgressEvents: jest.Mock }; + + beforeEach(() => { + statisticsService = { getEgressEvents: jest.fn(), getClientEgressEvents: jest.fn() }; + }); + + function setup(action: unknown) { + TestBed.configureTestingModule({ + providers: [provideMockActions(() => of(action)), { provide: StatisticsService, useValue: statisticsService }], + }); + actions$ = TestBed.inject(Actions); + } + + it('loads aggregate egress events', async () => { + statisticsService.getEgressEvents.mockReturnValue(of(list)); + setup(loadStatisticsEgressEvents({ params: { decision: 'blocked' } })); + + const result = await firstValueFrom( + TestBed.runInInjectionContext(() => loadStatisticsEgressEvents$(actions$, statisticsService as never)), + ); + + expect(statisticsService.getEgressEvents).toHaveBeenCalledWith({ decision: 'blocked' }); + expect(result).toEqual(loadStatisticsEgressEventsSuccess({ data: list })); + }); + + it('maps aggregate failures', async () => { + statisticsService.getEgressEvents.mockReturnValue(throwError(() => new Error('network'))); + setup(loadStatisticsEgressEvents({ params: {} })); + + const result = await firstValueFrom( + TestBed.runInInjectionContext(() => loadStatisticsEgressEvents$(actions$, statisticsService as never)), + ); + + expect(result).toEqual(loadStatisticsEgressEventsFailure({ error: 'network' })); + }); + + it('loads and fails client-scoped egress events', async () => { + statisticsService.getClientEgressEvents.mockReturnValue(of(list)); + setup(loadClientStatisticsEgressEvents({ params: { clientId: 'client-1', kind: 'dns' } })); + + await expect( + firstValueFrom( + TestBed.runInInjectionContext(() => loadClientStatisticsEgressEvents$(actions$, statisticsService as never)), + ), + ).resolves.toEqual(loadClientStatisticsEgressEventsSuccess({ clientId: 'client-1', data: list })); + expect(statisticsService.getClientEgressEvents).toHaveBeenCalledWith('client-1', { + clientId: 'client-1', + kind: 'dns', + }); + + TestBed.resetTestingModule(); + statisticsService.getClientEgressEvents.mockReturnValue(throwError(() => 'denied')); + setup(loadClientStatisticsEgressEvents({ params: { clientId: 'client-1' } })); + + await expect( + firstValueFrom( + TestBed.runInInjectionContext(() => loadClientStatisticsEgressEvents$(actions$, statisticsService as never)), + ), + ).resolves.toEqual(loadClientStatisticsEgressEventsFailure({ clientId: 'client-1', error: 'denied' })); + }); + }); + + describe('facade', () => { + it('dispatches egress actions and exposes selectors', () => { + const store = { dispatch: jest.fn(), select: jest.fn().mockReturnValue(of(list)) }; + + TestBed.configureTestingModule({ providers: [StatisticsFacade, { provide: Store, useValue: store }] }); + + const facade = TestBed.inject(StatisticsFacade); + + facade.loadEgressEvents({ decision: 'observed' }); + facade.loadClientEgressEvents({ clientId: 'client-1' }); + expect(store.dispatch).toHaveBeenCalledWith(loadStatisticsEgressEvents({ params: { decision: 'observed' } })); + expect(store.dispatch).toHaveBeenCalledWith( + loadClientStatisticsEgressEvents({ params: { clientId: 'client-1' } }), + ); + + facade.getClientEgressEvents$('client-1'); + facade.getLoadingClientEgressEvents$('client-1'); + expect(store.select).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.actions.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.actions.ts index f77a6d064..eb3c6f1e5 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.actions.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.actions.ts @@ -4,6 +4,7 @@ import type { StatisticsAggregateParams, StatisticsChatIoListDto, StatisticsClientScopeParams, + StatisticsEgressEventListDto, StatisticsEntityEventListDto, StatisticsFilterDropListDto, StatisticsFilterFlagListDto, @@ -149,3 +150,31 @@ export const loadStatisticsEntityEventsFailure = createAction( '[Statistics] Load Entity Events Failure', props<{ error: string }>(), ); + +// Client-scoped: Egress Events +export const loadClientStatisticsEgressEvents = createAction( + '[Statistics] Load Client Egress Events', + props<{ params: StatisticsClientScopeParams }>(), +); +export const loadClientStatisticsEgressEventsSuccess = createAction( + '[Statistics] Load Client Egress Events Success', + props<{ clientId: string; data: StatisticsEgressEventListDto }>(), +); +export const loadClientStatisticsEgressEventsFailure = createAction( + '[Statistics] Load Client Egress Events Failure', + props<{ clientId: string; error: string }>(), +); + +// Aggregate: Egress Events +export const loadStatisticsEgressEvents = createAction( + '[Statistics] Load Egress Events', + props<{ params?: StatisticsAggregateParams }>(), +); +export const loadStatisticsEgressEventsSuccess = createAction( + '[Statistics] Load Egress Events Success', + props<{ data: StatisticsEgressEventListDto }>(), +); +export const loadStatisticsEgressEventsFailure = createAction( + '[Statistics] Load Egress Events Failure', + props<{ error: string }>(), +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.effects.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.effects.ts index d7359d247..b6b4b17cb 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.effects.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.effects.ts @@ -5,6 +5,12 @@ import { catchError, map, of, switchMap } from 'rxjs'; import { StatisticsService } from '../../services/statistics.service'; import { + loadClientStatisticsEgressEvents, + loadClientStatisticsEgressEventsFailure, + loadClientStatisticsEgressEventsSuccess, + loadStatisticsEgressEvents, + loadStatisticsEgressEventsFailure, + loadStatisticsEgressEventsSuccess, loadClientStatisticsChatIo, loadClientStatisticsChatIoFailure, loadClientStatisticsChatIoSuccess, @@ -194,3 +200,33 @@ export const loadStatisticsEntityEvents$ = createEffect( ), { functional: true }, ); + +export const loadClientStatisticsEgressEvents$ = createEffect( + (actions$ = inject(Actions), statisticsService = inject(StatisticsService)) => + actions$.pipe( + ofType(loadClientStatisticsEgressEvents), + switchMap(({ params }) => + statisticsService.getClientEgressEvents(params.clientId, params).pipe( + map((data) => loadClientStatisticsEgressEventsSuccess({ clientId: params.clientId, data })), + catchError((error) => + of(loadClientStatisticsEgressEventsFailure({ clientId: params.clientId, error: normalizeError(error) })), + ), + ), + ), + ), + { functional: true }, +); + +export const loadStatisticsEgressEvents$ = createEffect( + (actions$ = inject(Actions), statisticsService = inject(StatisticsService)) => + actions$.pipe( + ofType(loadStatisticsEgressEvents), + switchMap(({ params }) => + statisticsService.getEgressEvents(params).pipe( + map((data) => loadStatisticsEgressEventsSuccess({ data })), + catchError((error) => of(loadStatisticsEgressEventsFailure({ error: normalizeError(error) }))), + ), + ), + ), + { functional: true }, +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.facade.ts index 7d9dcb87f..92c20ab62 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.facade.ts @@ -3,6 +3,8 @@ import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { + loadClientStatisticsEgressEvents, + loadStatisticsEgressEvents, loadClientStatisticsChatIo, loadClientStatisticsEntityEvents, loadClientStatisticsFilterDrops, @@ -15,6 +17,10 @@ import { loadStatisticsSummary, } from './statistics.actions'; import { + selectClientEgressEvents, + selectEgressEvents, + selectLoadingClientEgressEvents, + selectLoadingEgressEvents, selectChatIo, selectClientChatIo, selectClientEntityEvents, @@ -41,6 +47,7 @@ import type { StatisticsAggregateParams, StatisticsChatIoListDto, StatisticsClientScopeParams, + StatisticsEgressEventListDto, StatisticsEntityEventListDto, StatisticsFilterDropListDto, StatisticsFilterFlagListDto, @@ -68,6 +75,8 @@ export class StatisticsFacade { readonly loadingFilterDrops$: Observable = this.store.select(selectLoadingFilterDrops); readonly loadingFilterFlags$: Observable = this.store.select(selectLoadingFilterFlags); readonly loadingEntityEvents$: Observable = this.store.select(selectLoadingEntityEvents); + readonly egressEvents$: Observable = this.store.select(selectEgressEvents); + readonly loadingEgressEvents$: Observable = this.store.select(selectLoadingEgressEvents); readonly error$: Observable = this.store.select(selectStatisticsError); /** Client-scoped: load summary */ @@ -169,4 +178,24 @@ export class StatisticsFacade { getLoadingClientEntityEvents$(clientId: string): Observable { return this.store.select(selectLoadingClientEntityEvents(clientId)); } + + /** Client-scoped: load egress guard decisions */ + loadClientEgressEvents(params: StatisticsClientScopeParams): void { + this.store.dispatch(loadClientStatisticsEgressEvents({ params })); + } + + /** Aggregate: load egress guard decisions */ + loadEgressEvents(params?: StatisticsAggregateParams): void { + this.store.dispatch(loadStatisticsEgressEvents({ params })); + } + + /** Client-scoped: egress events for a client */ + getClientEgressEvents$(clientId: string): Observable { + return this.store.select(selectClientEgressEvents(clientId)); + } + + /** Client-scoped: loading state for egress events */ + getLoadingClientEgressEvents$(clientId: string): Observable { + return this.store.select(selectLoadingClientEgressEvents(clientId)); + } } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.reducer.ts index f75a89f9a..941213a4c 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.reducer.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.reducer.ts @@ -1,6 +1,12 @@ import { createReducer, on } from '@ngrx/store'; import { + loadClientStatisticsEgressEvents, + loadClientStatisticsEgressEventsFailure, + loadClientStatisticsEgressEventsSuccess, + loadStatisticsEgressEvents, + loadStatisticsEgressEventsFailure, + loadStatisticsEgressEventsSuccess, loadClientStatisticsChatIo, loadClientStatisticsChatIoFailure, loadClientStatisticsChatIoSuccess, @@ -34,6 +40,7 @@ import { } from './statistics.actions'; import type { StatisticsChatIoListDto, + StatisticsEgressEventListDto, StatisticsEntityEventListDto, StatisticsFilterDropListDto, StatisticsFilterFlagListDto, @@ -47,22 +54,26 @@ export interface StatisticsState { clientFilterDrops: Record; clientFilterFlags: Record; clientEntityEvents: Record; + clientEgressEvents: Record; loadingClientSummary: Record; loadingClientChatIo: Record; loadingClientFilterDrops: Record; loadingClientFilterFlags: Record; loadingClientEntityEvents: Record; + loadingClientEgressEvents: Record; // Aggregate summary: StatisticsSummaryDto | null; chatIo: StatisticsChatIoListDto | null; filterDrops: StatisticsFilterDropListDto | null; filterFlags: StatisticsFilterFlagListDto | null; entityEvents: StatisticsEntityEventListDto | null; + egressEvents: StatisticsEgressEventListDto | null; loadingSummary: boolean; loadingChatIo: boolean; loadingFilterDrops: boolean; loadingFilterFlags: boolean; loadingEntityEvents: boolean; + loadingEgressEvents: boolean; error: string | null; } @@ -72,21 +83,25 @@ export const initialStatisticsState: StatisticsState = { clientFilterDrops: {}, clientFilterFlags: {}, clientEntityEvents: {}, + clientEgressEvents: {}, loadingClientSummary: {}, loadingClientChatIo: {}, loadingClientFilterDrops: {}, loadingClientFilterFlags: {}, loadingClientEntityEvents: {}, + loadingClientEgressEvents: {}, summary: null, chatIo: null, filterDrops: null, filterFlags: null, entityEvents: null, + egressEvents: null, loadingSummary: false, loadingChatIo: false, loadingFilterDrops: false, loadingFilterFlags: false, loadingEntityEvents: false, + loadingEgressEvents: false, error: null, }; @@ -262,4 +277,38 @@ export const statisticsReducer = createReducer( loadingEntityEvents: false, error, })), + // Client-scoped Egress Events + on(loadClientStatisticsEgressEvents, (state, { params }) => ({ + ...state, + loadingClientEgressEvents: { ...state.loadingClientEgressEvents, [params.clientId]: true }, + error: null, + })), + on(loadClientStatisticsEgressEventsSuccess, (state, { clientId, data }) => ({ + ...state, + clientEgressEvents: { ...state.clientEgressEvents, [clientId]: data }, + loadingClientEgressEvents: { ...state.loadingClientEgressEvents, [clientId]: false }, + error: null, + })), + on(loadClientStatisticsEgressEventsFailure, (state, { clientId, error }) => ({ + ...state, + loadingClientEgressEvents: { ...state.loadingClientEgressEvents, [clientId]: false }, + error, + })), + // Aggregate Egress Events + on(loadStatisticsEgressEvents, (state) => ({ + ...state, + loadingEgressEvents: true, + error: null, + })), + on(loadStatisticsEgressEventsSuccess, (state, { data }) => ({ + ...state, + egressEvents: data, + loadingEgressEvents: false, + error: null, + })), + on(loadStatisticsEgressEventsFailure, (state, { error }) => ({ + ...state, + loadingEgressEvents: false, + error, + })), ); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.selectors.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.selectors.ts index bcb9a3710..bb726583d 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.selectors.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.selectors.ts @@ -57,3 +57,13 @@ export const selectLoadingFilterFlags = createSelector(selectStatisticsState, (s export const selectLoadingEntityEvents = createSelector(selectStatisticsState, (state) => state.loadingEntityEvents); export const selectStatisticsError = createSelector(selectStatisticsState, (state) => state.error); + +export const selectClientEgressEvents = (clientId: string) => + createSelector(selectStatisticsState, (state) => state.clientEgressEvents[clientId]); + +export const selectLoadingClientEgressEvents = (clientId: string) => + createSelector(selectStatisticsState, (state) => state.loadingClientEgressEvents[clientId] ?? false); + +export const selectEgressEvents = createSelector(selectStatisticsState, (state) => state.egressEvents); + +export const selectLoadingEgressEvents = createSelector(selectStatisticsState, (state) => state.loadingEgressEvents); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.types.ts index eab7bf9f1..14f218ed0 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/statistics/statistics.types.ts @@ -28,6 +28,68 @@ export interface StatisticsSummaryDto { filterFlagCount: number; filterFlagsBreakdown: StatisticsFilterTypesBreakdownItem[]; series?: StatisticsSeriesPoint[]; + /** Egress guard decisions (sum of aggregated counts) by decision. */ + egressAllowedCount?: number; + egressBlockedCount?: number; + egressObservedCount?: number; + egressGuardErrorCount?: number; + egressBreakdown?: StatisticsEgressBreakdownItem[]; + egressTopBlockedHosts?: Array<{ host: string; count: number }>; + egressRulesBreakdown?: Array<{ rule: string; count: number }>; + /** Security events over time (with groupBy). */ + securitySeries?: StatisticsSecuritySeriesPoint[]; +} + +export type StatisticsEgressKind = 'web' | 'dns' | 'network' | 'guard'; +export type StatisticsEgressDecision = 'allowed' | 'blocked' | 'observed' | 'error'; + +export interface StatisticsEgressBreakdownItem { + kind: StatisticsEgressKind; + decision: StatisticsEgressDecision; + count: number; +} + +export interface StatisticsSecuritySeriesPoint { + period: string; + filterDrops: number; + filterFlags: number; + egressAllowed: number; + egressBlocked: number; + egressObserved: number; +} + +/** Single egress guard decision (aggregate of identical decisions within one collection cycle). */ +export interface StatisticsEgressEventDto { + id: string; + clientId: string; + agentId?: string; + clientName?: string; + agentName?: string; + kind: StatisticsEgressKind; + decision: StatisticsEgressDecision; + host?: string | null; + rule?: string | null; + matchedEntry?: string | null; + matchedSource?: string | null; + method?: string | null; + httpStatus?: number | null; + sampleUrl?: string | null; + count: number; + bytesIn: number; + bytesOut: number; + mode?: string | null; + policyRevision?: string | null; + message?: string | null; + firstSeenAt: string; + occurredAt: string; +} + +/** Paginated egress events list */ +export interface StatisticsEgressEventListDto { + data: StatisticsEgressEventDto[]; + total: number; + limit: number; + offset: number; } /** Single chat I/O record */ @@ -140,6 +202,8 @@ export interface StatisticsQueryParams { entityType?: StatisticsEntityType; eventType?: StatisticsEntityEventType; direction?: 'input' | 'output'; + kind?: StatisticsEgressKind; + decision?: StatisticsEgressDecision; search?: string; limit?: number; offset?: number; diff --git a/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/agent-config-editor.component.html b/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/agent-config-editor.component.html index 7c049bf30..6f8d78ff0 100644 --- a/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/agent-config-editor.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/agent-config-editor.component.html @@ -2721,6 +2721,592 @@

+ @if (layer() === 'agent' && egressStatus(); as status) { +
+ Guard status: + {{ status.status }} + {{ status.mode }} + @if (status.appliedAt) { + applied {{ status.appliedAt | date: 'short' }} + } + @if (status.lastError) { + + } +
+ } + +

Mode

+ @if (isRowVisible('/egress/mode')) { + + + + @for (mode of egressModes; track mode) { + + } + +
+ Effective: + {{ egressScalarHint('mode').value }} + @if (egressScalarHint('mode').source; as source) { + {{ source }} + } +
+
+ } + @for (flag of egressFlagRows; track flag.key) { + @if (isRowVisible('/egress/' + flag.key)) { + + + + + + +
+ Effective: + {{ egressScalarHint($any(flag.hint)).value }} + @if (egressScalarHint($any(flag.hint)).source; as source) { + {{ source }} + } +
+
+ } + } + +

+ Destinations +

+ @for (list of egressListRows; track list.key) { + @if (isRowVisible('/egress/' + list.key)) { + + @if (inheritedEgressItems($any(list.key)).length) { +
+ @for (item of inheritedEgressItems($any(list.key)); track item) { + {{ item }} + } + (inherited, read-only) +
+ } + +
+ } + } + +

+ Non-web exceptions + @if (showLockControl('/egress/network_allow')) { + + } +

+
+ + + +
+ @for (rule of inheritedEgressNetworkRules(); track $index) { +
+ {{ rule['protocol'] }}/{{ rule['ports'] }} → {{ egressNetworkDestinations(rule) }} + Inherited +
+ } + @if (egressNetworkRuleEntries().length === 0 && inheritedEgressNetworkRules().length === 0) { +
+ +
+ } @else if (egressNetworkRuleEntries().length > 0) { + + @for (rule of egressNetworkRuleEntries(); track rule.__index) { + + + {{ rule['protocol'] }}/{{ rule['ports'] }} + @if (rule.__deleted) { + Removed + } + + {{ egressNetworkDestinations(rule) }} +
+ + + +
+ @if (!rule.__deleted) { +
+ + + @for (protocol of egressNetworkProtocols; track protocol) { + + } + + + + + + + + + + + +
+ } +
+ } +
+ } + +

+ Test a destination +

+ + + @if (egressTestResult(); as result) { +
+ {{ result.decision }} + {{ result.host }} + @if (result.rule) { + · {{ result.rule }} + } + @if (result.matchedEntry) { + · {{ result.matchedEntry }} + } + @if (result.source) { + {{ result.source }} + } +
+ } +
+ + } + + @if (isTabVisible('message_filters')) { + +

+ Rules + @if (showLockControl('/message_filters')) { + + } +

+
+ + + +
+ @for (rule of inheritedMessageFilters(); track $index) { +
+ {{ rule.name }} + /{{ rule.pattern }}/{{ rule.flags }} + {{ rule.action }} · {{ rule.direction }} + @if (!rule.enabled) { + Disabled + } + {{ rule.source }} +
+ } + @if (messageFilterRuleEntries().length === 0 && inheritedMessageFilters().length === 0) { +
+ +
+ } @else if (messageFilterRuleEntries().length > 0) { + + @for (rule of messageFilterRuleEntries(); track rule.__index) { + + + {{ rule['name'] }} + @if (rule.__deleted) { + Removed + } + + {{ rule['action'] }} · {{ rule['direction'] ?? 'bidirectional' }} +
+ + + + + + + + + +
+ @if (!rule.__deleted) { +
+ + + + + + + + + + + + @for (direction of messageFilterDirections; track direction) { + + } + + + + + @for (action of messageFilterActions; track action) { + + } + + + @if (rule['action'] === 'replace') { + + + + } + + + +
+ } +
+ } +
+ } + +

+ Test a message +

+ + + + + + + @if (messageFilterTestResult(); as result) { +
+ {{ result.status }} + @if (result.matchedRule; as matched) { + {{ matched.name }} + {{ matched.source }} + } + @if (result.modifiedMessage !== undefined) { +
{{ result.modifiedMessage }}
+ } +
+ } +
+
+ } + @if (isTabVisible('websearch')) { ([]); readonly inheritedAdditive = signal([]); readonly secretKeys = signal([]); + /** Effective platform security policy resolved by the server (provenance per entry). */ + readonly platformPolicy = signal(null); + /** Egress guard status of the environment (agent layer only). */ + readonly egressStatus = signal(null); + readonly egressTestInput = signal(''); + readonly messageFilterTestInput = signal(''); + readonly messageFilterTestDirection = signal<'incoming' | 'outgoing'>('incoming'); + readonly egressModes = EGRESS_MODES; + readonly egressFlagRows = [ + { + key: 'block_private_networks', + hint: 'blockPrivateNetworks', + title: $localize`:@@featureAgentConfig-fieldEgressBlockPrivate:Block private networks`, + help: $localize`:@@featureAgentConfig-fieldEgressBlockPrivateHelp:Deny loopback, link-local, RFC 1918 and cloud metadata addresses unless explicitly allowed`, + }, + { + key: 'block_ip_literals', + hint: 'blockIpLiterals', + title: $localize`:@@featureAgentConfig-fieldEgressBlockIpLiterals:Block IP literals`, + help: $localize`:@@featureAgentConfig-fieldEgressBlockIpLiteralsHelp:Deny connections to raw IP addresses (and TLS without a hostname) unless the address is allowed`, + }, + { + key: 'log_allowed', + hint: 'logAllowed', + title: $localize`:@@featureAgentConfig-fieldEgressLogAllowed:Record allowed traffic`, + help: $localize`:@@featureAgentConfig-fieldEgressLogAllowedHelp:Include allowed destinations in the audit log (blocked traffic is always recorded)`, + }, + ] as const; + readonly egressListRows = [ + { + key: 'deny_hosts', + title: $localize`:@@featureAgentConfig-fieldEgressDenyHosts:Denylist`, + help: $localize`:@@featureAgentConfig-fieldEgressDenyHostsHelp:Hostnames (subdomains match too; *.example.com is equivalent), IP addresses or CIDRs, one per line. Always wins.`, + }, + { + key: 'allow_hosts', + title: $localize`:@@featureAgentConfig-fieldEgressAllowHosts:Allowlist`, + help: $localize`:@@featureAgentConfig-fieldEgressAllowHostsHelp:When not empty, only these destinations are reachable. Same syntax as the denylist.`, + }, + { + key: 'deny_url_patterns', + title: $localize`:@@featureAgentConfig-fieldEgressDenyUrlPatterns:Blocked plain-HTTP URL patterns`, + help: $localize`:@@featureAgentConfig-fieldEgressDenyUrlPatternsHelp:Regular expressions matched against http:// URLs (HTTPS paths are not visible), one per line.`, + }, + ] as const; + readonly egressNetworkProtocols = EGRESS_NETWORK_PROTOCOLS; + readonly messageFilterActions = MESSAGE_FILTER_ACTIONS; + readonly messageFilterDirections = MESSAGE_FILTER_DIRECTIONS; + readonly policyLayerSource = computed(() => editorLayerSource(this.layer())); + /** Composed draft (config + overrides) of this layer, used by the policy testers. */ + readonly composedDraft = computed(() => composeLayerOverlay(this.config(), this.overrides())); + readonly draftEgress = computed(() => draftEgressPolicy(this.platformPolicy(), this.layer(), this.composedDraft())); + readonly egressTestResult = computed(() => testEgressDestination(this.draftEgress(), this.egressTestInput())); + readonly draftMessageFilters = computed(() => + draftMessageFilterRules(this.platformPolicy(), this.layer(), this.composedDraft()), + ); + readonly inheritedMessageFilters = computed(() => + this.draftMessageFilters().filter((rule) => rule.source !== this.policyLayerSource()), + ); + readonly messageFilterRuleEntries = computed(() => this.ruleEntries('message_filters')); + readonly egressNetworkRuleEntries = computed(() => this.ruleEntries('egress.network_allow')); + readonly messageFilterTestResult = computed(() => { + const message = this.messageFilterTestInput(); + + return message ? testMessageFilters(this.draftMessageFilters(), message, this.messageFilterTestDirection()) : null; + }); /** Draft password values for layer secrets (network + provider credentials). Never prefilled from API. */ readonly secretDrafts = signal>({}); /** Secret keys marked for removal on save (empty draft while key was previously set). */ @@ -2889,6 +2971,136 @@ export class AgentConfigEditorComponent implements OnInit { this.patchPath(path, rules); } + /** Inherited (read-only) entries of an egress list from higher layers. */ + inheritedEgressItems(list: 'allow_hosts' | 'deny_hosts' | 'deny_url_patterns'): string[] { + return this.inheritedItems(`/egress/${list}`).filter((item): item is string => typeof item === 'string'); + } + + /** Inherited (read-only) network exceptions from higher layers. */ + inheritedEgressNetworkRules(): JsonObject[] { + return this.inheritedItems('/egress/network_allow').filter(isPlainObject); + } + + /** Effective value of an egress scalar with its source (shown as a hint next to the local control). */ + egressScalarHint(key: 'mode' | 'blockPrivateNetworks' | 'blockIpLiterals' | 'logAllowed'): { + value: string; + source: string | null; + } { + const policy = this.draftEgress(); + + return { value: String(policy[key]), source: policy.scalarSources[key] }; + } + + /** Tri-state select value for egress booleans (`''` = inherit / default). */ + egressBooleanSelectValue(key: 'block_private_networks' | 'block_ip_literals' | 'log_allowed'): string { + const value = this.getDisplayPath(`egress.${key}`); + + return value === true ? 'true' : value === false ? 'false' : ''; + } + + setEgressBoolean(key: 'block_private_networks' | 'block_ip_literals' | 'log_allowed', value: string): void { + if (this.isLocked(`/egress/${key}`)) { + return; + } + + this.patchPath(`egress.${key}`, value === 'true' ? true : value === 'false' ? false : undefined); + } + + setEgressMode(value: string): void { + if (this.isLocked('/egress/mode')) { + return; + } + + this.patchPath('egress.mode', value ? value : undefined); + } + + addEgressNetworkRule(): void { + if (this.isLocked('/egress/network_allow')) { + return; + } + + this.patchPath('egress.network_allow', [ + ...this.objectArray('egress.network_allow'), + { protocol: 'tcp', ports: '22', destinations: [] }, + ]); + } + + egressNetworkDestinations(rule: JsonObject): string { + return Array.isArray(rule['destinations']) ? (rule['destinations'] as unknown[]).map(String).join(', ') : ''; + } + + updateEgressNetworkDestinations(index: number, value: string): void { + if (this.isPathMutationLocked('egress.network_allow')) { + return; + } + + const rules = this.objectArray('egress.network_allow').map((rule) => ({ ...rule })); + + if (!rules[index]) { + return; + } + + rules[index]['destinations'] = parseDestinationList(value); + this.patchPath('egress.network_allow', rules); + } + + addMessageFilterRule(): void { + if (this.isLocked('/message_filters')) { + return; + } + + const current = this.objectArray('message_filters'); + + this.patchPath('message_filters', [ + ...current, + { + name: nextMessageFilterRuleName(current), + pattern: '', + flags: 'gi', + direction: 'bidirectional', + action: 'flag', + enabled: true, + }, + ]); + } + + updateMessageFilterAction(index: number, action: string): void { + if (this.isPathMutationLocked('message_filters')) { + return; + } + + const rules = this.objectArray('message_filters').map((rule) => ({ ...rule })); + + if (!rules[index]) { + return; + } + + rules[index]['action'] = action; + + if (action === 'replace') { + rules[index]['replacement'] = typeof rules[index]['replacement'] === 'string' ? rules[index]['replacement'] : ''; + } else { + delete rules[index]['replacement']; + } + + this.patchPath('message_filters', rules); + } + + setMessageFilterEnabled(index: number, enabled: boolean): void { + if (this.isPathMutationLocked('message_filters')) { + return; + } + + const rules = this.objectArray('message_filters').map((rule) => ({ ...rule })); + + if (!rules[index]) { + return; + } + + rules[index]['enabled'] = enabled; + this.patchPath('message_filters', rules); + } + readPlugins(): Array<{ index: number; packageName: string; @@ -3416,6 +3628,8 @@ export class AgentConfigEditorComponent implements OnInit { this.locks.set(normalizeStoredLocks(dto.locks ?? [])); this.inheritedAdditive.set(dto.inheritedAdditive ?? []); this.secretKeys.set(dto.secretKeys ?? []); + this.platformPolicy.set(dto.platformPolicy ?? null); + this.egressStatus.set(dto.egress ?? null); this.secretDrafts.set({}); this.clearSecretKeys.set(new Set()); this.mcpAllowDenyRemovalStash.set(new Map()); diff --git a/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.spec.ts b/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.spec.ts new file mode 100644 index 000000000..0d31a6710 --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.spec.ts @@ -0,0 +1,106 @@ +import { resolvePlatformSecurityPolicy } from '@forepath/agenstra/shared/util-opencode-config'; + +import { + draftEgressPolicy, + draftMessageFilterRules, + editorLayerSource, + egressOverlay, + messageFilterRules, + nextMessageFilterRuleName, + parentEgressPolicy, + parentLayerSources, + parseDestinationList, + parseEgressTestInput, + testEgressDestination, + testMessageFilters, +} from './security-policy-editor.util'; + +const serverPolicy = resolvePlatformSecurityPolicy([ + { source: 'global', overlay: { egress: { mode: 'enforce', deny_hosts: ['bad.example'] } } }, + { + source: 'workspace', + overlay: { + egress: { allow_hosts: ['old.example'], log_allowed: false }, + message_filters: [{ name: 'Old', pattern: 'old', action: 'drop' }], + }, + }, +]); + +describe('security-policy-editor.util', () => { + it('maps editor layers to policy sources and parents', () => { + expect(editorLayerSource('agent')).toBe('environment'); + expect(editorLayerSource('global')).toBe('global'); + expect(parentLayerSources('global')).toEqual([]); + expect(parentLayerSources('workspace')).toEqual(['global']); + expect(parentLayerSources('agent')).toEqual(['global', 'workspace']); + }); + + it('removes the own layer from the server policy to obtain the parent policy', () => { + const parent = parentEgressPolicy(serverPolicy, 'workspace'); + + expect(parent.mode).toBe('enforce'); + expect(parent.allowHosts).toEqual([]); + expect(parent.logAllowed).toBe(true); + expect(parent.scalarSources.logAllowed).toBeNull(); + expect(parent.denyHosts).toEqual([{ value: 'bad.example', source: 'global' }]); + }); + + it('combines parent policy and the unsaved draft (parent scalars win)', () => { + const policy = draftEgressPolicy(serverPolicy, 'workspace', { + egress: { mode: 'off', allow_hosts: ['github.com'], block_ip_literals: true }, + }); + + expect(policy.mode).toBe('enforce'); + expect(policy.scalarSources.mode).toBe('global'); + expect(policy.blockIpLiterals).toBe(true); + expect(policy.scalarSources.blockIpLiterals).toBe('workspace'); + expect(policy.allowHosts).toEqual([{ value: 'github.com', source: 'workspace' }]); + + expect(testEgressDestination(policy, 'https://x.bad.example/path')).toEqual( + expect.objectContaining({ host: 'x.bad.example', decision: 'blocked', rule: 'deny_host', source: 'global' }), + ); + expect(testEgressDestination(policy, 'gitlab.com:443')).toEqual( + expect.objectContaining({ host: 'gitlab.com', decision: 'blocked', rule: 'not_allowlisted' }), + ); + expect(testEgressDestination(policy, 'api.github.com')?.decision).toBe('allowed'); + expect(testEgressDestination(policy, ' ')).toBeNull(); + }); + + it('parses tester input', () => { + expect(parseEgressTestInput('http://Example.com/a?b=1')).toEqual({ + host: 'example.com', + url: 'http://example.com/a', + scheme: 'http', + }); + expect(parseEgressTestInput('[::1]:8080')).toEqual({ host: '::1' }); + expect(parseEgressTestInput('http://')).toBeNull(); + }); + + it('evaluates inherited and draft message filter rules together', () => { + const rules = draftMessageFilterRules(serverPolicy, 'agent', { + message_filters: [{ name: 'Mask', pattern: 'secret', action: 'replace', replacement: '***' }], + }); + + expect(rules.map((rule) => [rule.name, rule.source])).toEqual([ + ['Old', 'workspace'], + ['Mask', 'environment'], + ]); + expect(testMessageFilters(rules, 'my secret', 'incoming')).toEqual( + expect.objectContaining({ status: 'filtered', modifiedMessage: 'my ***' }), + ); + expect(testMessageFilters(rules, 'old secret', 'outgoing').status).toBe('dropped'); + }); + + it('provides overlay readers and helpers', () => { + expect(egressOverlay({ egress: { mode: 'off' } })).toEqual({ mode: 'off' }); + expect(egressOverlay({ egress: [] })).toEqual({}); + expect(messageFilterRules({ message_filters: [{ name: 'a' }, 'x'] })).toEqual([{ name: 'a' }]); + expect(parseDestinationList('10.0.0.1, 10.0.0.2\n\n192.168.0.0/16')).toEqual([ + '10.0.0.1', + '10.0.0.2', + '192.168.0.0/16', + ]); + expect(nextMessageFilterRuleName([{ name: 'Rule 2' }])).toBe('Rule 3'); + expect(nextMessageFilterRuleName([{ name: 'Rule 2' }, { name: 'Rule 3' }])).toBe('Rule 4'); + }); +}); diff --git a/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.ts b/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.ts new file mode 100644 index 000000000..71e09add3 --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-config/src/lib/agent-config-editor/security-policy-editor.util.ts @@ -0,0 +1,220 @@ +import { + EGRESS_CONFIG_ROOT, + MESSAGE_FILTERS_CONFIG_ROOT, + evaluateEgressDestination, + evaluateMessageFilters, + resolveEffectiveEgressPolicy, + resolveEffectiveMessageFilters, + type EffectiveEgressPolicy, + type EffectiveMessageFilterRule, + type EffectivePlatformSecurityPolicy, + type EgressEvaluationResult, + type JsonObject, + type MessageFilterEvaluation, + type PlatformPolicyLayer, +} from '@forepath/agenstra/shared/util-opencode-config'; + +export type SecurityEditorLayer = 'global' | 'workspace' | 'agent'; + +/** Lists edited on the Web traffic tab (inherited items are read-only, local items editable). */ +export const EGRESS_EDITOR_LIST_KEYS = ['allow_hosts', 'deny_hosts', 'deny_url_patterns'] as const; +export type EgressEditorListKey = (typeof EGRESS_EDITOR_LIST_KEYS)[number]; + +export function editorLayerSource(layer: SecurityEditorLayer): PlatformPolicyLayer { + return layer === 'agent' ? 'environment' : layer; +} + +/** Parent layers a layer inherits from (highest precedence first). */ +export function parentLayerSources(layer: SecurityEditorLayer): PlatformPolicyLayer[] { + if (layer === 'global') { + return []; + } + + return layer === 'workspace' ? ['global'] : ['global', 'workspace']; +} + +function isPlainObject(value: unknown): value is JsonObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Policy contributed by the parent layers, reconstructed from the server-resolved policy (entries of the + * current layer are removed so the draft can replace them). + */ +export function parentEgressPolicy( + serverPolicy: EffectivePlatformSecurityPolicy | null | undefined, + layer: SecurityEditorLayer, +): EffectiveEgressPolicy { + const own = editorLayerSource(layer); + const base = serverPolicy?.egress ?? resolveEffectiveEgressPolicy([]); + const keep = (entries: readonly T[]) => + entries.filter((entry) => entry.source !== own); + const sources = { ...base.scalarSources }; + const defaults = resolveEffectiveEgressPolicy([]); + const scalar = (key: K) => + base.scalarSources[key] === own ? defaults[key] : base[key]; + + for (const key of Object.keys(sources) as Array) { + if (sources[key] === own) { + sources[key] = null; + } + } + + return { + mode: scalar('mode'), + allowHosts: keep(base.allowHosts), + denyHosts: keep(base.denyHosts), + denyUrlPatterns: keep(base.denyUrlPatterns), + networkAllow: keep(base.networkAllow), + blockPrivateNetworks: scalar('blockPrivateNetworks'), + blockIpLiterals: scalar('blockIpLiterals'), + logAllowed: scalar('logAllowed'), + scalarSources: sources, + }; +} + +/** + * Effective egress policy for the editor draft: parent layers (from the server) + the current layer's + * composed draft overlay. Parent scalars win (they are locked for lower layers anyway). + */ +export function draftEgressPolicy( + serverPolicy: EffectivePlatformSecurityPolicy | null | undefined, + layer: SecurityEditorLayer, + draftOverlay: JsonObject, +): EffectiveEgressPolicy { + const parent = parentEgressPolicy(serverPolicy, layer); + const own = resolveEffectiveEgressPolicy([{ source: editorLayerSource(layer), overlay: draftOverlay }]); + const merge = (parentEntries: readonly T[], ownEntries: readonly T[]) => [ + ...parentEntries, + ...ownEntries.filter((entry) => !parentEntries.some((existing) => existing.value === entry.value)), + ]; + const pick = (key: K) => + parent.scalarSources[key] !== null + ? { value: parent[key], source: parent.scalarSources[key] } + : { value: own[key], source: own.scalarSources[key] }; + const mode = pick('mode'); + const blockPrivateNetworks = pick('blockPrivateNetworks'); + const blockIpLiterals = pick('blockIpLiterals'); + const logAllowed = pick('logAllowed'); + + return { + mode: mode.value, + allowHosts: merge(parent.allowHosts, own.allowHosts), + denyHosts: merge(parent.denyHosts, own.denyHosts), + denyUrlPatterns: merge(parent.denyUrlPatterns, own.denyUrlPatterns), + networkAllow: [...parent.networkAllow, ...own.networkAllow], + blockPrivateNetworks: blockPrivateNetworks.value, + blockIpLiterals: blockIpLiterals.value, + logAllowed: logAllowed.value, + scalarSources: { + mode: mode.source, + blockPrivateNetworks: blockPrivateNetworks.source, + blockIpLiterals: blockIpLiterals.source, + logAllowed: logAllowed.source, + }, + }; +} + +/** Split a tester input (hostname, host:port or URL) into host / URL / scheme. */ +export function parseEgressTestInput(raw: string): { host: string; url?: string; scheme?: 'http' | 'https' } | null { + const value = raw.trim(); + + if (!value) { + return null; + } + + if (/^https?:\/\//i.test(value)) { + try { + const parsed = new URL(value); + + return { + host: parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(), + url: `${parsed.protocol}//${parsed.host}${parsed.pathname}`, + scheme: parsed.protocol === 'http:' ? 'http' : 'https', + }; + } catch { + return null; + } + } + + const head = value.split('/')[0] ?? ''; + const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(head); + const host = bracketed + ? bracketed[1] + : (head.match(/:/g) ?? []).length === 1 + ? head.slice(0, head.indexOf(':')) + : head; + + return host ? { host: host.toLowerCase() } : null; +} + +export function testEgressDestination( + policy: EffectiveEgressPolicy, + raw: string, +): (EgressEvaluationResult & { host: string }) | null { + const parsed = parseEgressTestInput(raw); + + if (!parsed) { + return null; + } + + return { + host: parsed.host, + ...evaluateEgressDestination(policy, parsed.host, { url: parsed.url, scheme: parsed.scheme }), + }; +} + +/** Effective message filter rules for the editor draft: inherited parent rules + the draft rules of this layer. */ +export function draftMessageFilterRules( + serverPolicy: EffectivePlatformSecurityPolicy | null | undefined, + layer: SecurityEditorLayer, + draftOverlay: JsonObject, +): EffectiveMessageFilterRule[] { + const own = editorLayerSource(layer); + const parents = (serverPolicy?.messageFilters ?? []).filter((rule) => rule.source !== own); + const local = resolveEffectiveMessageFilters([{ source: own, overlay: draftOverlay }]); + + return [...parents, ...local]; +} + +export function testMessageFilters( + rules: readonly EffectiveMessageFilterRule[], + message: string, + direction: 'incoming' | 'outgoing', +): MessageFilterEvaluation { + return evaluateMessageFilters(rules, message, direction); +} + +/** Parse a comma / newline separated destination list (network exceptions). */ +export function parseDestinationList(value: string): string[] { + return value + .split(/[\n,]/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** Read the egress overlay of a composed layer draft. */ +export function egressOverlay(draft: JsonObject): JsonObject { + const egress = draft[EGRESS_CONFIG_ROOT]; + + return isPlainObject(egress) ? egress : {}; +} + +/** Read the message filter rules of a composed layer draft. */ +export function messageFilterRules(draft: JsonObject): JsonObject[] { + const rules = draft[MESSAGE_FILTERS_CONFIG_ROOT]; + + return Array.isArray(rules) ? rules.filter(isPlainObject) : []; +} + +/** Next free default rule name within a layer (`Rule 1`, `Rule 2`, …). */ +export function nextMessageFilterRuleName(existing: readonly JsonObject[], prefix = 'Rule'): string { + const names = new Set(existing.map((rule) => String(rule['name'] ?? '').trim())); + let index = existing.length + 1; + + while (names.has(`${prefix} ${index}`)) { + index += 1; + } + + return `${prefix} ${index}`; +} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts index da9c74e89..803a7cdd8 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts @@ -28,7 +28,6 @@ import { createChatSession$, createExternalImportConfig$, createFileOrDirectory$, - createFilterRule$, createKnowledgeNode$, createKnowledgeRelation$, createTicket$, @@ -42,7 +41,6 @@ import { deleteEnvironmentVariable$, deleteExternalImportConfig$, deleteFileOrDirectory$, - deleteFilterRule$, deleteKnowledgeNode$, deleteKnowledgeRelation$, deleteProvisionedServer$, @@ -64,8 +62,6 @@ import { fetch$, FilesFacade, filesReducer, - FilterRulesFacade, - filterRulesReducer, WorkspaceSearchFacade, workspaceSearchReducer, loadWorkspaceIndexStatus$, @@ -93,6 +89,7 @@ import { loadClients$, loadMoreClients$, loadClientStatisticsChatIo$, + loadClientStatisticsEgressEvents$, loadClientStatisticsEntityEvents$, loadClientStatisticsFilterDrops$, loadClientStatisticsFilterFlags$, @@ -103,8 +100,6 @@ import { loadEnvironmentVariablesBatch$, loadEnvironmentVariablesCount$, loadChatSessions$, - loadFilterRules$, - loadMoreFilterRules$, loadGitBranches$, loadGitDiff$, loadGitStatus$, @@ -123,6 +118,7 @@ import { loadServerTypes$, loadLocations$, loadStatisticsChatIo$, + loadStatisticsEgressEvents$, loadStatisticsEntityEvents$, loadStatisticsFilterDrops$, loadStatisticsFilterFlags$, @@ -201,7 +197,6 @@ import { updateDeploymentConfiguration$, updateEnvironmentVariable$, updateExternalImportConfig$, - updateFilterRule$, updateKnowledgeNode$, updateTicket$, uploadKnowledgeTextFiles$, @@ -230,7 +225,6 @@ import { AgentConsoleContainerComponent } from './container/container.component' import { ticketsRequireActiveClientGuard } from './guards/tickets-require-active-client.guard'; import { KnowledgeBoardComponent } from './knowledge/knowledge-board.component'; import { AgentConfigAdminComponent } from './opencode-config/opencode-config-admin.component'; -import { RuleManagerComponent } from './rule-manager/rule-manager.component'; import { provideAgenstraNotificationAdminClientProvider } from './providers/notification-admin.providers'; import { TicketsBoardComponent } from './tickets/tickets-board.component'; @@ -254,12 +248,6 @@ export const agentConsoleRoutes: Route[] = [ component: AuditComponent, title: $localize`:@@featureAgentConsole-auditTitle:Audit :: Agenstra`, }, - { - path: 'filters', - canActivate: [authGuard, adminGuard], - component: RuleManagerComponent, - title: $localize`:@@featureAgentConsole-filtersTitle:Filters :: Agenstra`, - }, { path: 'agent-config', canActivate: [authGuard, adminGuard], @@ -368,7 +356,6 @@ export const agentConsoleRoutes: Route[] = [ TicketAutomationFacade, KnowledgeFacade, ClientAgentAutonomyFacade, - FilterRulesFacade, AtlassianContextImportFacade, WorkspaceConfigFacade, WorkspaceSearchFacade, @@ -393,7 +380,6 @@ export const agentConsoleRoutes: Route[] = [ provideState('ticketAutomation', ticketAutomationReducer), provideState('knowledge', knowledgeReducer), provideState('clientAgentAutonomy', clientAgentAutonomyReducer), - provideState('filterRules', filterRulesReducer), provideState('atlassianContextImport', atlassianContextImportReducer), provideState('workspaceConfig', workspaceConfigReducer), provideState('notifications', notificationsReducer), @@ -500,6 +486,8 @@ export const agentConsoleRoutes: Route[] = [ loadStatisticsFilterDrops$, loadStatisticsFilterFlags$, loadStatisticsEntityEvents$, + loadClientStatisticsEgressEvents$, + loadStatisticsEgressEvents$, loadDeploymentConfiguration$, createDeploymentConfiguration$, updateDeploymentConfiguration$, @@ -543,11 +531,6 @@ export const agentConsoleRoutes: Route[] = [ reloadRelationsAfterWrite$, reloadActivityAfterWrite$, upsertClientAgentAutonomy$, - loadFilterRules$, - loadMoreFilterRules$, - createFilterRule$, - updateFilterRule$, - deleteFilterRule$, loadAtlassianContextImport$, loadAtlassianContextImportBatch$, loadAtlassianConnections$, diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.spec.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.spec.ts new file mode 100644 index 000000000..982b6a362 --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.spec.ts @@ -0,0 +1,94 @@ +import { + alignSecuritySeries, + egressDecisionColor, + formatBytes, + formatEgressDecision, + formatEgressKind, + formatEgressRule, + hasEgressData, +} from './audit-security.util'; + +describe('audit-security.util', () => { + it('aligns security points with bucket keys and fills gaps with zero', () => { + const aligned = alignSecuritySeries( + [ + { + period: '2026-10-11T10:00:00.000Z', + filterDrops: 1, + filterFlags: 0, + egressAllowed: 5, + egressBlocked: 2, + egressObserved: 0, + }, + { + period: '2026-10-11T18:00:00.000Z', + filterDrops: 0, + filterFlags: 1, + egressAllowed: 1, + egressBlocked: 1, + egressObserved: 3, + }, + ], + ['2026-10-10T00:00:00.000Z', '2026-10-11T00:00:00.000Z'], + 'day', + ); + + expect(aligned).toEqual({ + categories: ['2026-10-10T00:00:00.000Z', '2026-10-11T00:00:00.000Z'], + filterDrops: [0, 1], + filterFlags: [0, 1], + egressAllowed: [0, 6], + egressBlocked: [0, 3], + egressObserved: [0, 3], + }); + }); + + it('falls back to API periods when no bucket keys exist', () => { + const aligned = alignSecuritySeries( + [ + { + period: '2026-10-11T11:30:00.000Z', + filterDrops: 2, + filterFlags: 0, + egressAllowed: 0, + egressBlocked: 0, + egressObserved: 0, + }, + { + period: '2026-10-11T10:05:00.000Z', + filterDrops: 1, + filterFlags: 0, + egressAllowed: 0, + egressBlocked: 0, + egressObserved: 0, + }, + ], + [], + 'hour', + ); + + expect(aligned.categories).toEqual(['2026-10-11T10:00:00.000Z', '2026-10-11T11:00:00.000Z']); + expect(aligned.filterDrops).toEqual([1, 2]); + }); + + it('detects egress data and formats labels', () => { + expect(hasEgressData(undefined)).toBe(false); + expect(hasEgressData([{ kind: 'web', decision: 'blocked', count: 0 }])).toBe(false); + expect(hasEgressData([{ kind: 'web', decision: 'allowed', count: 2 }])).toBe(true); + expect(formatEgressRule('deny_host')).toBe('Denylisted destination'); + expect(formatEgressRule(null)).toBe('-'); + expect(formatEgressRule('custom_rule')).toBe('custom_rule'); + expect(formatEgressDecision('observed')).toBe('Would be blocked'); + expect(formatEgressKind('dns')).toBe('DNS'); + expect(egressDecisionColor('blocked')).toBe('danger'); + expect(egressDecisionColor('allowed')).toBe('success'); + expect(egressDecisionColor('other')).toBe('secondary'); + }); + + it('formats byte counts', () => { + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(50 * 1024 * 1024)).toBe('50 MB'); + }); +}); diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.ts new file mode 100644 index 000000000..41b256019 --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit-security.util.ts @@ -0,0 +1,168 @@ +import type { + StatisticsEgressBreakdownItem, + StatisticsSecuritySeriesPoint, +} from '@forepath/agenstra/frontend/data-access-agent-console'; + +export interface AlignedSecuritySeries { + categories: string[]; + filterDrops: number[]; + filterFlags: number[]; + egressAllowed: number[]; + egressBlocked: number[]; + egressObserved: number[]; +} + +function utcBucketKey(period: string, groupBy: 'day' | 'hour'): string { + const date = new Date(period); + + if (Number.isNaN(date.getTime())) { + return period; + } + + return groupBy === 'day' + ? new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())).toISOString() + : new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours()), + ).toISOString(); +} + +/** + * Align security series points with every bucket of the selected range (gaps become zero). + * When no bucket keys are available, the API points are used as categories. + */ +export function alignSecuritySeries( + points: readonly StatisticsSecuritySeriesPoint[], + bucketKeys: readonly string[], + groupBy: 'day' | 'hour', +): AlignedSecuritySeries { + const byBucket = new Map(); + + for (const point of points) { + const key = utcBucketKey(point.period, groupBy); + const existing = byBucket.get(key); + + byBucket.set( + key, + existing + ? { + period: key, + filterDrops: existing.filterDrops + point.filterDrops, + filterFlags: existing.filterFlags + point.filterFlags, + egressAllowed: existing.egressAllowed + point.egressAllowed, + egressBlocked: existing.egressBlocked + point.egressBlocked, + egressObserved: existing.egressObserved + point.egressObserved, + } + : { ...point, period: key }, + ); + } + + const categories = bucketKeys.length > 0 ? [...bucketKeys] : [...byBucket.keys()].sort(); + const pick = (field: keyof Omit) => + categories.map((key) => byBucket.get(key)?.[field] ?? 0); + + return { + categories, + filterDrops: pick('filterDrops'), + filterFlags: pick('filterFlags'), + egressAllowed: pick('egressAllowed'), + egressBlocked: pick('egressBlocked'), + egressObserved: pick('egressObserved'), + }; +} + +/** Whether any egress data exists in a summary breakdown (hides empty egress charts). */ +export function hasEgressData(breakdown: readonly StatisticsEgressBreakdownItem[] | undefined): boolean { + return (breakdown ?? []).some((item) => item.count > 0); +} + +/** Human-readable label for a guard rule identifier. */ +export function formatEgressRule(rule: string | null | undefined): string { + switch (rule) { + case 'deny_host': + return $localize`:@@featureAudit-egressRuleDenyHost:Denylisted destination`; + case 'not_allowlisted': + return $localize`:@@featureAudit-egressRuleNotAllowlisted:Not on the allowlist`; + case 'private_network': + return $localize`:@@featureAudit-egressRulePrivateNetwork:Private network`; + case 'ip_literal': + return $localize`:@@featureAudit-egressRuleIpLiteral:IP literal`; + case 'deny_url': + return $localize`:@@featureAudit-egressRuleDenyUrl:Blocked URL pattern`; + case 'offline': + return $localize`:@@featureAudit-egressRuleOffline:Offline mode`; + case 'quic': + return $localize`:@@featureAudit-egressRuleQuic:QUIC (UDP/443)`; + case 'dns_over_tls': + return $localize`:@@featureAudit-egressRuleDnsOverTls:DNS over TLS`; + case 'undeclared_protocol': + return $localize`:@@featureAudit-egressRuleUndeclared:Undeclared protocol / port`; + case 'guard': + return $localize`:@@featureAudit-egressRuleGuard:Guard`; + case null: + case undefined: + case '': + return '-'; + default: + return rule; + } +} + +/** Human-readable label for an egress decision. */ +export function formatEgressDecision(decision: string): string { + switch (decision) { + case 'allowed': + return $localize`:@@featureAudit-egressDecisionAllowed:Allowed`; + case 'blocked': + return $localize`:@@featureAudit-egressDecisionBlocked:Blocked`; + case 'observed': + return $localize`:@@featureAudit-egressDecisionObserved:Would be blocked`; + case 'error': + return $localize`:@@featureAudit-egressDecisionError:Guard error`; + default: + return decision; + } +} + +/** Human-readable label for an egress event kind. */ +export function formatEgressKind(kind: string): string { + switch (kind) { + case 'web': + return $localize`:@@featureAudit-egressKindWeb:Web`; + case 'dns': + return $localize`:@@featureAudit-egressKindDns:DNS`; + case 'network': + return $localize`:@@featureAudit-egressKindNetwork:Network`; + case 'guard': + return $localize`:@@featureAudit-egressKindGuard:Guard`; + default: + return kind; + } +} + +/** Badge color for an egress decision. */ +export function egressDecisionColor(decision: string): 'success' | 'danger' | 'warning' | 'secondary' { + switch (decision) { + case 'allowed': + return 'success'; + case 'blocked': + case 'error': + return 'danger'; + case 'observed': + return 'warning'; + default: + return 'secondary'; + } +} + +/** Compact byte formatting for table cells. */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** exponent; + + return `${value >= 10 || exponent === 0 ? Math.round(value) : value.toFixed(1)} ${units[exponent]}`; +} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.html index b1307df66..864426eed 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.html @@ -1,7 +1,7 @@
- + @if (loadingSummary$ | async) { @@ -65,6 +65,24 @@ } + + + @if (loadingSummary$ | async) { + + } @else if (summary(); as summaryData) { + {{ summaryData.egressBlockedCount ?? 0 }} + } + + + + + @if (loadingSummary$ | async) { + + } @else if (summary(); as summaryData) { + {{ summaryData.egressObservedCount ?? 0 }} + } + + + +
+ @if (securitySeriesChartOptions(); as chartOpts) { +
+
+
+ +
+
+
+ } + @if (egressRulesChartOptions(); as donutOpts) { +
+
+
+ +
+
+
+ } + @if (egressSeriesChartOptions(); as chartOpts) { +
+
+
+ +
+
+
+ } + @if (egressTopHostsChartOptions(); as barOpts) { +
+
+
+ +
+
+
+ } +
+
Audit Tables
@@ -502,6 +598,146 @@
Audit Tables
}
+ +
+
diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.ts index 447c56a07..a40cd8669 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/audit/audit.component.ts @@ -8,6 +8,7 @@ import { ClientsFacade, StatisticsFacade, type ClientResponseDto, + type StatisticsEgressDecision, type StatisticsSeriesPoint, type UserResponseDto, } from '@forepath/agenstra/frontend/data-access-agent-console'; @@ -39,6 +40,16 @@ import { NgApexchartsModule } from 'ng-apexcharts'; import { getUnavailableDisplayLabel, resolveNamedDisplayLabel } from '../display-name.util'; import { ClientSelectComponent } from '../client-select/client-select.component'; +import { + alignSecuritySeries, + egressDecisionColor, + formatBytes, + formatEgressDecision, + formatEgressKind, + formatEgressRule, + hasEgressData, +} from './audit-security.util'; + const PAGE_SIZE = 10; const AUDIT_FILTERS_STORAGE_KEY = 'agent-console-audit-filters'; @@ -51,9 +62,14 @@ interface AuditFiltersStorage { filterDropsSearch: string; filterFlagsSearch: string; entityEventsSearch: string; + egressEventsSearch: string; + egressDecision: StatisticsEgressDecision | ''; filtersCollapsed: boolean; } +const CHART_TEXT_STYLE = { color: 'var(--bs-body-color)', fontFamily: 'var(--bs-body-font-family)' }; +const CHART_LABEL_STYLE = { colors: 'var(--bs-body-color)', fontFamily: 'var(--bs-body-font-family)' }; + /** Bootstrap color palette for charts (donut/bar). */ const BS_CHART_COLORS = [ 'var(--bs-primary)', @@ -109,6 +125,15 @@ export class AuditComponent implements OnInit { readonly totalCostLabel = $localize`:@@featureAudit-totalCost:Total cost (USD)`; readonly filterFlagsLabel = $localize`:@@featureAudit-filterFlags:Filter flags`; readonly filterDropsLabel = $localize`:@@featureAudit-filterDrops:Filter drops`; + readonly egressBlockedLabel = $localize`:@@featureAudit-egressBlocked:Blocked destinations`; + readonly egressObservedLabel = $localize`:@@featureAudit-egressObserved:Would-be blocks`; + readonly egressAllowedLabel = $localize`:@@featureAudit-egressAllowed:Allowed requests`; + readonly egressDecisions: StatisticsEgressDecision[] = ['blocked', 'observed', 'allowed', 'error']; + readonly formatEgressRule = formatEgressRule; + readonly formatEgressDecision = formatEgressDecision; + readonly formatEgressKind = formatEgressKind; + readonly egressDecisionColor = egressDecisionColor; + readonly formatBytes = formatBytes; readonly filtersCollapsed = signal(true); readonly selectedClientId = signal(null); @@ -120,16 +145,20 @@ export class AuditComponent implements OnInit { readonly filterDropsPage = signal(0); readonly filterFlagsPage = signal(0); readonly entityEventsPage = signal(0); + readonly egressEventsPage = signal(0); + readonly egressDecision = signal(''); readonly chatIoSearch = signal(''); readonly filterDropsSearch = signal(''); readonly filterFlagsSearch = signal(''); readonly entityEventsSearch = signal(''); + readonly egressEventsSearch = signal(''); private chatIoSearchTimer: ReturnType | null = null; private filterDropsSearchTimer: ReturnType | null = null; private filterFlagsSearchTimer: ReturnType | null = null; private entityEventsSearchTimer: ReturnType | null = null; + private egressEventsSearchTimer: ReturnType | null = null; readonly clients$ = this.clientsFacade.clients$; readonly clients = toSignal(this.clientsFacade.clients$, { initialValue: [] as ClientResponseDto[] }); @@ -139,6 +168,9 @@ export class AuditComponent implements OnInit { readonly filterDrops = toSignal(this.statisticsFacade.filterDrops$, { initialValue: null }); readonly filterFlags = toSignal(this.statisticsFacade.filterFlags$, { initialValue: null }); readonly entityEvents = toSignal(this.statisticsFacade.entityEvents$, { initialValue: null }); + readonly egressEvents = toSignal(this.statisticsFacade.egressEvents$, { initialValue: null }); + readonly loadingEgressEvents$ = this.statisticsFacade.loadingEgressEvents$; + readonly hasEgress = computed(() => hasEgressData(this.summary()?.egressBreakdown)); readonly loadingSummary$ = this.statisticsFacade.loadingSummary$; readonly loadingChatIo$ = this.statisticsFacade.loadingChatIo$; readonly loadingFilterDrops$ = this.statisticsFacade.loadingFilterDrops$; @@ -266,6 +298,186 @@ export class AuditComponent implements OnInit { }; }); + /** Security events over time: message filter drops / flags and blocked or observed destinations. */ + readonly securitySeriesChartOptions = computed<{ + series: ApexAxisChartSeries; + chart: ApexChart; + colors: string[]; + stroke: { curve: 'smooth'; width: number }; + dataLabels: ApexDataLabels; + xaxis: ApexXAxis; + yaxis: { labels: { style: { colors: string } } }; + grid: { borderColor: string }; + legend: { labels: { colors: string } }; + title: ApexTitleSubtitle; + } | null>(() => { + const s = this.summary(); + + if (!s?.securitySeries) return null; + + const groupBy = this.groupBy(); + const aligned = alignSecuritySeries( + s.securitySeries, + this.generateUtcBucketKeysInclusive(this.fromDate(), this.toDate(), groupBy), + groupBy, + ); + const axisDateFormat = groupBy === 'day' ? 'mediumDate' : 'short'; + + return { + series: [ + { name: $localize`:@@featureAudit-seriesFilterDrops:Message drops`, data: aligned.filterDrops }, + { name: $localize`:@@featureAudit-seriesFilterFlags:Message flags`, data: aligned.filterFlags }, + { name: $localize`:@@featureAudit-seriesEgressBlocked:Blocked destinations`, data: aligned.egressBlocked }, + { name: $localize`:@@featureAudit-seriesEgressObserved:Would-be blocks`, data: aligned.egressObserved }, + ], + chart: { + type: 'line', + height: 280, + toolbar: { show: false }, + background: 'transparent', + zoom: { enabled: false }, + }, + colors: ['var(--bs-danger)', 'var(--bs-warning)', 'var(--bs-primary)', 'var(--bs-info)'], + stroke: { curve: 'smooth', width: 2 }, + dataLabels: { enabled: false }, + xaxis: { + categories: aligned.categories, + labels: { + style: CHART_LABEL_STYLE, + formatter: (value: string): string => this.formatAxisDateLabel(value, axisDateFormat), + }, + axisBorder: { color: 'var(--bs-border-color)' }, + }, + yaxis: { labels: { style: CHART_LABEL_STYLE } }, + grid: { borderColor: 'var(--bs-border-color)' }, + legend: { labels: { colors: 'var(--bs-body-color)' } }, + title: { + text: $localize`:@@featureAudit-chartSecurityOverTimeTitle:Security events over time`, + style: CHART_TEXT_STYLE, + }, + }; + }); + + /** Web traffic decisions over time (stacked: allowed / blocked / would-be blocked). */ + readonly egressSeriesChartOptions = computed<{ + series: ApexAxisChartSeries; + chart: ApexChart; + colors: string[]; + dataLabels: ApexDataLabels; + xaxis: ApexXAxis; + yaxis: { labels: { style: { colors: string } } }; + grid: { borderColor: string }; + legend: { labels: { colors: string } }; + title: ApexTitleSubtitle; + } | null>(() => { + const s = this.summary(); + + if (!s?.securitySeries || !this.hasEgress()) return null; + + const groupBy = this.groupBy(); + const aligned = alignSecuritySeries( + s.securitySeries, + this.generateUtcBucketKeysInclusive(this.fromDate(), this.toDate(), groupBy), + groupBy, + ); + const axisDateFormat = groupBy === 'day' ? 'mediumDate' : 'short'; + + return { + series: [ + { name: formatEgressDecision('allowed'), data: aligned.egressAllowed }, + { name: formatEgressDecision('blocked'), data: aligned.egressBlocked }, + { name: formatEgressDecision('observed'), data: aligned.egressObserved }, + ], + chart: { + type: 'bar', + stacked: true, + height: 280, + toolbar: { show: false }, + background: 'transparent', + zoom: { enabled: false }, + }, + colors: ['var(--bs-success)', 'var(--bs-danger)', 'var(--bs-warning)'], + dataLabels: { enabled: false }, + xaxis: { + categories: aligned.categories, + labels: { + style: CHART_LABEL_STYLE, + formatter: (value: string): string => this.formatAxisDateLabel(value, axisDateFormat), + }, + axisBorder: { color: 'var(--bs-border-color)' }, + }, + yaxis: { labels: { style: CHART_LABEL_STYLE } }, + grid: { borderColor: 'var(--bs-border-color)' }, + legend: { labels: { colors: 'var(--bs-body-color)' } }, + title: { + text: $localize`:@@featureAudit-chartEgressOverTimeTitle:Web traffic decisions over time`, + style: CHART_TEXT_STYLE, + }, + }; + }); + + /** Most frequently blocked / would-be blocked destinations. */ + readonly egressTopHostsChartOptions = computed<{ + series: ApexAxisChartSeries; + chart: ApexChart; + colors: string[]; + plotOptions: { bar: { horizontal: boolean; borderRadius: number } }; + dataLabels: ApexDataLabels; + xaxis: ApexXAxis; + yaxis: { labels: { style: { colors: string } } }; + grid: { borderColor: string }; + title: ApexTitleSubtitle; + } | null>(() => { + const hosts = this.summary()?.egressTopBlockedHosts ?? []; + + if (hosts.length === 0) return null; + + return { + series: [{ name: this.egressBlockedLabel, data: hosts.map((host) => host.count) }], + chart: { type: 'bar', height: 280, toolbar: { show: false }, background: 'transparent' }, + colors: ['var(--bs-danger)'], + plotOptions: { bar: { horizontal: true, borderRadius: 2 } }, + dataLabels: { enabled: false }, + xaxis: { + categories: hosts.map((host) => host.host), + labels: { style: CHART_LABEL_STYLE }, + axisBorder: { color: 'var(--bs-border-color)' }, + }, + yaxis: { labels: { style: CHART_LABEL_STYLE } }, + grid: { borderColor: 'var(--bs-border-color)' }, + title: { + text: $localize`:@@featureAudit-chartTopBlockedHostsTitle:Top blocked destinations`, + style: CHART_TEXT_STYLE, + }, + }; + }); + + /** Blocked / would-be blocked decisions by guard rule. */ + readonly egressRulesChartOptions = computed<{ + series: ApexNonAxisChartSeries; + chart: ApexChart; + colors: string[]; + labels: string[]; + legend: { labels: { colors: string }; fontFamily: string }; + title: ApexTitleSubtitle; + } | null>(() => { + const rules = this.summary()?.egressRulesBreakdown ?? []; + + if (rules.length === 0) return null; + + return { + series: rules.map((rule) => rule.count), + chart: { type: 'donut', height: 280, background: 'transparent' }, + colors: rules.map((_, index) => BS_CHART_COLORS[index % BS_CHART_COLORS.length]), + labels: rules.map((rule) => formatEgressRule(rule.rule)), + legend: { labels: { colors: 'var(--bs-body-color)' }, fontFamily: 'var(--bs-body-font-family)' }, + title: { + text: $localize`:@@featureAudit-chartEgressRulesTitle:Blocks by rule`, + style: CHART_TEXT_STYLE, + }, + }; + }); + ngOnInit(): void { const stored = this.loadFilters(); @@ -286,6 +498,15 @@ export class AuditComponent implements OnInit { if (stored.entityEventsSearch !== undefined) this.entityEventsSearch.set(stored.entityEventsSearch); + if (stored.egressEventsSearch !== undefined) this.egressEventsSearch.set(stored.egressEventsSearch); + + if ( + stored.egressDecision !== undefined && + (stored.egressDecision === '' || this.egressDecisions.includes(stored.egressDecision)) + ) { + this.egressDecision.set(stored.egressDecision); + } + if (stored.filtersCollapsed !== undefined) this.filtersCollapsed.set(stored.filtersCollapsed); } @@ -394,11 +615,17 @@ export class AuditComponent implements OnInit { this.filterDropsPage.set(0); this.filterFlagsPage.set(0); this.entityEventsPage.set(0); + this.egressEventsPage.set(0); this.filtersCollapsed.set(true); this.applyFilters(); } onResetFilters(): void { + if (this.egressEventsSearchTimer) { + clearTimeout(this.egressEventsSearchTimer); + this.egressEventsSearchTimer = null; + } + if (this.chatIoSearchTimer) { clearTimeout(this.chatIoSearchTimer); this.chatIoSearchTimer = null; @@ -427,11 +654,14 @@ export class AuditComponent implements OnInit { this.filterDropsSearch.set(''); this.filterFlagsSearch.set(''); this.entityEventsSearch.set(''); + this.egressEventsSearch.set(''); + this.egressDecision.set(''); this.filtersCollapsed.set(true); this.chatIoPage.set(0); this.filterDropsPage.set(0); this.filterFlagsPage.set(0); this.entityEventsPage.set(0); + this.egressEventsPage.set(0); this.applyFilters(); } @@ -459,6 +689,31 @@ export class AuditComponent implements OnInit { this.loadEntityEvents(); } + onEgressEventsPageChange(page: number): void { + this.egressEventsPage.set(page); + this.loadEgressEvents(); + } + + onEgressEventsSearchChange(value: string): void { + this.egressEventsSearch.set(value); + + if (this.egressEventsSearchTimer) clearTimeout(this.egressEventsSearchTimer); + + this.egressEventsSearchTimer = setTimeout(() => { + this.egressEventsPage.set(0); + this.loadEgressEvents(); + this.egressEventsSearchTimer = null; + }, 300); + } + + onEgressDecisionChange(value: string): void { + this.egressDecision.set( + this.egressDecisions.includes(value as StatisticsEgressDecision) ? (value as StatisticsEgressDecision) : '', + ); + this.egressEventsPage.set(0); + this.loadEgressEvents(); + } + onChatIoSearchChange(value: string): void { this.chatIoSearch.set(value); @@ -515,6 +770,7 @@ export class AuditComponent implements OnInit { this.loadFilterDrops(); this.loadFilterFlags(); this.loadEntityEvents(); + this.loadEgressEvents(); } private getBaseParams(): { @@ -597,6 +853,20 @@ export class AuditComponent implements OnInit { }); } + private loadEgressEvents(): void { + const base = this.getBaseParams(); + const search = this.egressEventsSearch().trim() || undefined; + const decision = this.egressDecision() || undefined; + + this.statisticsFacade.loadEgressEvents({ + ...base, + search, + decision, + limit: PAGE_SIZE, + offset: this.egressEventsPage() * PAGE_SIZE, + }); + } + /** * Aligns API series with every UTC bucket in the selected date range so gaps show as zero. * Falls back to API-only points when from/to are not valid YYYY-MM-DD. @@ -776,6 +1046,8 @@ export class AuditComponent implements OnInit { this.filterDropsSearch(); this.filterFlagsSearch(); this.entityEventsSearch(); + this.egressEventsSearch(); + this.egressDecision(); this.filtersCollapsed(); this.saveFilters(); }); @@ -814,6 +1086,8 @@ export class AuditComponent implements OnInit { filterDropsSearch: this.filterDropsSearch(), filterFlagsSearch: this.filterFlagsSearch(), entityEventsSearch: this.entityEventsSearch(), + egressEventsSearch: this.egressEventsSearch(), + egressDecision: this.egressDecision(), filtersCollapsed: this.filtersCollapsed(), }; diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.ts index 62077cfad..c2d930dae 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.ts @@ -179,19 +179,12 @@ export class AgentConsoleContainerComponent implements OnInit { title: $localize`:@@featureContainer-userManagementTitle:User Management`, label: $localize`:@@featureContainer-users:Users`, }, - { - routerLink: ['/filters'], - activePaths: ['/filters'], - icon: 'bi-funnel', - title: $localize`:@@featureContainer-filtersTitle:Filters`, - label: $localize`:@@featureContainer-filters:Filters`, - }, { routerLink: ['/agent-config'], activePaths: ['/agent-config', '/opencode-config'], - icon: 'bi-sliders', + icon: 'bi-cpu', title: $localize`:@@featureContainer-agentConfigTitle:Agent configuration`, - label: $localize`:@@featureContainer-agentConfig:Agent config`, + label: $localize`:@@featureContainer-agentConfig:Config`, }, { routerLink: ['/imports/atlassian'], @@ -251,7 +244,6 @@ export class AgentConsoleContainerComponent implements OnInit { const onMainRoute = path.includes('/clients') || path.includes('/users') || - path.includes('/filters') || path.includes('/webhooks') || path.includes('/updates') || path.includes('/audit') || @@ -280,7 +272,6 @@ export class AgentConsoleContainerComponent implements OnInit { return ( path.includes('/users') || - path.includes('/filters') || path.includes('/agent-config') || path.includes('/imports') || path.includes('/webhooks') || diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/filter-rule-labels.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/filter-rule-labels.ts deleted file mode 100644 index baec127df..000000000 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/filter-rule-labels.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Human-readable labels for filter rule API values (direction, filter type). - * Unknown values pass through for forward compatibility. - */ - -export function filterRuleDirectionLabel(direction: string): string { - switch (direction) { - case 'incoming': - return $localize`:@@featureRuleManager-directionIncoming:Incoming`; - case 'outgoing': - return $localize`:@@featureRuleManager-directionOutgoing:Outgoing`; - case 'bidirectional': - return $localize`:@@featureRuleManager-directionBidirectional:Bidirectional`; - default: - return direction; - } -} - -export function filterRuleTypeLabel(filterType: string): string { - switch (filterType) { - case 'none': - return $localize`:@@featureRuleManager-filterTypeNone:No content change`; - case 'filter': - return $localize`:@@featureRuleManager-filterTypeReplace:Replace content`; - case 'drop': - return $localize`:@@featureRuleManager-filterTypeDrop:Drop message`; - default: - return filterType; - } -} - -export function filterRuleTesterNoMatch(): string { - return $localize`:@@featureRuleManager-testerNoMatch:No match`; -} - -export function filterRuleTesterWouldDrop(): string { - return $localize`:@@featureRuleManager-testerWouldDrop:Match: the message would be dropped.`; -} - -export function filterRuleTesterNoContentChange(): string { - return $localize`:@@featureRuleManager-testerNoContentChange:Match: no content change.`; -} - -export function filterRuleTesterReplacedMessage(replaced: string): string { - const head = $localize`:@@featureRuleManager-testerReplacedHead:Match: replaced result:`; - - return `${head}\n${replaced}`; -} - -/** Tooltip for workspace sync icon (shows lastError when sync failed). */ -export function filterRuleWorkspaceSyncTitle( - row: { syncStatus: string; lastError?: string | null } | undefined, -): string { - const status = row?.syncStatus ?? 'pending'; - - if (status === 'failed' && row?.lastError) { - return row.lastError; - } - - if (status === 'failed') { - return $localize`:@@featureRuleManager-syncWsTitleFailed:Sync to agent-manager failed`; - } - - if (status === 'synced') { - return $localize`:@@featureRuleManager-syncWsTitleSynced:Synced to agent-manager`; - } - - return $localize`:@@featureRuleManager-syncWsTitlePending:Waiting to sync to agent-manager`; -} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.html deleted file mode 100644 index 44c3accbb..000000000 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.html +++ /dev/null @@ -1,412 +0,0 @@ -
- - - - - - -
- @if (loading$ | async) { -
- -
- } @else { - @if (error$ | async; as err) { - - } - @if (rules().length === 0) { - @if (searchQuery().trim()) { - - } @else { - - } - } @else { - - @for (rule of rules(); track rule.id) { - -
- /{{ rule.pattern - }}/{{ rule.regexFlags }} -
-
- - @if (statusLabel(rule) === 'inactive') { - - Inactive - } @else if (statusLabel(rule) === 'active') { - - Active - } @else { - - Synced - } - - - {{ directionLabel(rule.direction) }} - - - {{ filterTypeLabel(rule.filterType) }} - -
- - - - - - - - - - - -
- @if (rule.isGlobal) { - All workspaces - } - @if (rule.isGlobal) { - @for (row of sortedWorkspaceSync(rule); track row.clientId) { - - - {{ clientName(row.clientId) }} - - } - } @else { - @for (wid of rule.workspaceIds; track wid) { - @let row = workspaceSyncRow(rule, wid); - - - {{ clientName(wid) }} - - } - } -
-
- } -
- @if ((appendLoading$ | async) || (appendError$ | async)) { - - } - - } - } -
-
- - - - - -
-
- - - -
-
- - - - - - - -
-
- - - - - - - -
-
- @if (createForm.filterType === 'filter') { - - - - } -
-
- - - -
-
- -
-
- -
-
- @if (!createForm.isGlobal) { -
-
Workspaces
- @for (c of clients(); track c.id) { - - } -
- } -
- - Create - -
-
- - - @if (editRule) { - - - -
-
- - - -
-
- - - - - - - -
-
- - - - - - - -
-
- @if (editForm.filterType === 'filter') { - - - - } -
-
- - - -
-
- -
-
- -
-
- @if (editForm.isGlobal === false) { -
-
Workspaces
- @for (c of clients(); track c.id) { - - } -
- } - } -
- - Save - -
-
- - - @if (ruleToDelete; as rd) { -

Delete this rule? Remote copies will be removed where possible.

-

{{ rd.pattern }}

-

- - This action cannot be undone. -

- } -
- - - @if (ruleToTest; as rt) { -

{{ rt.pattern }} / {{ rt.regexFlags }}

-

{{ directionLabel(rt.direction) }} · {{ filterTypeLabel(rt.filterType) }}

- - - - - Run test - - @if (testerResult) { -
-
{{ testerResult.message }}
-
- } - } -
diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.scss b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.scss deleted file mode 100644 index b3bfa3fb9..000000000 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.scss +++ /dev/null @@ -1,13 +0,0 @@ -:host { - display: flex; - flex-direction: column; - flex: 1 1 0; - width: 100%; - min-height: 0; - overflow: hidden; -} - -.rule-manager-container { - flex: 1 1 0; - min-height: 0; -} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.ts deleted file mode 100644 index 9719afa3e..000000000 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/rule-manager/rule-manager.component.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; -import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { RouterModule } from '@angular/router'; -import { - ClientsFacade, - FilterRulesFacade, - createFilterRuleSuccess, - deleteFilterRuleSuccess, - updateFilterRuleSuccess, - type ClientResponseDto, - type CreateFilterRuleDto, - type FilterRuleResponseDto, - type FilterRuleWorkspaceSyncDto, - type UpdateFilterRuleDto, -} from '@forepath/agenstra/frontend/data-access-agent-console'; -import { - FpcAlertComponent, - FpcBadgeComponent, - FpcButtonComponent, - FpcButtonGroupComponent, - FpcConfirmDialogComponent, - FpcEmptyStateComponent, - FpcFormCheckComponent, - FpcFormControlComponent, - FpcFormFieldComponent, - FpcFormSwitchComponent, - FpcInfiniteScrollDirective, - FpcListAppendFooterComponent, - FpcListComponent, - FpcListItemComponent, - FpcModalComponent, - FpcModalFooterDirective, - FpcPageHeaderComponent, - FpcSearchFieldComponent, - FpcSpinnerComponent, -} from '@forepath/shared/frontend/ui-components'; -import { Actions, ofType } from '@ngrx/effects'; -import { combineLatest, debounceTime, distinctUntilChanged, filter, skip } from 'rxjs'; - -import { - filterRuleDirectionLabel, - filterRuleTesterNoContentChange, - filterRuleTesterNoMatch, - filterRuleTesterReplacedMessage, - filterRuleTesterWouldDrop, - filterRuleTypeLabel, - filterRuleWorkspaceSyncTitle, -} from './filter-rule-labels'; -import { resolveNamedDisplayLabel } from '../display-name.util'; - -@Component({ - selector: 'framework-rule-manager', - imports: [ - CommonModule, - FormsModule, - RouterModule, - FpcAlertComponent, - FpcBadgeComponent, - FpcButtonComponent, - FpcButtonGroupComponent, - FpcConfirmDialogComponent, - FpcEmptyStateComponent, - FpcFormCheckComponent, - FpcFormControlComponent, - FpcFormFieldComponent, - FpcFormSwitchComponent, - FpcInfiniteScrollDirective, - FpcListAppendFooterComponent, - FpcListComponent, - FpcListItemComponent, - FpcModalComponent, - FpcModalFooterDirective, - FpcPageHeaderComponent, - FpcSearchFieldComponent, - FpcSpinnerComponent, - ], - templateUrl: './rule-manager.component.html', - styleUrls: ['./rule-manager.component.scss'], - standalone: true, -}) -export class RuleManagerComponent implements OnInit { - readonly directionLabel = filterRuleDirectionLabel; - readonly filterTypeLabel = filterRuleTypeLabel; - readonly workspaceSyncTitle = filterRuleWorkspaceSyncTitle; - - private readonly filterRulesFacade = inject(FilterRulesFacade); - private readonly clientsFacade = inject(ClientsFacade); - private readonly actions$ = inject(Actions); - private readonly destroyRef = inject(DestroyRef); - - readonly createModalOpen = signal(false); - readonly editModalOpen = signal(false); - readonly deleteModalOpen = signal(false); - readonly testerModalOpen = signal(false); - - readonly rules$ = this.filterRulesFacade.rules$; - readonly rules = toSignal(this.rules$, { initialValue: [] as FilterRuleResponseDto[] }); - readonly loading$ = this.filterRulesFacade.loading$; - readonly error$ = this.filterRulesFacade.error$; - readonly saving$ = this.filterRulesFacade.saving$; - readonly deleting$ = this.filterRulesFacade.deleting$; - readonly hasMore$ = this.filterRulesFacade.hasMore$; - readonly appendLoading$ = this.filterRulesFacade.appendLoading$; - readonly appendError$ = this.filterRulesFacade.appendError$; - - readonly clients = toSignal(this.clientsFacade.clients$, { initialValue: [] as ClientResponseDto[] }); - - readonly pageTitle = $localize`:@@featureRuleManager-title:Filters`; - readonly addButtonTitle = $localize`:@@featureRuleManager-addButtonTitle:Add filter rule`; - readonly searchPlaceholder = $localize`:@@featureRuleManager-searchPlaceholder:Search rules`; - readonly loadingLabel = $localize`:@@featureRuleManager-loading:Loading…`; - readonly emptyMessage = $localize`:@@featureRuleManager-empty:No filter rules yet`; - - readonly searchQuery = signal(''); - readonly searchQuery$ = toObservable(this.searchQuery); - - ruleToDelete: FilterRuleResponseDto | null = null; - ruleToTest: FilterRuleResponseDto | null = null; - testerInput = ''; - testerResult: { ok: boolean; message: string } | null = null; - - createForm: CreateFilterRuleDto = { - pattern: '', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - isGlobal: true, - priority: 0, - enabled: true, - }; - - createWorkspaceSelection: Record = {}; - - editRule: FilterRuleResponseDto | null = null; - editForm: UpdateFilterRuleDto & { workspaceIdsSelected: Record } = { - workspaceIdsSelected: {}, - }; - - ngOnInit(): void { - this.filterRulesFacade.load(); - this.clientsFacade.loadClients(); - this.searchQuery$ - .pipe(skip(1), debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe((search) => { - this.filterRulesFacade.load({ search: search.trim() || undefined }); - }); - combineLatest([ - this.clientsFacade.hasMore$, - this.clientsFacade.appendLoading$, - this.clientsFacade.loading$, - this.clientsFacade.appendError$, - ]) - .pipe( - filter( - ([hasMore, appendLoading, loading, appendError]) => hasMore && !appendLoading && !loading && !appendError, - ), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(() => this.clientsFacade.loadMoreClients()); - this.actions$ - .pipe(ofType(createFilterRuleSuccess, updateFilterRuleSuccess), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.createModalOpen.set(false); - this.editModalOpen.set(false); - this.editRule = null; - }); - this.actions$.pipe(ofType(deleteFilterRuleSuccess), takeUntilDestroyed(this.destroyRef)).subscribe(() => { - this.deleteModalOpen.set(false); - this.ruleToDelete = null; - }); - } - - onLoadMoreRules(): void { - this.filterRulesFacade.loadMore(); - } - - clientName(id: string): string { - return resolveNamedDisplayLabel(this.clients().find((c) => c.id === id)?.name); - } - - workspaceSyncRow(rule: FilterRuleResponseDto, clientId: string): FilterRuleWorkspaceSyncDto | undefined { - return (rule.workspaceSync ?? []).find((w) => w.clientId === clientId); - } - - sortedWorkspaceSync(rule: FilterRuleResponseDto): FilterRuleWorkspaceSyncDto[] { - const rows = rule.workspaceSync ?? []; - - return [...rows].sort((a, b) => this.clientName(a.clientId).localeCompare(this.clientName(b.clientId))); - } - - syncWorkspaceIconClass(row: FilterRuleWorkspaceSyncDto | undefined): string { - const s = row?.syncStatus ?? 'pending'; - - if (s === 'synced') { - return 'bi bi-check-circle text-success'; - } - - if (s === 'failed') { - return 'bi bi-x-circle text-danger'; - } - - return 'bi bi-clock text-warning'; - } - - statusLabel(rule: FilterRuleResponseDto): 'inactive' | 'active' | 'synced' { - if (!rule.enabled) { - return 'inactive'; - } - - if (rule.sync.pending > 0 || rule.sync.failed > 0) { - return 'active'; - } - - return 'synced'; - } - - /** Colored first meta value (Decabill admin-list pattern). */ - statusTextClass(rule: FilterRuleResponseDto): string { - switch (this.statusLabel(rule)) { - case 'inactive': - return 'text-secondary'; - case 'active': - return 'text-warning'; - default: - return 'text-success'; - } - } - - onAdd(): void { - this.createForm = { - pattern: '', - regexFlags: 'g', - direction: 'incoming', - filterType: 'none', - isGlobal: true, - priority: 0, - enabled: true, - }; - const sel: Record = {}; - - for (const c of this.clients()) { - sel[c.id] = false; - } - - this.createWorkspaceSelection = sel; - this.createModalOpen.set(true); - } - - submitCreate(): void { - const dto: CreateFilterRuleDto = { - pattern: this.createForm.pattern.trim(), - regexFlags: this.createForm.regexFlags?.trim() || 'g', - direction: this.createForm.direction, - filterType: this.createForm.filterType, - priority: this.createForm.priority ?? 0, - enabled: this.createForm.enabled ?? true, - isGlobal: this.createForm.isGlobal, - replaceContent: this.createForm.filterType === 'filter' ? this.createForm.replaceContent : undefined, - }; - - if (!dto.isGlobal) { - dto.workspaceIds = this.selectedIdsFromMap(this.createWorkspaceSelection); - } - - this.filterRulesFacade.create(dto); - } - - onEdit(rule: FilterRuleResponseDto): void { - this.editRule = rule; - const selected: Record = {}; - - for (const c of this.clients()) { - selected[c.id] = rule.workspaceIds.includes(c.id); - } - - this.editForm = { - pattern: rule.pattern, - regexFlags: rule.regexFlags, - direction: rule.direction, - filterType: rule.filterType, - replaceContent: rule.replaceContent ?? undefined, - priority: rule.priority, - enabled: rule.enabled, - isGlobal: rule.isGlobal, - workspaceIds: [...rule.workspaceIds], - workspaceIdsSelected: selected, - }; - this.editModalOpen.set(true); - } - - submitEdit(): void { - if (!this.editRule) { - return; - } - - const dto: UpdateFilterRuleDto = { - pattern: this.editForm.pattern?.trim(), - regexFlags: this.editForm.regexFlags?.trim(), - direction: this.editForm.direction, - filterType: this.editForm.filterType, - replaceContent: this.editForm.filterType === 'filter' ? this.editForm.replaceContent : null, - priority: this.editForm.priority, - enabled: this.editForm.enabled, - isGlobal: this.editForm.isGlobal, - }; - - if (this.editForm.isGlobal) { - delete dto.workspaceIds; - } else { - dto.workspaceIds = this.selectedIdsFromMap(this.editForm.workspaceIdsSelected); - } - - this.filterRulesFacade.update(this.editRule.id, dto); - } - - onDelete(rule: FilterRuleResponseDto): void { - this.ruleToDelete = rule; - this.deleteModalOpen.set(true); - } - - confirmDelete(): void { - if (this.ruleToDelete) { - this.filterRulesFacade.delete(this.ruleToDelete.id); - } - } - - onTest(rule: FilterRuleResponseDto): void { - this.ruleToTest = rule; - this.testerInput = ''; - this.testerResult = null; - this.testerModalOpen.set(true); - } - - runTester(): void { - const rule = this.ruleToTest; - - if (!rule) { - return; - } - - try { - const re = new RegExp(rule.pattern, rule.regexFlags || 'g'); - const text = this.testerInput; - const matched = re.test(text); - - re.lastIndex = 0; - - if (!matched) { - this.testerResult = { ok: true, message: filterRuleTesterNoMatch() }; - - return; - } - - if (rule.filterType === 'drop') { - this.testerResult = { ok: true, message: filterRuleTesterWouldDrop() }; - - return; - } - - if (rule.filterType === 'none') { - this.testerResult = { ok: true, message: filterRuleTesterNoContentChange() }; - - return; - } - - re.lastIndex = 0; - const replaced = text.replace(re, rule.replaceContent ?? ''); - - this.testerResult = { ok: true, message: filterRuleTesterReplacedMessage(replaced) }; - } catch (e) { - this.testerResult = { ok: false, message: e instanceof Error ? e.message : String(e) }; - } - } - - toggleCreateWorkspace(id: string): void { - this.createWorkspaceSelection[id] = !this.createWorkspaceSelection[id]; - } - - toggleEditWorkspace(id: string): void { - this.editForm.workspaceIdsSelected[id] = !this.editForm.workspaceIdsSelected[id]; - } - - private selectedIdsFromMap(m: Record): string[] { - return Object.entries(m) - .filter(([, v]) => v) - .map(([k]) => k); - } - - cancelCreate(): void { - this.createModalOpen.set(false); - } - - cancelEdit(): void { - this.editModalOpen.set(false); - this.editRule = null; - } - - cancelDelete(): void { - this.deleteModalOpen.set(false); - this.ruleToDelete = null; - } - - cancelTest(): void { - this.testerModalOpen.set(false); - this.ruleToTest = null; - } -} diff --git a/libs/domains/agenstra/frontend/feature-landingpage/src/lib/comparison/shared/matrix/comparison-matrix-data.ts b/libs/domains/agenstra/frontend/feature-landingpage/src/lib/comparison/shared/matrix/comparison-matrix-data.ts index 38f5cf04e..89b8d12d7 100644 --- a/libs/domains/agenstra/frontend/feature-landingpage/src/lib/comparison/shared/matrix/comparison-matrix-data.ts +++ b/libs/domains/agenstra/frontend/feature-landingpage/src/lib/comparison/shared/matrix/comparison-matrix-data.ts @@ -89,7 +89,7 @@ const AGENSTRA_TOOLTIPS: readonly string[] = [ $localize`:@@featurePortalComparison-agenstraT7:Knowledge tree, relations, activity, and prompt-context endpoints.`, $localize`:@@featurePortalComparison-agenstraT8:Deployments APIs cover configs, workflows, runs, logs, and cancel. All of that is scoped to agents.`, $localize`:@@featurePortalComparison-agenstraT9:Documented automation for Hetzner and DigitalOcean to install agent-manager and register clients.`, - $localize`:@@featurePortalComparison-agenstraT10:Global regex filter rules, pluggable chat filters, statistics for drops/flags and entity events.`, + $localize`:@@featurePortalComparison-agenstraT10:Layered message filters and per-environment web traffic filtering (egress guard), pluggable chat filters, audit statistics for drops, flags, blocked destinations and entity events.`, $localize`:@@featurePortalComparison-agenstraT11:Statistics REST for chat I/O, filter incidents, and entity lifecycle (per client and aggregate).`, $localize`:@@featurePortalComparison-agenstraT12:Plugin agent providers and chat filters in agent-manager.`, $localize`:@@featurePortalComparison-agenstraT13:Keycloak OIDC/SAML, built-in JWT users, or static API key. Federate corporate IdPs via Keycloak where needed.`, diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts index cb2e7de7c..668252fba 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts @@ -9,3 +9,7 @@ export * from './materialize'; export * from './enforce-allow-deny'; export * from './automation-platform'; export * from './plan-platform'; +export * from './platform-policy'; +export * from './egress-policy'; +export * from './message-filters'; +export * from './platform-security'; diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/egress-policy.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/egress-policy.ts new file mode 100644 index 000000000..8807ffe2d --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/egress-policy.ts @@ -0,0 +1,1306 @@ +import { + EGRESS_CONFIG_ROOT, + isPlainPolicyObject, + orderPolicyLayersHighToLow, + policyValidationError, + type PlatformPolicyLayer, + type PlatformPolicyLayerInput, +} from './platform-policy'; +import { hasNestedQuantifier } from './message-filters'; +import type { JsonObject } from './types'; + +/** + * Egress (web traffic) policy enforced by the per-agent egress guard sidecar. + * + * - `off` — no guard enforcement (default; agents without a guard keep their legacy network) + * - `monitor` — all traffic allowed; would-be blocks are recorded as `observed` + * - `enforce` — allow / deny lists apply (deny wins; empty allowlist = unrestricted) + * - `offline` — all egress blocked (loopback and inbound manager traffic still work) + */ +export const EGRESS_MODES = ['off', 'monitor', 'enforce', 'offline'] as const; +export type EgressMode = (typeof EGRESS_MODES)[number]; + +/** Scalar settings: the highest layer that sets one wins and locks it for lower layers. */ +export const EGRESS_SCALAR_KEYS = ['mode', 'block_private_networks', 'block_ip_literals', 'log_allowed'] as const; +export type EgressScalarKey = (typeof EGRESS_SCALAR_KEYS)[number]; + +/** List settings: entries concatenate across layers; lower layers may append but never remove. */ +export const EGRESS_LIST_KEYS = ['allow_hosts', 'deny_hosts', 'deny_url_patterns', 'network_allow'] as const; +export type EgressListKey = (typeof EGRESS_LIST_KEYS)[number]; + +export const EGRESS_NETWORK_PROTOCOLS = ['tcp', 'udp'] as const; +export type EgressNetworkProtocol = (typeof EGRESS_NETWORK_PROTOCOLS)[number]; + +export const EGRESS_MAX_HOST_ENTRIES = 5000; +export const EGRESS_MAX_URL_PATTERNS = 500; +export const EGRESS_MAX_URL_PATTERN_LENGTH = 512; +export const EGRESS_MAX_NETWORK_RULES = 100; +export const EGRESS_MAX_NETWORK_DESTINATIONS = 64; +export const EGRESS_MAX_HOST_LENGTH = 253; + +export const EGRESS_DEFAULTS = { + mode: 'off' as EgressMode, + blockPrivateNetworks: true, + blockIpLiterals: false, + logAllowed: true, +}; + +/** Rule identifiers reported by the guard (squid annotations / DNS / netfilter categories). */ +export const EGRESS_RULES = [ + 'offline', + 'deny_host', + 'private_network', + 'ip_literal', + 'deny_url', + 'not_allowlisted', + 'quic', + 'dns_over_tls', + 'undeclared_protocol', + 'guard', +] as const; +export type EgressRule = (typeof EGRESS_RULES)[number]; + +export const EGRESS_DECISIONS = ['allowed', 'blocked', 'observed', 'error'] as const; +export type EgressDecision = (typeof EGRESS_DECISIONS)[number]; + +export const EGRESS_EVENT_KINDS = ['web', 'dns', 'network', 'guard'] as const; +export type EgressEventKind = (typeof EGRESS_EVENT_KINDS)[number]; + +/** Non-web L3/L4 exception (destinations are IPs / CIDRs; ports `22` or `1000-2000`). */ +export interface EgressNetworkAllowRule { + protocol: EgressNetworkProtocol; + ports: string; + destinations: string[]; + description?: string; +} + +/** Stored (snake_case) layer overlay under `egress`. */ +export interface EgressConfigOverlay { + mode?: EgressMode; + allow_hosts?: string[]; + deny_hosts?: string[]; + deny_url_patterns?: string[]; + network_allow?: EgressNetworkAllowRule[]; + block_private_networks?: boolean; + block_ip_literals?: boolean; + log_allowed?: boolean; +} + +export interface EffectiveEgressEntry { + value: string; + source: PlatformPolicyLayer; +} + +export interface EffectiveEgressNetworkRule extends EgressNetworkAllowRule { + source: PlatformPolicyLayer; +} + +/** Fully resolved policy shipped to the agent manager (camelCase wire shape). */ +export interface EffectiveEgressPolicy { + mode: EgressMode; + allowHosts: EffectiveEgressEntry[]; + denyHosts: EffectiveEgressEntry[]; + denyUrlPatterns: EffectiveEgressEntry[]; + networkAllow: EffectiveEgressNetworkRule[]; + blockPrivateNetworks: boolean; + blockIpLiterals: boolean; + logAllowed: boolean; + /** Layer that set each scalar (null = platform default). */ + scalarSources: Record<'mode' | 'blockPrivateNetworks' | 'blockIpLiterals' | 'logAllowed', PlatformPolicyLayer | null>; +} + +export type EgressHostKind = 'domain' | 'ipv4' | 'ipv6'; + +export interface NormalizedEgressHost { + kind: EgressHostKind; + /** Canonical lowercase value (domains without wildcard prefix, IPs / CIDRs normalized). */ + value: string; + /** Prefix length for IP entries (32 / 128 when a single address). */ + prefix?: number; +} + +export interface EgressEvaluationResult { + decision: Exclude; + rule: EgressRule | null; + matchedEntry?: string; + source?: PlatformPolicyLayer | null; +} + +// --------------------------------------------------------------------------- +// IP helpers (browser-safe; no Node `net`) +// --------------------------------------------------------------------------- + +function parseIpv4(raw: string): number[] | null { + const parts = raw.split('.'); + + if (parts.length !== 4) { + return null; + } + + const octets: number[] = []; + + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) { + return null; + } + + const value = Number(part); + + if (value > 255 || (part.length > 1 && part.startsWith('0'))) { + return null; + } + + octets.push(value); + } + + return octets; +} + +/** Parse IPv6 into 8 hextets (supports `::` compression and a trailing dotted IPv4). */ +function parseIpv6(raw: string): number[] | null { + let input = raw.trim().toLowerCase(); + + if (input.startsWith('[') && input.endsWith(']')) { + input = input.slice(1, -1); + } + + if (!input.includes(':') || input.includes('%')) { + return null; + } + + let tailV4: number[] | null = null; + const lastColon = input.lastIndexOf(':'); + const tail = input.slice(lastColon + 1); + + if (tail.includes('.')) { + tailV4 = parseIpv4(tail); + + if (!tailV4) { + return null; + } + + input = `${input.slice(0, lastColon + 1)}0:0`; + } + + const doubleColon = input.split('::'); + + if (doubleColon.length > 2) { + return null; + } + + const parseGroup = (group: string): number[] | null => { + if (!group) { + return []; + } + + const out: number[] = []; + + for (const part of group.split(':')) { + if (!/^[0-9a-f]{1,4}$/.test(part)) { + return null; + } + + out.push(parseInt(part, 16)); + } + + return out; + }; + + const head = parseGroup(doubleColon[0]); + const rest = doubleColon.length === 2 ? parseGroup(doubleColon[1]) : []; + + if (!head || !rest) { + return null; + } + + let hextets: number[]; + + if (doubleColon.length === 2) { + const missing = 8 - head.length - rest.length; + + if (missing < 1) { + return null; + } + + hextets = [...head, ...new Array(missing).fill(0), ...rest]; + } else { + hextets = head; + } + + if (hextets.length !== 8) { + return null; + } + + if (tailV4) { + hextets[6] = (tailV4[0] << 8) | tailV4[1]; + hextets[7] = (tailV4[2] << 8) | tailV4[3]; + } + + return hextets; +} + +function ipv4ToBits(octets: number[]): number[] { + const bits: number[] = []; + + for (const octet of octets) { + for (let i = 7; i >= 0; i--) { + bits.push((octet >> i) & 1); + } + } + + return bits; +} + +function ipv6ToBits(hextets: number[]): number[] { + const bits: number[] = []; + + for (const hextet of hextets) { + for (let i = 15; i >= 0; i--) { + bits.push((hextet >> i) & 1); + } + } + + return bits; +} + +function prefixMatches(addressBits: number[], networkBits: number[], prefix: number): boolean { + for (let i = 0; i < prefix; i++) { + if (addressBits[i] !== networkBits[i]) { + return false; + } + } + + return true; +} + +function formatIpv6(hextets: number[]): string { + // Canonical RFC 5952-ish compression of the longest zero run. + let bestStart = -1; + let bestLength = 0; + + for (let i = 0; i < 8; ) { + if (hextets[i] !== 0) { + i++; + continue; + } + + let j = i; + + while (j < 8 && hextets[j] === 0) { + j++; + } + + if (j - i > bestLength && j - i > 1) { + bestStart = i; + bestLength = j - i; + } + + i = j; + } + + const parts = hextets.map((value) => value.toString(16)); + + if (bestStart === -1) { + return parts.join(':'); + } + + const head = parts.slice(0, bestStart).join(':'); + const tail = parts.slice(bestStart + bestLength).join(':'); + + return `${head}::${tail}`; +} + +interface ParsedIpEntry { + kind: 'ipv4' | 'ipv6'; + bits: number[]; + prefix: number; + canonical: string; +} + +function parseIpOrCidr(raw: string): ParsedIpEntry | null { + const trimmed = raw.trim(); + + if (!trimmed) { + return null; + } + + const slash = trimmed.indexOf('/'); + const address = slash === -1 ? trimmed : trimmed.slice(0, slash); + const prefixRaw = slash === -1 ? null : trimmed.slice(slash + 1); + + const v4 = parseIpv4(address); + + if (v4) { + const prefix = prefixRaw === null ? 32 : /^\d{1,2}$/.test(prefixRaw) ? Number(prefixRaw) : NaN; + + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + return null; + } + + const bits = ipv4ToBits(v4); + // Normalize host bits away so `10.1.2.3/8` becomes `10.0.0.0/8`. + const masked = bits.map((bit, index) => (index < prefix ? bit : 0)); + const octets = [0, 1, 2, 3].map((octet) => + masked.slice(octet * 8, octet * 8 + 8).reduce((acc, bit) => (acc << 1) | bit, 0), + ); + const canonicalAddress = octets.join('.'); + + return { + kind: 'ipv4', + bits: masked, + prefix, + canonical: prefix === 32 ? canonicalAddress : `${canonicalAddress}/${prefix}`, + }; + } + + const v6 = parseIpv6(address); + + if (v6) { + const prefix = prefixRaw === null ? 128 : /^\d{1,3}$/.test(prefixRaw) ? Number(prefixRaw) : NaN; + + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 128) { + return null; + } + + const bits = ipv6ToBits(v6); + const masked = bits.map((bit, index) => (index < prefix ? bit : 0)); + const hextets = [0, 1, 2, 3, 4, 5, 6, 7].map((group) => + masked.slice(group * 16, group * 16 + 16).reduce((acc, bit) => (acc << 1) | bit, 0), + ); + const canonicalAddress = formatIpv6(hextets); + + return { + kind: 'ipv6', + bits: masked, + prefix, + canonical: prefix === 128 ? canonicalAddress : `${canonicalAddress}/${prefix}`, + }; + } + + return null; +} + +const PRIVATE_IPV4_CIDRS = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '224.0.0.0/4', + '240.0.0.0/4', +] as const; + +const PRIVATE_IPV6_CIDRS = ['::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'ff00::/8'] as const; + +/** Reserved / internal ranges blocked by `block_private_networks` (also rendered into the guard). */ +export const EGRESS_PRIVATE_NETWORK_CIDRS: readonly string[] = [...PRIVATE_IPV4_CIDRS, ...PRIVATE_IPV6_CIDRS]; + +const PRIVATE_RANGES: ParsedIpEntry[] = EGRESS_PRIVATE_NETWORK_CIDRS.map( + (cidr) => parseIpOrCidr(cidr) as ParsedIpEntry, +); + +function ipMatchesEntry(address: ParsedIpEntry, entry: ParsedIpEntry): boolean { + return address.kind === entry.kind && prefixMatches(address.bits, entry.bits, entry.prefix); +} + +/** Whether a literal IP address falls into a private / reserved range. */ +export function isPrivateNetworkAddress(host: string): boolean { + const parsed = parseIpOrCidr(stripHostDecorations(host)); + + if (!parsed || parsed.prefix !== (parsed.kind === 'ipv4' ? 32 : 128)) { + return false; + } + + // IPv4-mapped IPv6 (::ffff:a.b.c.d) → evaluate the embedded IPv4 address. + if (parsed.kind === 'ipv6' && parsed.bits.slice(0, 80).every((bit) => bit === 0)) { + const mappedMarker = parsed.bits.slice(80, 96).every((bit) => bit === 1); + + if (mappedMarker) { + const v4Bits = parsed.bits.slice(96); + const octets = [0, 1, 2, 3].map((octet) => + v4Bits.slice(octet * 8, octet * 8 + 8).reduce((acc, bit) => (acc << 1) | bit, 0), + ); + + return isPrivateNetworkAddress(octets.join('.')); + } + } + + return PRIVATE_RANGES.some((range) => ipMatchesEntry(parsed, range)); +} + +function stripHostDecorations(host: string): string { + let value = host.trim().toLowerCase(); + + if (value.startsWith('[') && value.includes(']')) { + value = value.slice(1, value.indexOf(']')); + } + + return value.replace(/\.$/, ''); +} + +/** Whether a host string is an IP literal (v4 / v6, optionally bracketed). */ +export function isIpLiteralHost(host: string): boolean { + const value = stripHostDecorations(host); + + return parseIpv4(value) !== null || parseIpv6(value) !== null; +} + +// --------------------------------------------------------------------------- +// Host entry normalization / matching +// --------------------------------------------------------------------------- + +const DOMAIN_LABEL = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/; + +/** + * Normalize an allow/deny host entry. + * Domains match themselves and every subdomain (`example.com`, `*.example.com` and `.example.com` + * are equivalent). IP literals and CIDRs (v4 / v6) match by address. + * Returns null for invalid input (URLs, ports, paths, whitespace…). + */ +export function normalizeEgressHost(raw: unknown): NormalizedEgressHost | null { + if (typeof raw !== 'string') { + return null; + } + + const trimmed = raw.trim().toLowerCase(); + + if (!trimmed || trimmed.length > EGRESS_MAX_HOST_LENGTH + 2 || /\s/.test(trimmed)) { + return null; + } + + const ip = parseIpOrCidr(trimmed.startsWith('[') ? trimmed.replace(/^\[([^\]]+)\]/, '$1') : trimmed); + + if (ip) { + return { kind: ip.kind, value: ip.canonical, prefix: ip.prefix }; + } + + let domain = trimmed; + + if (domain.startsWith('*.')) { + domain = domain.slice(2); + } else if (domain.startsWith('.')) { + domain = domain.slice(1); + } + + domain = domain.replace(/\.$/, ''); + + if (!domain || domain.length > EGRESS_MAX_HOST_LENGTH) { + return null; + } + + const labels = domain.split('.'); + + if (labels.some((label) => !DOMAIN_LABEL.test(label))) { + return null; + } + + // Bare numeric dotted strings that failed IPv4 parsing (e.g. 999.1.1.1) are not hostnames. + if (labels.every((label) => /^\d+$/.test(label))) { + return null; + } + + return { kind: 'domain', value: domain }; +} + +/** Whether a destination host matches a normalized entry (domain suffix or IP / CIDR). */ +export function egressHostMatchesEntry(host: string, entry: NormalizedEgressHost): boolean { + const target = stripHostDecorations(host); + + if (!target) { + return false; + } + + if (entry.kind === 'domain') { + return target === entry.value || target.endsWith(`.${entry.value}`); + } + + const address = parseIpOrCidr(target); + const network = parseIpOrCidr(entry.value); + + if (!address || !network) { + return false; + } + + return ipMatchesEntry(address, network); +} + +function findMatchingEntry(host: string, entries: readonly EffectiveEgressEntry[]): EffectiveEgressEntry | undefined { + for (const entry of entries) { + const normalized = normalizeEgressHost(entry.value); + + if (normalized && egressHostMatchesEntry(host, normalized)) { + return entry; + } + } + + return undefined; +} + +/** + * Drop entries that are redundant because a broader entry of the same list already covers them + * (Squid rejects / warns on overlapping `dstdomain` values). Keeps first occurrence order. + */ +export function compactEgressHostEntries(entries: readonly T[]): T[] { + const normalized = entries + .map((entry) => ({ entry, host: normalizeEgressHost(entry.value) })) + .filter((item): item is { entry: T; host: NormalizedEgressHost } => item.host !== null); + const result: T[] = []; + const seen = new Set(); + + for (const { entry, host } of normalized) { + if (seen.has(`${host.kind}:${host.value}`)) { + continue; + } + + const coveredByOther = normalized.some( + (other) => + other.host !== host && + other.host.kind === host.kind && + other.host.value !== host.value && + (host.kind === 'domain' + ? host.value.endsWith(`.${other.host.value}`) + : (other.host.prefix ?? 0) < (host.prefix ?? 0) && + egressHostMatchesEntry(host.value.split('/')[0], other.host)), + ); + + if (coveredByOther) { + continue; + } + + seen.add(`${host.kind}:${host.value}`); + result.push({ ...entry, value: host.value }); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +function assertStringList(value: unknown, path: string, max: number): string[] { + if (!Array.isArray(value)) { + throw policyValidationError(`'${path}' must be an array of strings`); + } + + if (value.length > max) { + throw policyValidationError(`'${path}' may contain at most ${max} entries`); + } + + for (const [index, item] of value.entries()) { + if (typeof item !== 'string' || !item.trim()) { + throw policyValidationError(`'${path}/${index}' must be a non-empty string`); + } + } + + return value as string[]; +} + +const PORT_SPEC = /^(\d{1,5})(?:-(\d{1,5}))?$/; + +/** Parse `22` / `1000-2000` into an inclusive range; null when invalid. */ +export function parseEgressPortSpec(spec: string): { from: number; to: number } | null { + const match = PORT_SPEC.exec(spec.trim()); + + if (!match) { + return null; + } + + const from = Number(match[1]); + const to = match[2] !== undefined ? Number(match[2]) : from; + + if (from < 1 || to > 65535 || from > to) { + return null; + } + + return { from, to }; +} + +/** Validate a POSIX/JS-compatible URL pattern used by the guard for plain HTTP URLs. */ +export function validateEgressUrlPattern(pattern: string): string | null { + if (pattern.length > EGRESS_MAX_URL_PATTERN_LENGTH) { + return `pattern exceeds ${EGRESS_MAX_URL_PATTERN_LENGTH} characters`; + } + + // One pattern per ACL file line: whitespace / control characters would split or corrupt it. + // eslint-disable-next-line no-control-regex + if (/[\s\u0000-\u001f\u007f]/.test(pattern)) { + return 'pattern must not contain whitespace or control characters'; + } + + // A leading `#` turns the ACL file line into a comment (the rule would be silently ignored). + if (pattern.startsWith('#')) { + return 'pattern must not start with "#"'; + } + + // The guard uses POSIX extended regular expressions: reject JS-only syntax (backreferences, + // lookarounds, non-capturing / named groups, lazy quantifiers, shorthand classes) that would + // make the guard reject the whole policy bundle. + if (/\\[1-9]|\(\?/.test(pattern)) { + return 'pattern must not use backreferences, lookarounds or special groups'; + } + + if (/[*+?}]\?/.test(pattern.replace(/\\./g, ''))) { + return 'pattern must not use lazy quantifiers'; + } + + if (/\\[dDwWsSbBpPuxck0]/.test(pattern)) { + return 'pattern must not use JavaScript escape classes (use POSIX bracket expressions instead)'; + } + + if (hasNestedQuantifier(pattern)) { + return 'pattern uses nested quantifiers or overlapping repeated alternatives, which risk catastrophic backtracking'; + } + + try { + new RegExp(pattern, 'i'); + } catch { + return 'pattern is not a valid regular expression'; + } + + return null; +} + +/** + * Validate the shape of an `egress` overlay (partial; only present keys are checked). + * @throws OpencodeConfigValidationError + */ +export function assertValidEgressConfig(value: unknown, path = `/${EGRESS_CONFIG_ROOT}`): void { + if (value === undefined) { + return; + } + + if (!isPlainPolicyObject(value)) { + throw policyValidationError(`'${path}' must be an object`); + } + + const allowedKeys = new Set([...EGRESS_SCALAR_KEYS, ...EGRESS_LIST_KEYS]); + const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key)); + + if (unknown.length > 0) { + throw policyValidationError(`Unknown keys at '${path}': ${unknown.join(', ')}`); + } + + if (value['mode'] !== undefined && !(EGRESS_MODES as readonly unknown[]).includes(value['mode'])) { + throw policyValidationError(`'${path}/mode' must be one of: ${EGRESS_MODES.join(', ')}`); + } + + for (const key of ['block_private_networks', 'block_ip_literals', 'log_allowed'] as const) { + if (value[key] !== undefined && typeof value[key] !== 'boolean') { + throw policyValidationError(`'${path}/${key}' must be a boolean`); + } + } + + for (const key of ['allow_hosts', 'deny_hosts'] as const) { + if (value[key] === undefined) { + continue; + } + + const list = assertStringList(value[key], `${path}/${key}`, EGRESS_MAX_HOST_ENTRIES); + + for (const [index, entry] of list.entries()) { + if (!normalizeEgressHost(entry)) { + throw policyValidationError( + `'${path}/${key}/${index}' ('${entry}') must be a hostname (optionally *.prefixed), an IP address or a CIDR`, + ); + } + } + } + + if (value['deny_url_patterns'] !== undefined) { + const list = assertStringList(value['deny_url_patterns'], `${path}/deny_url_patterns`, EGRESS_MAX_URL_PATTERNS); + + for (const [index, pattern] of list.entries()) { + const problem = validateEgressUrlPattern(pattern); + + if (problem) { + throw policyValidationError(`'${path}/deny_url_patterns/${index}': ${problem}`); + } + } + } + + if (value['network_allow'] !== undefined) { + const rules = value['network_allow']; + + if (!Array.isArray(rules)) { + throw policyValidationError(`'${path}/network_allow' must be an array`); + } + + if (rules.length > EGRESS_MAX_NETWORK_RULES) { + throw policyValidationError(`'${path}/network_allow' may contain at most ${EGRESS_MAX_NETWORK_RULES} rules`); + } + + for (const [index, rule] of rules.entries()) { + const rulePath = `${path}/network_allow/${index}`; + + if (!isPlainPolicyObject(rule)) { + throw policyValidationError(`'${rulePath}' must be an object`); + } + + const ruleKeys = Object.keys(rule).filter( + (key) => !['protocol', 'ports', 'destinations', 'description'].includes(key), + ); + + if (ruleKeys.length > 0) { + throw policyValidationError(`Unknown keys at '${rulePath}': ${ruleKeys.join(', ')}`); + } + + if (!(EGRESS_NETWORK_PROTOCOLS as readonly unknown[]).includes(rule['protocol'])) { + throw policyValidationError(`'${rulePath}/protocol' must be one of: ${EGRESS_NETWORK_PROTOCOLS.join(', ')}`); + } + + if (typeof rule['ports'] !== 'string' || !parseEgressPortSpec(rule['ports'])) { + throw policyValidationError(`'${rulePath}/ports' must be a port (e.g. 22) or a range (e.g. 1000-2000)`); + } + + const destinations = assertStringList( + rule['destinations'], + `${rulePath}/destinations`, + EGRESS_MAX_NETWORK_DESTINATIONS, + ); + + if (destinations.length === 0) { + throw policyValidationError(`'${rulePath}/destinations' must contain at least one IP address or CIDR`); + } + + for (const [destinationIndex, destination] of destinations.entries()) { + const normalized = normalizeEgressHost(destination); + + if (!normalized || normalized.kind === 'domain') { + throw policyValidationError( + `'${rulePath}/destinations/${destinationIndex}' ('${destination}') must be an IP address or CIDR`, + ); + } + } + + if ( + rule['description'] !== undefined && + (typeof rule['description'] !== 'string' || rule['description'].length > 200) + ) { + throw policyValidationError(`'${rulePath}/description' must be a string of at most 200 characters`); + } + } + } +} + +// --------------------------------------------------------------------------- +// Merge (cross-layer + within-layer compose) +// --------------------------------------------------------------------------- + +/** + * Merge two `egress` overlays (left = lower precedence, right = higher precedence): + * scalars from `right` win, lists concatenate (deduplicated by JSON identity). + */ +export function mergeEgressOverlays(left: unknown, right: unknown): JsonObject { + const base = isPlainPolicyObject(left) ? left : {}; + const overlay = isPlainPolicyObject(right) ? right : {}; + const result: JsonObject = {}; + + for (const key of EGRESS_SCALAR_KEYS) { + if (overlay[key] !== undefined) { + result[key] = overlay[key]; + } else if (base[key] !== undefined) { + result[key] = base[key]; + } + } + + for (const key of EGRESS_LIST_KEYS) { + const leftList = Array.isArray(base[key]) ? (base[key] as unknown[]) : []; + const rightList = Array.isArray(overlay[key]) ? (overlay[key] as unknown[]) : []; + + if (!Array.isArray(base[key]) && !Array.isArray(overlay[key])) { + continue; + } + + const seen = new Set(); + const merged: unknown[] = []; + + for (const item of [...leftList, ...rightList]) { + const serialized = JSON.stringify(item); + + if (seen.has(serialized)) { + continue; + } + + seen.add(serialized); + merged.push(structuredClone(item)); + } + + result[key] = merged; + } + + // Preserve unknown keys verbatim so validation can report them instead of silently dropping data. + for (const [key, value] of Object.entries(base)) { + if (!(key in result) && !(EGRESS_SCALAR_KEYS as readonly string[]).includes(key)) { + result[key] = structuredClone(value); + } + } + + for (const [key, value] of Object.entries(overlay)) { + if ( + !(EGRESS_SCALAR_KEYS as readonly string[]).includes(key) && + !(EGRESS_LIST_KEYS as readonly string[]).includes(key) + ) { + result[key] = structuredClone(value); + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Effective resolution +// --------------------------------------------------------------------------- + +function readEgressOverlay(overlay: PlatformPolicyLayerInput['overlay']): JsonObject { + if (!isPlainPolicyObject(overlay)) { + return {}; + } + + const egress = overlay[EGRESS_CONFIG_ROOT]; + + return isPlainPolicyObject(egress) ? egress : {}; +} + +function pushUniqueEntries( + target: EffectiveEgressEntry[], + values: unknown, + source: PlatformPolicyLayer, + normalize: (raw: string) => string | null, +): void { + if (!Array.isArray(values)) { + return; + } + + for (const raw of values) { + if (typeof raw !== 'string') { + continue; + } + + const value = normalize(raw); + + if (!value || target.some((entry) => entry.value === value)) { + continue; + } + + target.push({ value, source }); + } +} + +/** Whether `value` (domain / IP / CIDR) is fully covered by one of the `bounds` entries. */ +function egressEntryCoveredBy(value: string, bounds: readonly EffectiveEgressEntry[]): boolean { + const child = normalizeEgressHost(value); + + if (!child) { + return false; + } + + return bounds.some((bound) => { + const parent = normalizeEgressHost(bound.value); + + if (!parent) { + return false; + } + + if (child.kind === 'domain' || parent.kind === 'domain') { + return child.kind === 'domain' && parent.kind === 'domain' && egressHostMatchesEntry(child.value, parent); + } + + const childNet = parseIpOrCidr(child.value); + const parentNet = parseIpOrCidr(parent.value); + + return ( + !!childNet && + !!parentNet && + childNet.kind === parentNet.kind && + childNet.prefix >= parentNet.prefix && + prefixMatches(childNet.bits, parentNet.bits, parentNet.prefix) + ); + }); +} + +/** Whether a network exception is fully covered by one of the `bounds` rules (protocol, ports, destinations). */ +function networkRuleCoveredBy( + rule: EffectiveEgressNetworkRule, + bounds: readonly EffectiveEgressNetworkRule[], +): boolean { + const ports = parseEgressPortSpec(rule.ports); + + return bounds.some((bound) => { + const boundPorts = parseEgressPortSpec(bound.ports); + + return ( + bound.protocol === rule.protocol && + !!ports && + !!boundPorts && + ports.from >= boundPorts.from && + ports.to <= boundPorts.to && + rule.destinations.every((destination) => + egressEntryCoveredBy( + destination, + bound.destinations.map((value) => ({ value, source: bound.source })), + ), + ) + ); + }); +} + +/** + * Resolve the effective egress policy from layer overlays. + * Precedence: global → workspace → environment (scalars: first defined wins; lists: union with provenance). + * Allow lists are bounded by higher layers: once a higher layer declares `allow_hosts` (or + * `network_allow`), lower-layer allow entries are only kept when a higher-layer entry covers them, + * so workspaces / environments can narrow but never widen an inherited allowlist. + */ +export function resolveEffectiveEgressPolicy(layers: readonly PlatformPolicyLayerInput[]): EffectiveEgressPolicy { + const ordered = orderPolicyLayersHighToLow(layers); + const policy: EffectiveEgressPolicy = { + mode: EGRESS_DEFAULTS.mode, + allowHosts: [], + denyHosts: [], + denyUrlPatterns: [], + networkAllow: [], + blockPrivateNetworks: EGRESS_DEFAULTS.blockPrivateNetworks, + blockIpLiterals: EGRESS_DEFAULTS.blockIpLiterals, + logAllowed: EGRESS_DEFAULTS.logAllowed, + scalarSources: { mode: null, blockPrivateNetworks: null, blockIpLiterals: null, logAllowed: null }, + }; + const scalarMap = [ + ['mode', 'mode'], + ['block_private_networks', 'blockPrivateNetworks'], + ['block_ip_literals', 'blockIpLiterals'], + ['log_allowed', 'logAllowed'], + ] as const; + + for (const layer of ordered) { + const egress = readEgressOverlay(layer.overlay); + const higherAllowHosts = policy.allowHosts.filter((entry) => entry.source !== layer.source); + const higherNetworkAllow = policy.networkAllow.filter((rule) => rule.source !== layer.source); + + for (const [configKey, policyKey] of scalarMap) { + if (policy.scalarSources[policyKey] !== null || egress[configKey] === undefined) { + continue; + } + + const value = egress[configKey]; + + if (policyKey === 'mode') { + if ((EGRESS_MODES as readonly unknown[]).includes(value)) { + policy.mode = value as EgressMode; + policy.scalarSources.mode = layer.source; + } + } else if (typeof value === 'boolean') { + policy[policyKey] = value; + policy.scalarSources[policyKey] = layer.source; + } + } + + pushUniqueEntries(policy.allowHosts, egress['allow_hosts'], layer.source, (raw) => { + const value = normalizeEgressHost(raw)?.value ?? null; + + return value && (higherAllowHosts.length === 0 || egressEntryCoveredBy(value, higherAllowHosts)) ? value : null; + }); + pushUniqueEntries( + policy.denyHosts, + egress['deny_hosts'], + layer.source, + (raw) => normalizeEgressHost(raw)?.value ?? null, + ); + pushUniqueEntries(policy.denyUrlPatterns, egress['deny_url_patterns'], layer.source, (raw) => + validateEgressUrlPattern(raw) === null ? raw : null, + ); + + if (Array.isArray(egress['network_allow'])) { + for (const rule of egress['network_allow']) { + if (!isPlainPolicyObject(rule)) { + continue; + } + + const protocol = rule['protocol']; + const ports = typeof rule['ports'] === 'string' ? rule['ports'].trim() : ''; + const destinations = Array.isArray(rule['destinations']) + ? (rule['destinations'] as unknown[]) + .map((destination) => normalizeEgressHost(destination)) + .filter( + (destination): destination is NormalizedEgressHost => !!destination && destination.kind !== 'domain', + ) + .map((destination) => destination.value) + : []; + + if ( + !(EGRESS_NETWORK_PROTOCOLS as readonly unknown[]).includes(protocol) || + !parseEgressPortSpec(ports) || + destinations.length === 0 + ) { + continue; + } + + const candidate: EffectiveEgressNetworkRule = { + protocol: protocol as EgressNetworkProtocol, + ports, + destinations: [...new Set(destinations)], + ...(typeof rule['description'] === 'string' && rule['description'].trim() + ? { description: rule['description'].trim() } + : {}), + source: layer.source, + }; + const identity = JSON.stringify([candidate.protocol, candidate.ports, candidate.destinations]); + + if (higherNetworkAllow.length > 0 && !networkRuleCoveredBy(candidate, higherNetworkAllow)) { + continue; + } + + if ( + policy.networkAllow.some( + (existing) => JSON.stringify([existing.protocol, existing.ports, existing.destinations]) === identity, + ) + ) { + continue; + } + + policy.networkAllow.push(candidate); + } + } + } + + return policy; +} + +const POLICY_LAYER_VALUES = new Set(['global', 'workspace', 'environment']); + +function normalizeSource(raw: unknown): PlatformPolicyLayer { + return typeof raw === 'string' && POLICY_LAYER_VALUES.has(raw) ? (raw as PlatformPolicyLayer) : 'global'; +} + +function normalizeNullableSource(raw: unknown): PlatformPolicyLayer | null { + return typeof raw === 'string' && POLICY_LAYER_VALUES.has(raw) ? (raw as PlatformPolicyLayer) : null; +} + +/** + * Defensive normalization of an effective policy received over the wire (agent manager boundary). + * Every entry is re-validated; invalid values are dropped so nothing unvalidated reaches the guard's + * Squid / dnsmasq / netfilter configuration. Returns the `off` default for non-objects. + */ +export function normalizeEffectiveEgressPolicy(raw: unknown): EffectiveEgressPolicy { + const fallback = resolveEffectiveEgressPolicy([]); + + if (!isPlainPolicyObject(raw)) { + return fallback; + } + + const hostEntries = (value: unknown): EffectiveEgressEntry[] => { + if (!Array.isArray(value)) { + return []; + } + + const out: EffectiveEgressEntry[] = []; + + for (const item of value.slice(0, EGRESS_MAX_HOST_ENTRIES * 3)) { + if (!isPlainPolicyObject(item)) { + continue; + } + + const normalized = normalizeEgressHost(item['value']); + + if (normalized && !out.some((entry) => entry.value === normalized.value)) { + out.push({ value: normalized.value, source: normalizeSource(item['source']) }); + } + } + + return out; + }; + + const patterns = Array.isArray(raw['denyUrlPatterns']) + ? (raw['denyUrlPatterns'] as unknown[]) + .slice(0, EGRESS_MAX_URL_PATTERNS * 3) + .filter( + (item): item is JsonObject => + isPlainPolicyObject(item) && + typeof item['value'] === 'string' && + validateEgressUrlPattern(item['value'] as string) === null, + ) + .map((item) => ({ value: item['value'] as string, source: normalizeSource(item['source']) })) + : []; + + const networkAllow: EffectiveEgressNetworkRule[] = []; + + if (Array.isArray(raw['networkAllow'])) { + for (const item of (raw['networkAllow'] as unknown[]).slice(0, EGRESS_MAX_NETWORK_RULES * 3)) { + if (!isPlainPolicyObject(item)) { + continue; + } + + const protocol = item['protocol']; + const ports = typeof item['ports'] === 'string' ? item['ports'].trim() : ''; + const destinations = Array.isArray(item['destinations']) + ? (item['destinations'] as unknown[]) + .slice(0, EGRESS_MAX_NETWORK_DESTINATIONS) + .map((destination) => normalizeEgressHost(destination)) + .filter( + (destination): destination is NormalizedEgressHost => !!destination && destination.kind !== 'domain', + ) + .map((destination) => destination.value) + : []; + + if ( + !(EGRESS_NETWORK_PROTOCOLS as readonly unknown[]).includes(protocol) || + !parseEgressPortSpec(ports) || + destinations.length === 0 + ) { + continue; + } + + networkAllow.push({ + protocol: protocol as EgressNetworkProtocol, + ports, + destinations: [...new Set(destinations)], + ...(typeof item['description'] === 'string' && item['description'].trim() + ? { description: item['description'].trim().slice(0, 200) } + : {}), + source: normalizeSource(item['source']), + }); + } + } + + const sources = isPlainPolicyObject(raw['scalarSources']) ? raw['scalarSources'] : {}; + + return { + mode: (EGRESS_MODES as readonly unknown[]).includes(raw['mode']) ? (raw['mode'] as EgressMode) : fallback.mode, + allowHosts: hostEntries(raw['allowHosts']), + denyHosts: hostEntries(raw['denyHosts']), + denyUrlPatterns: patterns, + networkAllow, + blockPrivateNetworks: + typeof raw['blockPrivateNetworks'] === 'boolean' ? raw['blockPrivateNetworks'] : fallback.blockPrivateNetworks, + blockIpLiterals: typeof raw['blockIpLiterals'] === 'boolean' ? raw['blockIpLiterals'] : fallback.blockIpLiterals, + logAllowed: typeof raw['logAllowed'] === 'boolean' ? raw['logAllowed'] : fallback.logAllowed, + scalarSources: { + mode: normalizeNullableSource(sources['mode']), + blockPrivateNetworks: normalizeNullableSource(sources['blockPrivateNetworks']), + blockIpLiterals: normalizeNullableSource(sources['blockIpLiterals']), + logAllowed: normalizeNullableSource(sources['logAllowed']), + }, + }; +} + +/** Whether the guard must be present / enforcing for this policy. */ +export function egressPolicyRequiresGuard(policy: Pick | null | undefined): boolean { + return !!policy && policy.mode !== 'off'; +} + +/** + * Evaluate a destination against the effective policy (same precedence as the guard): + * offline → deny list → private networks → IP literals → URL patterns (plain HTTP) → allowlist. + * Denylist wins over the allowlist; an empty allowlist means unrestricted. Private-network and + * IP-literal blocks are lifted for destinations matching an explicit allow entry. + */ +export function evaluateEgressDestination( + policy: EffectiveEgressPolicy, + host: string, + options: { url?: string; scheme?: 'http' | 'https' } = {}, +): EgressEvaluationResult { + if (policy.mode === 'off') { + return { decision: 'allowed', rule: null }; + } + + const target = stripHostDecorations(host); + const violation = ((): Omit | null => { + if (policy.mode === 'offline') { + return { rule: 'offline', source: policy.scalarSources.mode }; + } + + const denied = findMatchingEntry(target, policy.denyHosts); + + if (denied) { + return { rule: 'deny_host', matchedEntry: denied.value, source: denied.source }; + } + + const allowedExplicitly = findMatchingEntry(target, policy.allowHosts); + + // Explicit allow entries (e.g. an internal Git server CIDR) are the documented exception path. + if (policy.blockPrivateNetworks && isPrivateNetworkAddress(target) && !allowedExplicitly) { + return { rule: 'private_network', source: policy.scalarSources.blockPrivateNetworks }; + } + + if (policy.blockIpLiterals && isIpLiteralHost(target) && !allowedExplicitly) { + return { rule: 'ip_literal', source: policy.scalarSources.blockIpLiterals }; + } + + if (options.url && options.scheme !== 'https') { + for (const pattern of policy.denyUrlPatterns) { + // Defense in depth for policies stored before validation tightened. + if (validateEgressUrlPattern(pattern.value) !== null) { + continue; + } + + try { + if (new RegExp(pattern.value, 'i').test(options.url)) { + return { rule: 'deny_url', matchedEntry: pattern.value, source: pattern.source }; + } + } catch { + // Invalid patterns are rejected on write; ignore defensively. + } + } + } + + if (policy.allowHosts.length > 0 && !allowedExplicitly) { + return { rule: 'not_allowlisted' }; + } + + return null; + })(); + + if (!violation) { + return { decision: 'allowed', rule: null }; + } + + return { decision: policy.mode === 'monitor' ? 'observed' : 'blocked', ...violation }; +} + +/** Explain which policy entry is responsible for a guard-reported rule on a host (best-effort provenance). */ +export function explainEgressRule( + policy: EffectiveEgressPolicy, + rule: EgressRule | null, + host: string | null | undefined, +): { matchedEntry?: string; source?: PlatformPolicyLayer | null } { + if (!rule) { + return {}; + } + + if (rule === 'deny_host' && host) { + const entry = findMatchingEntry(host, policy.denyHosts); + + return entry ? { matchedEntry: entry.value, source: entry.source } : {}; + } + + if (rule === 'offline') { + return { source: policy.scalarSources.mode }; + } + + if (rule === 'private_network') { + return { source: policy.scalarSources.blockPrivateNetworks }; + } + + if (rule === 'ip_literal') { + return { source: policy.scalarSources.blockIpLiterals }; + } + + return {}; +} + +/** Strip query strings and fragments from URLs before they are persisted (tokens / secrets). */ +export function redactEgressUrl(url: string | null | undefined, maxLength = 512): string | null { + if (!url) { + return null; + } + + const withoutQuery = url.split(/[?#]/, 1)[0] ?? ''; + // Drop userinfo (user:password@) when present. + const withoutUserInfo = withoutQuery.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1'); + + return withoutUserInfo.length > maxLength ? withoutUserInfo.slice(0, maxLength) : withoutUserInfo; +} diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/heredity.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/heredity.ts index 54396160e..188f6a75d 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/heredity.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/heredity.ts @@ -5,6 +5,7 @@ import { type JsonObject, } from './types'; import { findForbiddenV1RootKeys } from './migrate-v1-to-v2'; +import { assertValidPlatformSecurityConfig } from './platform-security'; const ALLOWED_INHERITED_OVERRIDE = new Set(INHERITED_MAP_ENTRY_OVERRIDE_KEYS); @@ -85,6 +86,7 @@ export function assertOverlayRespectsHeredity( ); } + assertValidPlatformSecurityConfig(overlay); assertNoLockedWrites(overlay, lockedPaths); for (const entry of inheritedAdditive) { diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/locks.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/locks.ts index 801a83af9..b77c0484a 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/locks.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/locks.ts @@ -15,6 +15,8 @@ export const AGENT_CONFIG_TAB_IDS = [ 'agents', 'references', 'permissions', + 'egress', + 'message_filters', 'websearch', 'compaction', 'formatters', @@ -52,6 +54,18 @@ export const AGENT_CONFIG_TAB_LOCK_PATHS: Record): J continue; } + if (key === EGRESS_CONFIG_ROOT) { + next[key] = mergeEgressOverlays(result[key], value); + continue; + } + if (key === 'formatter' && isPlainObject(value) && isPlainObject(result[key])) { next[key] = mergeMapRoot(result[key] as JsonObject, value); continue; @@ -309,6 +316,28 @@ export function computeHeredityMetadata(...parents: Array 0) { + const entry = ensureAdditive(pointer(`${EGRESS_CONFIG_ROOT}/${list}`)); + entry.items = [...(entry.items ?? []), ...(egress[list] as unknown[])]; + } + } + } + + continue; + } + // `formatter: false` fully disables formatters (replace lock); object maps merge additively. if (key === 'formatter') { if (normalized[key] === false) { diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/message-filters.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/message-filters.ts new file mode 100644 index 000000000..392eb0da2 --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/message-filters.ts @@ -0,0 +1,517 @@ +import { + MESSAGE_FILTERS_CONFIG_ROOT, + isPlainPolicyObject, + orderPolicyLayersHighToLow, + policyValidationError, + type PlatformPolicyLayer, + type PlatformPolicyLayerInput, +} from './platform-policy'; + +/** + * Chat message filter rules (regex) stored per configuration layer under `message_filters`. + * + * Precedence mirrors allow/deny lists: the most restrictive outcome wins. + * - any matching `drop` rule drops the message (first match by layer precedence is reported) + * - otherwise every matching `replace` rule rewrites the message in precedence order + * - otherwise any matching `flag` rule flags the message (content unchanged) + * Layers are evaluated global → workspace → environment; lower layers may append rules but + * never remove or edit inherited ones. + */ +export const MESSAGE_FILTER_ACTIONS = ['drop', 'replace', 'flag'] as const; +export type MessageFilterAction = (typeof MESSAGE_FILTER_ACTIONS)[number]; + +export const MESSAGE_FILTER_DIRECTIONS = ['incoming', 'outgoing', 'bidirectional'] as const; +export type MessageFilterDirection = (typeof MESSAGE_FILTER_DIRECTIONS)[number]; + +export const MESSAGE_FILTER_MAX_RULES_PER_LAYER = 200; +export const MESSAGE_FILTER_MAX_PATTERN_LENGTH = 2000; +export const MESSAGE_FILTER_MAX_REPLACEMENT_LENGTH = 2000; +export const MESSAGE_FILTER_MAX_NAME_LENGTH = 120; +export const MESSAGE_FILTER_ALLOWED_FLAGS = ['g', 'i', 'm', 's', 'u', 'y', 'd'] as const; + +/** Stored (snake_case-free; flat) rule shape inside `message_filters`. */ +export interface MessageFilterRuleConfig { + name: string; + pattern: string; + flags?: string; + direction?: MessageFilterDirection; + action: MessageFilterAction; + replacement?: string; + enabled?: boolean; +} + +export interface EffectiveMessageFilterRule { + name: string; + pattern: string; + flags: string; + direction: MessageFilterDirection; + action: MessageFilterAction; + replacement?: string; + enabled: boolean; + source: PlatformPolicyLayer; +} + +export interface MessageFilterEvaluation { + status: 'allowed' | 'filtered' | 'dropped'; + /** Rule reported as the cause (drop rule, first replace rule, or first flag rule). */ + matchedRule?: EffectiveMessageFilterRule; + /** Rewritten content when one or more replace rules matched. */ + modifiedMessage?: string; + /** All rules that matched, in evaluation order. */ + matchedRules: EffectiveMessageFilterRule[]; +} + +/** + * Normalize and validate RegExp flags (subset allowed by JS). Defaults to `g`. + * @throws OpencodeConfigValidationError + */ +export function normalizeMessageFilterFlags(flags: string | undefined | null): string { + const raw = (flags ?? 'g').trim(); + + if (raw.length > MESSAGE_FILTER_ALLOWED_FLAGS.length) { + throw policyValidationError('Regex flags are too long'); + } + + const seen = new Set(); + let out = ''; + + for (const char of raw) { + if (!(MESSAGE_FILTER_ALLOWED_FLAGS as readonly string[]).includes(char)) { + throw policyValidationError(`Invalid regex flag character: ${char}`); + } + + if (!seen.has(char)) { + seen.add(char); + out += char; + } + } + + return out; +} + +/** Index of the first body character of a group opened at `index` (skips `?:`, `?=`, `?`, …). */ +function groupBodyStart(pattern: string, index: number): number { + if (pattern[index + 1] !== '?') { + return index + 1; + } + + const next = pattern[index + 2]; + + if (next === ':' || next === '=' || next === '!') { + return index + 3; + } + + if (next === '<') { + const after = pattern[index + 3]; + + if (after === '=' || after === '!') { + return index + 4; + } + + const close = pattern.indexOf('>', index + 3); + + return close === -1 ? index + 3 : close + 1; + } + + return index + 2; +} + +/** + * Whether the alternatives of a repeated group can match the same input, e.g. `(a|aa)+` or + * `(\w|x)*`. Only branches starting with distinct literal characters are considered safe. + */ +function hasAmbiguousAlternation(pattern: string, branchStarts: readonly number[]): boolean { + if (branchStarts.length < 2) { + return false; + } + + const seen = new Set(); + + for (const start of branchStarts) { + const char = pattern[start]; + + if (char === undefined || '|)\\[(.^$*+?{'.includes(char)) { + return true; + } + + const key = char.toLowerCase(); + + if (seen.has(key)) { + return true; + } + + seen.add(key); + } + + return false; +} + +/** + * Heuristic guard against catastrophic backtracking (ReDoS): rejects a quantified group whose + * body is itself quantified, e.g. `(a+)+`, `(\w*)*`, `(x{1,9})+`, and repeated groups with + * overlapping alternatives, e.g. `(a|aa)+`. Workspace managers can author rules, and rules run + * synchronously on every chat message in the agent manager (and on egress evaluation requests). + */ +export function hasNestedQuantifier(pattern: string): boolean { + const stack: Array<{ quantified: boolean; branchStarts: number[] }> = []; + let escaped = false; + let inClass = false; + + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (inClass) { + if (char === ']') { + inClass = false; + } + + continue; + } + + if (char === '[') { + inClass = true; + continue; + } + + if (char === '(') { + stack.push({ quantified: false, branchStarts: [groupBodyStart(pattern, index)] }); + continue; + } + + if (char === '|') { + stack[stack.length - 1]?.branchStarts.push(index + 1); + continue; + } + + const isQuantifier = char === '+' || char === '*' || char === '{' || (char === '?' && pattern[index - 1] !== '('); + + if (char === ')') { + const group = stack.pop(); + const next = pattern[index + 1]; + const groupQuantified = next === '+' || next === '*' || next === '{' || next === '?'; + + if (group?.quantified && groupQuantified && next !== '?') { + return true; + } + + if (group && groupQuantified && next !== '?' && hasAmbiguousAlternation(pattern, group.branchStarts)) { + return true; + } + + if (group?.quantified && stack.length > 0) { + stack[stack.length - 1].quantified = true; + } + + if (groupQuantified && stack.length > 0) { + stack[stack.length - 1].quantified = true; + } + + continue; + } + + if (isQuantifier && stack.length > 0 && char !== '?') { + stack[stack.length - 1].quantified = true; + } + } + + return false; +} + +/** + * Compile a rule pattern, throwing a validation error for invalid / unsafe regexes. + * @throws OpencodeConfigValidationError + */ +export function compileMessageFilterPattern(pattern: string, flags: string): RegExp { + if (pattern.length > MESSAGE_FILTER_MAX_PATTERN_LENGTH) { + throw policyValidationError(`Pattern exceeds ${MESSAGE_FILTER_MAX_PATTERN_LENGTH} characters`); + } + + if (hasNestedQuantifier(pattern)) { + throw policyValidationError( + 'Pattern uses nested quantifiers or overlapping repeated alternatives (e.g. "(a+)+", "(a|aa)+"), which risk catastrophic backtracking', + ); + } + + try { + return new RegExp(pattern, flags); + } catch { + throw policyValidationError('Invalid regular expression'); + } +} + +/** + * Validate a layer's `message_filters` value. + * @throws OpencodeConfigValidationError + */ +export function assertValidMessageFilters(value: unknown, path = `/${MESSAGE_FILTERS_CONFIG_ROOT}`): void { + if (value === undefined) { + return; + } + + if (!Array.isArray(value)) { + throw policyValidationError(`'${path}' must be an array of rules`); + } + + if (value.length > MESSAGE_FILTER_MAX_RULES_PER_LAYER) { + throw policyValidationError(`'${path}' may contain at most ${MESSAGE_FILTER_MAX_RULES_PER_LAYER} rules`); + } + + const names = new Set(); + + for (const [index, rule] of value.entries()) { + const rulePath = `${path}/${index}`; + + if (!isPlainPolicyObject(rule)) { + throw policyValidationError(`'${rulePath}' must be an object`); + } + + const unknown = Object.keys(rule).filter( + (key) => !['name', 'pattern', 'flags', 'direction', 'action', 'replacement', 'enabled'].includes(key), + ); + + if (unknown.length > 0) { + throw policyValidationError(`Unknown keys at '${rulePath}': ${unknown.join(', ')}`); + } + + const name = rule['name']; + + if (typeof name !== 'string' || !name.trim() || name.length > MESSAGE_FILTER_MAX_NAME_LENGTH) { + throw policyValidationError( + `'${rulePath}/name' must be a non-empty string of at most ${MESSAGE_FILTER_MAX_NAME_LENGTH} characters`, + ); + } + + if (names.has(name.trim())) { + throw policyValidationError(`'${rulePath}/name' duplicates another rule name in this layer`); + } + + names.add(name.trim()); + + if (typeof rule['pattern'] !== 'string' || !rule['pattern']) { + throw policyValidationError(`'${rulePath}/pattern' must be a non-empty string`); + } + + if (rule['flags'] !== undefined && typeof rule['flags'] !== 'string') { + throw policyValidationError(`'${rulePath}/flags' must be a string`); + } + + try { + compileMessageFilterPattern(rule['pattern'], normalizeMessageFilterFlags(rule['flags'] as string | undefined)); + } catch (error) { + throw policyValidationError(`'${rulePath}': ${(error as Error).message}`); + } + + if ( + rule['direction'] !== undefined && + !(MESSAGE_FILTER_DIRECTIONS as readonly unknown[]).includes(rule['direction']) + ) { + throw policyValidationError(`'${rulePath}/direction' must be one of: ${MESSAGE_FILTER_DIRECTIONS.join(', ')}`); + } + + if (!(MESSAGE_FILTER_ACTIONS as readonly unknown[]).includes(rule['action'])) { + throw policyValidationError(`'${rulePath}/action' must be one of: ${MESSAGE_FILTER_ACTIONS.join(', ')}`); + } + + if (rule['action'] === 'replace' && typeof rule['replacement'] !== 'string') { + throw policyValidationError(`'${rulePath}/replacement' is required when action is replace`); + } + + if ( + rule['replacement'] !== undefined && + (typeof rule['replacement'] !== 'string' || rule['replacement'].length > MESSAGE_FILTER_MAX_REPLACEMENT_LENGTH) + ) { + throw policyValidationError( + `'${rulePath}/replacement' must be a string of at most ${MESSAGE_FILTER_MAX_REPLACEMENT_LENGTH} characters`, + ); + } + + if (rule['enabled'] !== undefined && typeof rule['enabled'] !== 'boolean') { + throw policyValidationError(`'${rulePath}/enabled' must be a boolean`); + } + } +} + +/** + * Resolve effective rules (global → workspace → environment). Invalid rules are skipped + * defensively (writes are validated, but legacy / poisoned rows must not break chat). + */ +export function resolveEffectiveMessageFilters( + layers: readonly PlatformPolicyLayerInput[], +): EffectiveMessageFilterRule[] { + const rules: EffectiveMessageFilterRule[] = []; + + for (const layer of orderPolicyLayersHighToLow(layers)) { + const overlay = isPlainPolicyObject(layer.overlay) ? layer.overlay : {}; + const list = overlay[MESSAGE_FILTERS_CONFIG_ROOT]; + + if (!Array.isArray(list)) { + continue; + } + + for (const raw of list) { + if (!isPlainPolicyObject(raw)) { + continue; + } + + try { + assertValidMessageFilters([raw]); + } catch { + continue; + } + + const rule = raw as unknown as MessageFilterRuleConfig; + const flags = normalizeMessageFilterFlags(rule.flags); + const effective: EffectiveMessageFilterRule = { + name: rule.name.trim(), + pattern: rule.pattern, + flags, + direction: rule.direction ?? 'bidirectional', + action: rule.action, + enabled: rule.enabled !== false, + source: layer.source, + ...(rule.action === 'replace' ? { replacement: rule.replacement ?? '' } : {}), + }; + const duplicate = rules.some( + (existing) => + existing.pattern === effective.pattern && + existing.flags === effective.flags && + existing.direction === effective.direction && + existing.action === effective.action && + existing.replacement === effective.replacement, + ); + + if (!duplicate) { + rules.push(effective); + } + } + } + + return rules; +} + +/** + * Defensive normalization of effective rules received over the wire (agent manager boundary): + * re-validates every rule, drops invalid / unsafe ones, keeps order. + */ +export function normalizeEffectiveMessageFilters(raw: unknown): EffectiveMessageFilterRule[] { + if (!Array.isArray(raw)) { + return []; + } + + const rules: EffectiveMessageFilterRule[] = []; + const layers = new Set(['global', 'workspace', 'environment']); + + for (const item of raw.slice(0, MESSAGE_FILTER_MAX_RULES_PER_LAYER * 3)) { + if (!isPlainPolicyObject(item)) { + continue; + } + + const candidate = { + name: item['name'], + pattern: item['pattern'], + flags: item['flags'], + direction: item['direction'], + action: item['action'], + ...(item['replacement'] !== undefined ? { replacement: item['replacement'] } : {}), + enabled: item['enabled'], + }; + + try { + assertValidMessageFilters([candidate]); + } catch { + continue; + } + + rules.push({ + name: String(candidate.name).trim(), + pattern: candidate.pattern as string, + flags: normalizeMessageFilterFlags(candidate.flags as string | undefined), + direction: (candidate.direction as MessageFilterDirection | undefined) ?? 'bidirectional', + action: candidate.action as MessageFilterAction, + ...(candidate.action === 'replace' ? { replacement: (candidate.replacement as string | undefined) ?? '' } : {}), + enabled: candidate.enabled !== false, + source: + typeof item['source'] === 'string' && layers.has(item['source']) + ? (item['source'] as PlatformPolicyLayer) + : 'global', + }); + } + + return rules; +} + +function appliesToDirection(rule: EffectiveMessageFilterRule, direction: 'incoming' | 'outgoing'): boolean { + return rule.direction === 'bidirectional' || rule.direction === direction; +} + +function matches(rule: EffectiveMessageFilterRule, message: string): boolean { + // Fresh RegExp per test: global / sticky flags keep lastIndex state between calls. + return compileMessageFilterPattern(rule.pattern, rule.flags).test(message); +} + +/** + * Evaluate effective rules for one message. Most restrictive outcome wins (drop > replace > flag). + */ +export function evaluateMessageFilters( + rules: readonly EffectiveMessageFilterRule[], + message: string, + direction: 'incoming' | 'outgoing', +): MessageFilterEvaluation { + const applicable = rules.filter((rule) => rule.enabled && appliesToDirection(rule, direction)); + const matchedRules: EffectiveMessageFilterRule[] = []; + + for (const rule of applicable) { + try { + if (matches(rule, message)) { + matchedRules.push(rule); + } + } catch { + // Invalid rules never break chat; they are rejected on write. + } + } + + const drop = matchedRules.find((rule) => rule.action === 'drop'); + + if (drop) { + return { status: 'dropped', matchedRule: drop, matchedRules }; + } + + const replacements = matchedRules.filter((rule) => rule.action === 'replace'); + + if (replacements.length > 0) { + let modified = message; + + for (const rule of replacements) { + try { + modified = modified.replace(compileMessageFilterPattern(rule.pattern, rule.flags), rule.replacement ?? ''); + } catch { + // skip + } + } + + return { status: 'filtered', matchedRule: replacements[0], modifiedMessage: modified, matchedRules }; + } + + const flag = matchedRules.find((rule) => rule.action === 'flag'); + + if (flag) { + return { status: 'filtered', matchedRule: flag, matchedRules }; + } + + return { status: 'allowed', matchedRules }; +} + +/** Human-readable reason for a matched rule (used in chat drop notices and audit records). */ +export function describeMessageFilterRule(rule: Pick): string { + return `${rule.name} (${rule.source})`; +} diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-policy.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-policy.ts new file mode 100644 index 000000000..708b158d6 --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-policy.ts @@ -0,0 +1,36 @@ +import { OpencodeConfigValidationError, type JsonObject } from './types'; + +/** + * Platform-owned security policy roots stored in the layered agent configuration. + * They are never sent to the OpenCode worker (stripped from the wire) — the agent manager + * enforces them (egress guard sidecar, chat message filters). + */ +export const EGRESS_CONFIG_ROOT = 'egress'; +export const MESSAGE_FILTERS_CONFIG_ROOT = 'message_filters'; + +/** Layer that contributed an effective policy entry (highest precedence first: global → workspace → environment). */ +export type PlatformPolicyLayer = 'global' | 'workspace' | 'environment'; + +export const PLATFORM_POLICY_LAYERS: readonly PlatformPolicyLayer[] = ['global', 'workspace', 'environment']; + +export interface PlatformPolicyLayerInput { + source: PlatformPolicyLayer; + overlay?: JsonObject | Record | null; +} + +export function isPlainPolicyObject(value: unknown): value is JsonObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function policyValidationError(message: string): OpencodeConfigValidationError { + return new OpencodeConfigValidationError(message); +} + +/** + * Order layers by precedence (global first). Unknown / duplicate sources keep input order. + */ +export function orderPolicyLayersHighToLow(layers: readonly PlatformPolicyLayerInput[]): PlatformPolicyLayerInput[] { + return [...layers].sort( + (left, right) => PLATFORM_POLICY_LAYERS.indexOf(left.source) - PLATFORM_POLICY_LAYERS.indexOf(right.source), + ); +} diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.spec.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.spec.ts new file mode 100644 index 000000000..ac3510615 --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.spec.ts @@ -0,0 +1,578 @@ +import { + assertOverlayRespectsHeredity, + assertValidPlatformSecurityConfig, + compactEgressHostEntries, + composeLayerOverlay, + computeHeredityMetadata, + evaluateEgressDestination, + evaluateMessageFilters, + expandExplicitLocks, + explainEgressRule, + hasNestedQuantifier, + isIpLiteralHost, + isPrivateNetworkAddress, + mergeConfigs, + normalizeEgressHost, + normalizeMessageFilterFlags, + OpencodeConfigValidationError, + parseEgressPortSpec, + prepareConfigForSync, + redactEgressUrl, + resolveEffectiveEgressPolicy, + resolveEffectiveMessageFilters, + resolvePlatformSecurityPolicy, + stablePlatformPolicyJson, + validateEgressUrlPattern, + validateOverlayAgainstHeredity, + type EffectiveEgressPolicy, +} from './agenstra-shared-util-opencode-config'; + +function policy(overrides: Partial = {}): EffectiveEgressPolicy { + return { + ...resolveEffectiveEgressPolicy([]), + mode: 'enforce', + ...overrides, + }; +} + +describe('normalizeEgressHost', () => { + it('normalizes domains and wildcard prefixes to the registrable suffix form', () => { + expect(normalizeEgressHost('Example.COM')).toEqual({ kind: 'domain', value: 'example.com' }); + expect(normalizeEgressHost('*.example.com')).toEqual({ kind: 'domain', value: 'example.com' }); + expect(normalizeEgressHost('.example.com.')).toEqual({ kind: 'domain', value: 'example.com' }); + }); + + it('normalizes IP addresses and CIDRs (host bits masked)', () => { + expect(normalizeEgressHost('10.1.2.3/8')).toEqual({ kind: 'ipv4', value: '10.0.0.0/8', prefix: 8 }); + expect(normalizeEgressHost('203.0.113.10')).toEqual({ kind: 'ipv4', value: '203.0.113.10', prefix: 32 }); + expect(normalizeEgressHost('2001:DB8::1')).toEqual({ kind: 'ipv6', value: '2001:db8::1', prefix: 128 }); + expect(normalizeEgressHost('2001:db8:0:0:1::/64')).toEqual({ kind: 'ipv6', value: '2001:db8::/64', prefix: 64 }); + }); + + it('rejects URLs, ports, paths and malformed values', () => { + for (const invalid of [ + 'https://example.com', + 'example.com/path', + 'example.com:443', + 'exa mple.com', + '-bad.example', + '999.1.1.1', + '10.0.0.0/33', + '', + 42, + ]) { + expect(normalizeEgressHost(invalid)).toBeNull(); + } + }); +}); + +describe('IP helpers', () => { + it('detects private and reserved ranges (including IPv4-mapped IPv6)', () => { + expect(isPrivateNetworkAddress('10.0.0.5')).toBe(true); + expect(isPrivateNetworkAddress('169.254.169.254')).toBe(true); + expect(isPrivateNetworkAddress('172.20.1.1')).toBe(true); + expect(isPrivateNetworkAddress('fd00::1')).toBe(true); + expect(isPrivateNetworkAddress('[::1]')).toBe(true); + expect(isPrivateNetworkAddress('::ffff:192.168.1.1')).toBe(true); + expect(isPrivateNetworkAddress('140.82.121.4')).toBe(false); + expect(isPrivateNetworkAddress('github.com')).toBe(false); + }); + + it('detects IP literal hosts', () => { + expect(isIpLiteralHost('1.2.3.4')).toBe(true); + expect(isIpLiteralHost('[2001:db8::1]')).toBe(true); + expect(isIpLiteralHost('example.com')).toBe(false); + }); + + it('parses port specs', () => { + expect(parseEgressPortSpec('22')).toEqual({ from: 22, to: 22 }); + expect(parseEgressPortSpec('1000-2000')).toEqual({ from: 1000, to: 2000 }); + expect(parseEgressPortSpec('0')).toBeNull(); + expect(parseEgressPortSpec('2000-1000')).toBeNull(); + expect(parseEgressPortSpec('70000')).toBeNull(); + }); +}); + +describe('compactEgressHostEntries', () => { + it('removes duplicates and entries covered by broader ones', () => { + expect( + compactEgressHostEntries([ + { value: 'api.example.com' }, + { value: 'example.com' }, + { value: '*.example.com' }, + { value: '10.1.0.0/16' }, + { value: '10.0.0.0/8' }, + { value: 'github.com' }, + ]).map((entry) => entry.value), + ).toEqual(['example.com', '10.0.0.0/8', 'github.com']); + }); +}); + +describe('validateEgressUrlPattern', () => { + it('accepts plain patterns and rejects whitespace, lookarounds and invalid regex', () => { + expect(validateEgressUrlPattern('^http://[^/]+/forbidden/')).toBeNull(); + expect(validateEgressUrlPattern('a b')).toMatch(/whitespace/); + expect(validateEgressUrlPattern('(?=x)')).toMatch(/lookarounds/); + expect(validateEgressUrlPattern('(')).toMatch(/valid/); + }); +}); + +describe('assertValidPlatformSecurityConfig', () => { + it('accepts a complete egress configuration', () => { + expect(() => + assertValidPlatformSecurityConfig({ + egress: { + mode: 'enforce', + allow_hosts: ['github.com', '*.npmjs.org', '203.0.113.0/24'], + deny_hosts: ['example.org'], + deny_url_patterns: ['^http://[^/]+/forbidden/'], + network_allow: [{ protocol: 'tcp', ports: '22', destinations: ['203.0.113.10'], description: 'git ssh' }], + block_private_networks: true, + block_ip_literals: false, + log_allowed: true, + }, + }), + ).not.toThrow(); + }); + + it.each([ + [{ egress: [] }, /must be an object/], + [{ egress: { mode: 'strict' } }, /mode/], + [{ egress: { unknown: true } }, /Unknown keys/], + [{ egress: { allow_hosts: ['https://github.com'] } }, /allow_hosts\/0/], + [{ egress: { deny_hosts: 'github.com' } }, /array/], + [{ egress: { block_private_networks: 'yes' } }, /boolean/], + [{ egress: { network_allow: [{ protocol: 'icmp', ports: '1', destinations: ['1.1.1.1'] }] } }, /protocol/], + [{ egress: { network_allow: [{ protocol: 'tcp', ports: 'ssh', destinations: ['1.1.1.1'] }] } }, /ports/], + [{ egress: { network_allow: [{ protocol: 'tcp', ports: '22', destinations: ['github.com'] }] } }, /IP address/], + [{ egress: { network_allow: [{ protocol: 'tcp', ports: '22', destinations: [] }] } }, /at least one/], + ])('rejects invalid egress config %#', (overlay, message) => { + expect(() => assertValidPlatformSecurityConfig(overlay)).toThrow(OpencodeConfigValidationError); + expect(() => assertValidPlatformSecurityConfig(overlay)).toThrow(message); + }); + + it('validates message filter rules', () => { + expect(() => + assertValidPlatformSecurityConfig({ + message_filters: [ + { name: 'Secrets', pattern: 'sk-[a-z0-9]+', flags: 'gi', direction: 'bidirectional', action: 'drop' }, + { name: 'Mask', pattern: 'secret', action: 'replace', replacement: '***' }, + ], + }), + ).not.toThrow(); + + expect(() => assertValidPlatformSecurityConfig({ message_filters: {} })).toThrow(/array/); + expect(() => + assertValidPlatformSecurityConfig({ message_filters: [{ name: 'x', pattern: 'a', action: 'replace' }] }), + ).toThrow(/replacement/); + expect(() => + assertValidPlatformSecurityConfig({ message_filters: [{ name: 'x', pattern: '(', action: 'drop' }] }), + ).toThrow(/Invalid regular expression/); + expect(() => + assertValidPlatformSecurityConfig({ message_filters: [{ name: 'x', pattern: '(a+)+$', action: 'drop' }] }), + ).toThrow(/nested quantifiers/); + expect(() => + assertValidPlatformSecurityConfig({ message_filters: [{ name: 'x', pattern: 'a', flags: 'x', action: 'drop' }] }), + ).toThrow(/flag/); + expect(() => + assertValidPlatformSecurityConfig({ + message_filters: [ + { name: 'dup', pattern: 'a', action: 'drop' }, + { name: 'dup', pattern: 'b', action: 'flag' }, + ], + }), + ).toThrow(/duplicates/); + }); + + it('is enforced by heredity validation (structured and raw editors)', () => { + expect(validateOverlayAgainstHeredity({ egress: { mode: 'nope' } }, [], [])).toMatch(/mode/); + expect(() => assertOverlayRespectsHeredity({ message_filters: [{ name: 'x' }] }, [], [])).toThrow(/pattern/); + }); +}); + +describe('hasNestedQuantifier / normalizeMessageFilterFlags', () => { + it('flags nested quantifiers only', () => { + expect(hasNestedQuantifier('(a+)+')).toBe(true); + expect(hasNestedQuantifier('((ab*))*')).toBe(true); + expect(hasNestedQuantifier('(?:ab)+')).toBe(false); + expect(hasNestedQuantifier('[(a+)]+')).toBe(false); + expect(hasNestedQuantifier('\\(a+\\)+')).toBe(false); + expect(hasNestedQuantifier('(a+)?')).toBe(false); + expect(hasNestedQuantifier('(a|aa)+$')).toBe(true); + expect(hasNestedQuantifier('(?:\\w|x)*')).toBe(true); + expect(hasNestedQuantifier('(?Foo|foo)+')).toBe(true); + expect(hasNestedQuantifier('(foo|bar)+')).toBe(false); + expect(hasNestedQuantifier('(?:foo|bar)?')).toBe(false); + expect(hasNestedQuantifier('foo|foobar')).toBe(false); + }); + + it('dedupes flags and defaults to global', () => { + expect(normalizeMessageFilterFlags(undefined)).toBe('g'); + expect(normalizeMessageFilterFlags('gig')).toBe('gi'); + }); +}); + +describe('egress merge and heredity', () => { + it('merges egress per field: higher layers win scalars, lists concatenate', () => { + const merged = mergeConfigs( + { egress: { mode: 'monitor', allow_hosts: ['agent.example'] } }, + { egress: { allow_hosts: ['workspace.example'], deny_hosts: ['bad.example'] } }, + { egress: { mode: 'enforce', allow_hosts: ['agent.example'] } }, + ); + + expect(merged['egress']).toEqual({ + mode: 'enforce', + allow_hosts: ['agent.example', 'workspace.example'], + deny_hosts: ['bad.example'], + }); + }); + + it('composes structured config with overrides', () => { + expect(composeLayerOverlay({ egress: { mode: 'monitor' } }, { egress: { deny_hosts: ['x.example'] } })).toEqual({ + egress: { mode: 'monitor', deny_hosts: ['x.example'] }, + }); + }); + + it('concatenates message filters across layers', () => { + const merged = mergeConfigs( + { message_filters: [{ name: 'a', pattern: 'a', action: 'flag' }] }, + { message_filters: [{ name: 'b', pattern: 'b', action: 'drop' }] }, + ); + + expect(merged['message_filters']).toHaveLength(2); + }); + + it('locks parent egress scalars and exposes list entries as additive', () => { + const meta = computeHeredityMetadata({ + egress: { mode: 'enforce', deny_hosts: ['bad.example'], allow_hosts: [] }, + message_filters: [{ name: 'a', pattern: 'a', action: 'drop' }], + }); + + expect(meta.lockedPaths).toContain('/egress/mode'); + expect(meta.lockedPaths).not.toContain('/egress'); + expect(meta.inheritedAdditive).toEqual( + expect.arrayContaining([ + { path: '/egress/deny_hosts', items: ['bad.example'] }, + { path: '/message_filters', items: [{ name: 'a', pattern: 'a', action: 'drop' }] }, + ]), + ); + expect(() => assertOverlayRespectsHeredity({ egress: { mode: 'off' } }, meta.lockedPaths, [])).toThrow(/locked/); + expect(() => + assertOverlayRespectsHeredity({ egress: { deny_hosts: ['more.example'] } }, meta.lockedPaths, []), + ).not.toThrow(); + }); + + it('expands the egress and message filter tab locks', () => { + expect(expandExplicitLocks(['/tabs/egress', '/tabs/message_filters'])).toEqual( + expect.arrayContaining(['/egress', '/egress/mode', '/egress/allow_hosts', '/message_filters']), + ); + expect(() => + assertOverlayRespectsHeredity({ egress: { allow_hosts: ['x.example'] } }, expandExplicitLocks(['/tabs/egress'])), + ).toThrow(/locked/); + }); + + it('never sends platform security roots to the OpenCode worker', () => { + const wire = prepareConfigForSync({ + egress: { mode: 'enforce' }, + message_filters: [{ name: 'a', pattern: 'a', action: 'drop' }], + share: 'manual', + }); + + expect(wire).not.toHaveProperty('egress'); + expect(wire).not.toHaveProperty('message_filters'); + expect(wire['share']).toBe('manual'); + }); +}); + +describe('resolveEffectiveEgressPolicy', () => { + it('defaults to off with private networks blocked', () => { + const resolved = resolveEffectiveEgressPolicy([]); + + expect(resolved.mode).toBe('off'); + expect(resolved.blockPrivateNetworks).toBe(true); + expect(resolved.scalarSources.mode).toBeNull(); + }); + + it('applies global → workspace → environment precedence with provenance', () => { + const resolved = resolveEffectiveEgressPolicy([ + { source: 'environment', overlay: { egress: { mode: 'off', allow_hosts: ['api.github.com', 'github.com'] } } }, + { source: 'global', overlay: { egress: { mode: 'enforce', allow_hosts: ['github.com'] } } }, + { + source: 'workspace', + overlay: { + egress: { + log_allowed: false, + deny_hosts: ['Bad.Example'], + network_allow: [{ protocol: 'tcp', ports: '22', destinations: ['203.0.113.10/32'] }], + }, + }, + }, + ]); + + expect(resolved.mode).toBe('enforce'); + expect(resolved.scalarSources.mode).toBe('global'); + expect(resolved.logAllowed).toBe(false); + expect(resolved.scalarSources.logAllowed).toBe('workspace'); + expect(resolved.allowHosts).toEqual([ + { value: 'github.com', source: 'global' }, + { value: 'api.github.com', source: 'environment' }, + ]); + expect(resolved.denyHosts).toEqual([{ value: 'bad.example', source: 'workspace' }]); + expect(resolved.networkAllow).toEqual([ + { protocol: 'tcp', ports: '22', destinations: ['203.0.113.10'], source: 'workspace' }, + ]); + }); + + it('never lets lower layers widen an inherited allowlist', () => { + const resolved = resolveEffectiveEgressPolicy([ + { source: 'global', overlay: { egress: { mode: 'enforce', allow_hosts: ['github.com', '10.0.0.0/8'] } } }, + { + source: 'workspace', + overlay: { egress: { allow_hosts: ['evil.example', 'raw.github.com', '10.1.0.0/16', '0.0.0.0/0'] } }, + }, + ]); + + expect(resolved.allowHosts.map((entry) => entry.value)).toEqual([ + 'github.com', + '10.0.0.0/8', + 'raw.github.com', + '10.1.0.0/16', + ]); + expect(evaluateEgressDestination(resolved, 'evil.example').decision).toBe('blocked'); + }); + + it('lets lower layers add allow entries when no higher layer restricts', () => { + const resolved = resolveEffectiveEgressPolicy([ + { source: 'global', overlay: { egress: { mode: 'enforce' } } }, + { source: 'environment', overlay: { egress: { allow_hosts: ['example.org'] } } }, + ]); + + expect(resolved.allowHosts).toEqual([{ value: 'example.org', source: 'environment' }]); + }); + + it('bounds lower-layer network exceptions by inherited ones', () => { + const resolved = resolveEffectiveEgressPolicy([ + { + source: 'global', + overlay: { egress: { network_allow: [{ protocol: 'tcp', ports: '22-23', destinations: ['203.0.113.0/24'] }] } }, + }, + { + source: 'workspace', + overlay: { + egress: { + network_allow: [ + { protocol: 'tcp', ports: '22', destinations: ['203.0.113.10'] }, + { protocol: 'tcp', ports: '1-65535', destinations: ['0.0.0.0/0'] }, + { protocol: 'udp', ports: '22', destinations: ['203.0.113.10'] }, + ], + }, + }, + }, + ]); + + expect(resolved.networkAllow.map((rule) => [rule.protocol, rule.ports, rule.destinations, rule.source])).toEqual([ + ['tcp', '22-23', ['203.0.113.0/24'], 'global'], + ['tcp', '22', ['203.0.113.10'], 'workspace'], + ]); + }); +}); + +describe('egress URL pattern safety', () => { + it.each([ + ['(?:a|b)/x', /special groups/], + ['a.*?b', /lazy/], + ['\\d+/admin', /escape classes/], + ['#comment', /must not start/], + ['(a+)+$', /catastrophic/], + ['(a|aa)+$', /catastrophic/], + ])('rejects %s', (pattern, message) => { + expect(validateEgressUrlPattern(pattern)).toMatch(message); + expect(() => assertValidPlatformSecurityConfig({ egress: { deny_url_patterns: [pattern] } })).toThrow( + OpencodeConfigValidationError, + ); + }); + + it('accepts POSIX-compatible patterns', () => { + expect(validateEgressUrlPattern('^http://[^/]+/admin(/|$)')).toBeNull(); + expect(validateEgressUrlPattern('\\.exe$')).toBeNull(); + }); + + it('ignores unsafe stored patterns during evaluation', () => { + const resolved = policy({ denyUrlPatterns: [{ value: '(a|aa)+$', source: 'global' }] }); + + expect( + evaluateEgressDestination(resolved, 'h.example', { url: `http://h.example/${'a'.repeat(40)}!`, scheme: 'http' }) + .decision, + ).toBe('allowed'); + }); +}); + +describe('evaluateEgressDestination', () => { + it('allows everything when off', () => { + expect(evaluateEgressDestination(policy({ mode: 'off' }), 'anything.example')).toEqual({ + decision: 'allowed', + rule: null, + }); + }); + + it('lets the denylist win over the allowlist', () => { + const resolved = policy({ + allowHosts: [{ value: 'example.com', source: 'global' }], + denyHosts: [{ value: 'secret.example.com', source: 'workspace' }], + }); + + expect(evaluateEgressDestination(resolved, 'api.example.com').decision).toBe('allowed'); + expect(evaluateEgressDestination(resolved, 'x.secret.example.com')).toEqual({ + decision: 'blocked', + rule: 'deny_host', + matchedEntry: 'secret.example.com', + source: 'workspace', + }); + expect(evaluateEgressDestination(resolved, 'other.org')).toEqual({ decision: 'blocked', rule: 'not_allowlisted' }); + }); + + it('treats an empty allowlist as unrestricted', () => { + expect(evaluateEgressDestination(policy(), 'other.org').decision).toBe('allowed'); + }); + + it('blocks private networks, IP literals and HTTP URL patterns', () => { + expect(evaluateEgressDestination(policy(), '169.254.169.254').rule).toBe('private_network'); + expect(evaluateEgressDestination(policy({ blockIpLiterals: true }), '140.82.121.4').rule).toBe('ip_literal'); + expect( + evaluateEgressDestination( + policy({ blockIpLiterals: true, allowHosts: [{ value: '140.82.0.0/16', source: 'global' }] }), + '140.82.121.4', + ).decision, + ).toBe('allowed'); + + const withPatterns = policy({ denyUrlPatterns: [{ value: '/forbidden/', source: 'global' }] }); + + expect( + evaluateEgressDestination(withPatterns, 'example.com', { url: 'http://example.com/forbidden/x', scheme: 'http' }) + .rule, + ).toBe('deny_url'); + // HTTPS paths are not visible to the guard without TLS interception. + expect( + evaluateEgressDestination(withPatterns, 'example.com', { + url: 'https://example.com/forbidden/x', + scheme: 'https', + }).decision, + ).toBe('allowed'); + }); + + it('reports observed in monitor mode and blocks everything when offline', () => { + const monitored = policy({ mode: 'monitor', denyHosts: [{ value: 'bad.example', source: 'global' }] }); + + expect(evaluateEgressDestination(monitored, 'bad.example').decision).toBe('observed'); + expect(evaluateEgressDestination(policy({ mode: 'offline' }), 'github.com')).toEqual({ + decision: 'blocked', + rule: 'offline', + source: null, + }); + }); + + it('explains guard rules with provenance', () => { + const resolved = policy({ denyHosts: [{ value: 'bad.example', source: 'environment' }] }); + + expect(explainEgressRule(resolved, 'deny_host', 'www.bad.example')).toEqual({ + matchedEntry: 'bad.example', + source: 'environment', + }); + expect(explainEgressRule(resolved, null, 'x')).toEqual({}); + }); +}); + +describe('redactEgressUrl', () => { + it('drops query strings, fragments and credentials', () => { + expect(redactEgressUrl('http://user:pw@example.com/path?token=abc#frag')).toBe('http://example.com/path'); + expect(redactEgressUrl(null)).toBeNull(); + expect(redactEgressUrl(`http://example.com/${'a'.repeat(600)}`, 30)).toHaveLength(30); + }); +}); + +describe('message filters', () => { + const rules = resolveEffectiveMessageFilters([ + { + source: 'environment', + overlay: { message_filters: [{ name: 'Env flag', pattern: 'hello', action: 'flag', direction: 'incoming' }] }, + }, + { + source: 'global', + overlay: { + message_filters: [ + { name: 'Mask token', pattern: 'token-\\d+', action: 'replace', replacement: '[redacted]' }, + { name: 'Disabled drop', pattern: 'hello', action: 'drop', enabled: false }, + ], + }, + }, + { + source: 'workspace', + overlay: { + message_filters: [ + { name: 'Drop secrets', pattern: 'BEGIN PRIVATE KEY', action: 'drop', direction: 'bidirectional' }, + { + name: 'Mask mail', + pattern: '[a-z]+@corp\\.example', + flags: 'gi', + action: 'replace', + replacement: '', + }, + ], + }, + }, + ]); + + it('orders rules by layer precedence with provenance and defaults', () => { + expect(rules.map((rule) => [rule.name, rule.source])).toEqual([ + ['Mask token', 'global'], + ['Disabled drop', 'global'], + ['Drop secrets', 'workspace'], + ['Mask mail', 'workspace'], + ['Env flag', 'environment'], + ]); + expect(rules[0]).toMatchObject({ flags: 'g', direction: 'bidirectional', enabled: true }); + }); + + it('drops when any drop rule matches (most restrictive wins)', () => { + const result = evaluateMessageFilters(rules, 'token-1 BEGIN PRIVATE KEY', 'outgoing'); + + expect(result.status).toBe('dropped'); + expect(result.matchedRule?.name).toBe('Drop secrets'); + expect(result.matchedRules.map((rule) => rule.name)).toEqual(['Mask token', 'Drop secrets']); + }); + + it('chains replace rules in precedence order', () => { + const result = evaluateMessageFilters(rules, 'token-12 from Bob@corp.example', 'incoming'); + + expect(result.status).toBe('filtered'); + expect(result.matchedRule?.name).toBe('Mask token'); + expect(result.modifiedMessage).toBe('[redacted] from '); + }); + + it('flags without modifying, honours direction and skips disabled rules', () => { + expect(evaluateMessageFilters(rules, 'hello there', 'incoming')).toMatchObject({ + status: 'filtered', + matchedRule: expect.objectContaining({ name: 'Env flag' }), + }); + expect(evaluateMessageFilters(rules, 'hello there', 'incoming').modifiedMessage).toBeUndefined(); + expect(evaluateMessageFilters(rules, 'hello there', 'outgoing').status).toBe('allowed'); + }); + + it('skips invalid stored rules instead of failing', () => { + expect( + resolveEffectiveMessageFilters([ + { source: 'global', overlay: { message_filters: [{ name: 'bad', pattern: '(', action: 'drop' }, 'nope'] } }, + ]), + ).toEqual([]); + }); +}); + +describe('resolvePlatformSecurityPolicy / stablePlatformPolicyJson', () => { + it('resolves both policies and serializes deterministically', () => { + const resolved = resolvePlatformSecurityPolicy([{ source: 'global', overlay: { egress: { mode: 'monitor' } } }]); + + expect(resolved.egress.mode).toBe('monitor'); + expect(resolved.messageFilters).toEqual([]); + expect(stablePlatformPolicyJson({ b: 1, a: [{ d: 1, c: 2 }] })).toBe('{"a":[{"c":2,"d":1}],"b":1}'); + }); +}); diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.ts new file mode 100644 index 000000000..4f0b50c50 --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/platform-security.ts @@ -0,0 +1,70 @@ +import { assertValidEgressConfig, resolveEffectiveEgressPolicy, type EffectiveEgressPolicy } from './egress-policy'; +import { + assertValidMessageFilters, + resolveEffectiveMessageFilters, + type EffectiveMessageFilterRule, +} from './message-filters'; +import { + EGRESS_CONFIG_ROOT, + MESSAGE_FILTERS_CONFIG_ROOT, + isPlainPolicyObject, + type PlatformPolicyLayerInput, +} from './platform-policy'; +import type { JsonObject } from './types'; + +/** Effective platform security policy for one agent (shipped with every config sync). */ +export interface EffectivePlatformSecurityPolicy { + egress: EffectiveEgressPolicy; + messageFilters: EffectiveMessageFilterRule[]; +} + +/** + * Validate platform security roots of one layer overlay (`egress`, `message_filters`). + * @throws OpencodeConfigValidationError + */ +export function assertValidPlatformSecurityConfig( + overlay: JsonObject | Record | null | undefined, +): void { + if (!isPlainPolicyObject(overlay)) { + return; + } + + assertValidEgressConfig(overlay[EGRESS_CONFIG_ROOT]); + assertValidMessageFilters(overlay[MESSAGE_FILTERS_CONFIG_ROOT]); +} + +/** + * Resolve the effective platform security policy from composed layer overlays. + * Pass every available layer (global, workspace, environment); order does not matter. + */ +export function resolvePlatformSecurityPolicy( + layers: readonly PlatformPolicyLayerInput[], +): EffectivePlatformSecurityPolicy { + return { + egress: resolveEffectiveEgressPolicy(layers), + messageFilters: resolveEffectiveMessageFilters(layers), + }; +} + +/** Stable JSON serialization used for revision hashing (key order independent). */ +export function stablePlatformPolicyJson(value: unknown): string { + const normalize = (input: unknown): unknown => { + if (Array.isArray(input)) { + return input.map(normalize); + } + + if (isPlainPolicyObject(input)) { + const out: Record = {}; + + for (const key of Object.keys(input).sort()) { + out[key] = normalize(input[key]); + } + + return out; + } + + return input; + }; + + return JSON.stringify(normalize(value)); +} diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/types.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/types.ts index a2d0b6bfe..550e6aa7e 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/types.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/types.ts @@ -51,7 +51,13 @@ export const MAP_MERGE_ROOT_KEYS = ['providers', 'commands', 'agents', 'referenc export const INHERITED_MAP_ENTRY_OVERRIDE_KEYS = ['disabled', 'hidden'] as const; /** Array keys that concatenate across layers. */ -export const ARRAY_CONCAT_ROOT_KEYS = ['skills', 'instructions', 'plugins'] as const; +export const ARRAY_CONCAT_ROOT_KEYS = ['skills', 'instructions', 'plugins', 'message_filters'] as const; + +/** + * Platform security roots enforced by the agent manager (never sent to the OpenCode worker). + * `egress` merges per field (scalars lock / lists concatenate); `message_filters` concatenates. + */ +export const PLATFORM_SECURITY_ROOT_KEYS = ['egress', 'message_filters'] as const; /** Scalar / object roots locked when parent defines them. */ export const SCALAR_LOCK_ROOT_KEYS = [ diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.spec.ts index c6d848c7d..484563e05 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.spec.ts @@ -317,7 +317,7 @@ describe('cloud-init.utils', () => { expect(script).toContain('SEARCH_REINDEX_INTERVAL: 15m'); expect(script).toContain('vm.max_map_count=262144'); expect(script).not.toContain('9200:9200'); - expect(script).toContain('FILTER_RULES_SYNC_INTERVAL_MS: 30000'); + expect(script).toContain('EGRESS_EVENTS_INGEST_INTERVAL_MS: 30000'); expect(script).toContain('CONTEXT_IMPORT_SCHEDULER_INTERVAL_MS: 120000'); expect(script).toContain('KNOWLEDGE_EMBEDDINGS_REINDEX_INTERVAL_MS: 3600000'); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.ts index 6cabb4ccc..c6ed476fa 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/agenstra-controller/agent-controller.utils.ts @@ -270,8 +270,8 @@ export function buildAgentControllerCloudInitUserData(config: AgentControllerClo ...buildOpenSearchBackendEnvLines('agenstra'), `QUEUE_WORKER_CONCURRENCY: 5`, // Coordinator / worker scheduler intervals (shared across api, worker, scheduler) - `FILTER_RULES_SYNC_INTERVAL_MS: 30000`, - `FILTER_RULES_SYNC_BATCH_SIZE: 10`, + `EGRESS_EVENTS_INGEST_INTERVAL_MS: 30000`, + `EGRESS_EVENTS_INGEST_BATCH_SIZE: 10`, `CONTEXT_IMPORT_SCHEDULER_INTERVAL_MS: 120000`, `CONTEXT_IMPORT_SCHEDULER_CONFIG_BATCH: 3`, `CONTEXT_IMPORT_ITEM_BUDGET: 25`, diff --git a/libs/domains/identity/backend/feature-auth/src/lib/constants/pat.constants.ts b/libs/domains/identity/backend/feature-auth/src/lib/constants/pat.constants.ts index 2547e2206..3a4617031 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/constants/pat.constants.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/constants/pat.constants.ts @@ -33,7 +33,6 @@ export const ADMIN_ONLY_PAT_SCOPES = new Set([ 'billing_admin:read', 'billing_admin:write', 'datev:write', - 'filter_rules:write', 'imports:write', 'invoices:write', 'customer_profile:admin', diff --git a/libs/domains/shared/backend/util-queue/src/lib/job-id.util.spec.ts b/libs/domains/shared/backend/util-queue/src/lib/job-id.util.spec.ts index 80082f175..56cb57308 100644 --- a/libs/domains/shared/backend/util-queue/src/lib/job-id.util.spec.ts +++ b/libs/domains/shared/backend/util-queue/src/lib/job-id.util.spec.ts @@ -15,8 +15,8 @@ describe('job-id.util', () => { }); it('buildCoordinatorJobId uses allowed characters only', () => { - expect(buildCoordinatorJobId('filter-rules-sync')).toBe('coordinator.filter-rules-sync'); - assertValidBullMqJobId(buildCoordinatorJobId('filter-rules-sync')); + expect(buildCoordinatorJobId('egress-events-ingest')).toBe('coordinator.egress-events-ingest'); + assertValidBullMqJobId(buildCoordinatorJobId('egress-events-ingest')); }); it('rejects integer-only job ids', () => { diff --git a/libs/domains/shared/backend/util-queue/src/lib/register-repeatable-coordinator-job.spec.ts b/libs/domains/shared/backend/util-queue/src/lib/register-repeatable-coordinator-job.spec.ts index be77d09f8..969fc2690 100644 --- a/libs/domains/shared/backend/util-queue/src/lib/register-repeatable-coordinator-job.spec.ts +++ b/libs/domains/shared/backend/util-queue/src/lib/register-repeatable-coordinator-job.spec.ts @@ -9,10 +9,10 @@ import { describe('registerRepeatableCoordinatorJob', () => { it('removes stale repeatables before registering', async () => { const removeRepeatableByKey = jest.fn().mockResolvedValue(true); - const add = jest.fn().mockResolvedValue({ id: 'coordinator.filter-rules-sync' }); + const add = jest.fn().mockResolvedValue({ id: 'coordinator.egress-events-ingest' }); const queue = { getRepeatableJobs: jest.fn().mockResolvedValue([ - { name: 'filter-rules-sync.coordinator', id: 'coordinator.filter-rules-sync', key: 'repeat:abc' }, + { name: 'egress-events-ingest.coordinator', id: 'coordinator.egress-events-ingest', key: 'repeat:abc' }, { name: 'other.coordinator', id: 'coordinator.other', key: 'repeat:def' }, ]), removeRepeatableByKey, @@ -21,18 +21,18 @@ describe('registerRepeatableCoordinatorJob', () => { await registerRepeatableCoordinatorJob({ queue, - name: 'filter-rules-sync.coordinator', - coordinatorJobId: 'coordinator.filter-rules-sync', + name: 'egress-events-ingest.coordinator', + coordinatorJobId: 'coordinator.egress-events-ingest', everyMs: 30_000, }); expect(removeRepeatableByKey).toHaveBeenCalledTimes(1); expect(removeRepeatableByKey).toHaveBeenCalledWith('repeat:abc'); expect(add).toHaveBeenCalledWith( - 'filter-rules-sync.coordinator', + 'egress-events-ingest.coordinator', {}, expect.objectContaining({ - jobId: 'coordinator.filter-rules-sync', + jobId: 'coordinator.egress-events-ingest', repeat: { every: 30_000 }, removeOnComplete: defaultRemoveOnComplete, removeOnFail: defaultRemoveOnFail, diff --git a/libs/domains/shared/backend/util-webhook/src/lib/webhook-delivery-policy.ts b/libs/domains/shared/backend/util-webhook/src/lib/webhook-delivery-policy.ts index 3b7a40071..85191df55 100644 --- a/libs/domains/shared/backend/util-webhook/src/lib/webhook-delivery-policy.ts +++ b/libs/domains/shared/backend/util-webhook/src/lib/webhook-delivery-policy.ts @@ -5,7 +5,9 @@ import type { WebhookAuthConfig, WebhookHttpMethod } from './webhook.types'; /** Event types that must not be delivered via GET (payload would appear in query strings). */ export const WEBHOOK_SENSITIVE_EVENT_TYPES = new Set([ 'chat_message.created', - 'filter_rule.triggered', + 'message_filter.triggered', + 'egress.blocked', + 'egress.observed', 'user.created', 'user.updated', 'user.deleted',