diff --git a/.gitignore b/.gitignore index 8a4f4d9..c994426 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,30 @@ _build/ deps/ .elixir_ls/ *.beam + +# ── SF baseline (auto-generated) ── +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ +*.code-workspace +.env +.env.* +!.env.example +node_modules/ +.next/ +dist/ +build/ +__pycache__/ +*.pyc +.venv/ +venv/ +target/ +vendor/ +*.log +coverage/ +.cache/ +tmp/ diff --git a/.sf/NON-GOALS.md b/.sf/NON-GOALS.md new file mode 100644 index 0000000..4626fe8 --- /dev/null +++ b/.sf/NON-GOALS.md @@ -0,0 +1,10 @@ + +# Non-goals + +What this project explicitly does not want. Things that look attractive but have been decided against. + +This is gold — most wrong agent calls come from not knowing what to avoid. Each entry: 1-2 sentences with the rationale. + +## Examples + +- (replace with your own) diff --git a/.sf/PRINCIPLES.md b/.sf/PRINCIPLES.md new file mode 100644 index 0000000..fa43e1c --- /dev/null +++ b/.sf/PRINCIPLES.md @@ -0,0 +1,10 @@ + +# Principles + +Durable design philosophy. Things this codebase believes are true. + +Add entries as you make decisions. Each entry: 1-2 sentences. Cite the rationale (the why, not just the what). + +## Examples + +- (replace with your own) diff --git a/.sf/STYLE.md b/.sf/STYLE.md new file mode 100644 index 0000000..6ec93aa --- /dev/null +++ b/.sf/STYLE.md @@ -0,0 +1,10 @@ + +# Style + +What good code looks like here. Idioms, conventions, "we prefer X over Y" calls. + +Add entries as you notice patterns worth preserving. Each entry: 1-2 sentences with a concrete example. + +## Examples + +- (replace with your own) diff --git a/.sf/harness/AGENTS.md b/.sf/harness/AGENTS.md new file mode 100644 index 0000000..ef0becf --- /dev/null +++ b/.sf/harness/AGENTS.md @@ -0,0 +1,10 @@ + +# Harness Agent Notes + +The harness is SF-local operational scaffolding the agent can read and verify against. + +- `specs/`: behavior contracts. Each spec states what "done" looks like and the command that proves it. +- `evals/`: task definitions for behaviors tests cannot cover — model output quality, multi-turn flows, agent decisions. +- `graders/`: reusable grader scripts (code-based checks, LLM-judge prompts used by evals). + +**Rule:** Before marking a task done, run the relevant spec's verification command. Record the result in the completion summary or execution plan. diff --git a/.sf/harness/evals/AGENTS.md b/.sf/harness/evals/AGENTS.md new file mode 100644 index 0000000..8c6e53e --- /dev/null +++ b/.sf/harness/evals/AGENTS.md @@ -0,0 +1,11 @@ + +# Harness Evals Agent Notes + +Evals verify behavior that unit tests cannot cover — model output quality, agent decisions, multi-turn flows. + +Each eval should include: +- The input fixture or prompt +- The expected output or scoring rubric +- The command to run it (`promptfoo eval`, custom script, etc.) + +Keep evals deterministic where possible. Log results to `docs/records/` at milestone close. diff --git a/.sf/harness/graders/AGENTS.md b/.sf/harness/graders/AGENTS.md new file mode 100644 index 0000000..e9317ac --- /dev/null +++ b/.sf/harness/graders/AGENTS.md @@ -0,0 +1,9 @@ + +# Harness Graders Agent Notes + +Graders are reusable scripts or prompts that score eval outputs. + +- Code-based graders: shell scripts or test files that check structured outputs deterministically. +- LLM-judge graders: prompt templates that ask a model to score free-text output against a rubric. + +Prefer code-based graders. Add LLM-judge graders only when deterministic checking is impossible. diff --git a/.sf/harness/specs/AGENTS.md b/.sf/harness/specs/AGENTS.md new file mode 100644 index 0000000..846ac39 --- /dev/null +++ b/.sf/harness/specs/AGENTS.md @@ -0,0 +1,10 @@ + +# Harness Specs Agent Notes + +Each spec file in this directory: + +- States the behavior being specified (not the implementation). +- Includes the exact command that proves the spec passes. +- Is referenced by the relevant execution plan or ADR. + +Write the spec before implementation. Run it after. Record the result. diff --git a/.sf/harness/specs/bootstrap.md b/.sf/harness/specs/bootstrap.md new file mode 100644 index 0000000..999e704 --- /dev/null +++ b/.sf/harness/specs/bootstrap.md @@ -0,0 +1,20 @@ + +# Bootstrap Spec: Agent Legibility + +Verifies that this repo is minimally agent-legible. + +## Criteria + +- [ ] `AGENTS.md` exists at repo root and is non-empty. +- [ ] `ARCHITECTURE.md` exists at repo root and is non-empty. +- [ ] `docs/exec-plans/active/` exists. +- [ ] `docs/exec-plans/tech-debt-tracker.md` exists. +- [ ] `docs/design-docs/ADR-TEMPLATE.md` exists. + +## Verification command + +```bash +for f in AGENTS.md ARCHITECTURE.md docs/exec-plans/active/index.md docs/exec-plans/tech-debt-tracker.md docs/design-docs/ADR-TEMPLATE.md .sf/harness/specs/bootstrap.md; do [ -s "$f" ] && echo "OK: $f" || echo "MISSING: $f"; done +``` + +All lines should start with `OK:` for the bootstrap spec to pass. diff --git a/.sf/preferences.yaml b/.sf/preferences.yaml new file mode 100644 index 0000000..e920234 --- /dev/null +++ b/.sf/preferences.yaml @@ -0,0 +1,12 @@ +# SF preferences — see ~/.sf/agent/extensions/sf/docs/preferences-reference.md for docs +version: 1 +last_synced_with_sf: 2.75.3 +sf_template_state: pending +always_use_skills: [] +prefer_skills: [] +avoid_skills: [] +skill_rules: [] +custom_instructions: [] +models: {} +skill_discovery: {} +auto_supervisor: {} diff --git a/.sf/scaffold-manifest.json b/.sf/scaffold-manifest.json new file mode 100644 index 0000000..f07ac1c --- /dev/null +++ b/.sf/scaffold-manifest.json @@ -0,0 +1,142 @@ +{ + "schemaVersion": 1, + "profile": "infra", + "applied": [ + { + "path": ".siftignore", + "template": ".siftignore", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:e40f5757f1e3d603cc8f342e53e3e06fb86ea895f33c5b7dfdc7d693f7fd3e96" + }, + { + "path": "AGENTS.md", + "template": "AGENTS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:dc04211ddd84103bb94b805effcf00f436ea678c1e88301969647ded2e2a787a" + }, + { + "path": "ARCHITECTURE.md", + "template": "ARCHITECTURE.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:b93561af871cbfd6c7445b66273600a5654359dd3db578b424a0c3fa587ee5f3" + }, + { + "path": "docs/AGENTS.md", + "template": "docs/AGENTS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:b35804ce78ca309cab8769719f6e0738141f1121682fbd46490419abd2c6f870" + }, + { + "path": "docs/records/AGENTS.md", + "template": "docs/records/AGENTS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:dc21117dfa7607d7ce4cc6ce5724658348a95e9807673ff526b9cf02e2568de0" + }, + { + "path": "docs/records/index.md", + "template": "docs/records/index.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:03e974ded1b733db1a84dbf92096ff91bdc28a2ae37f457c536bc184fdc79cb9" + }, + { + "path": "docs/RECORDS_KEEPER.md", + "template": "docs/RECORDS_KEEPER.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:3872de9cd72bd9129814a5e77e3b86abe76bef33f3ca34e04ae7582b4cfd066a" + }, + { + "path": "docs/RELIABILITY.md", + "template": "docs/RELIABILITY.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:cda2b3d8f7f6323c5185e32fe832d8c181e6383c1a515b20c3e530eb6f133407" + }, + { + "path": "docs/SECURITY.md", + "template": "docs/SECURITY.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:baf816ba2591d9e3859b82ba1dbe8b05f7bb8003edab90071c086eee3edfd445" + }, + { + "path": ".sf/harness/AGENTS.md", + "template": ".sf/harness/AGENTS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:685c41e601340086b8076263a71315c66554efdaeb074bc1b907eebf879174c6" + }, + { + "path": ".sf/harness/specs/AGENTS.md", + "template": ".sf/harness/specs/AGENTS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:0f4fbf4111704d05744e4a4e13a9bf3eada262f0da9517c2010f0b46f4bd3c45" + }, + { + "path": ".sf/harness/specs/bootstrap.md", + "template": ".sf/harness/specs/bootstrap.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:b86ba7cf2cec39a7a9f9d94f885998cfe26eebfc5b76fdd8375ef125e927e0cf" + }, + { + "path": ".sf/harness/evals/AGENTS.md", + "template": ".sf/harness/evals/AGENTS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:6f88bf8a2bad95d8db5985c9b3317b9edd65592c12e98bb0bff1a24ec152d768" + }, + { + "path": ".sf/harness/graders/AGENTS.md", + "template": ".sf/harness/graders/AGENTS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:2db17feae1acfe62d85aafbe32d016873c3036d4d76e9dd0db478375fae0794e" + }, + { + "path": ".sf/PRINCIPLES.md", + "template": ".sf/PRINCIPLES.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:9d5c50cb3d602f66468a33a4324068fab8a022fab0fd6940c371a5986af2947e" + }, + { + "path": ".sf/STYLE.md", + "template": ".sf/STYLE.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:a78cb9cb5a95271ff43d91acf246f1390216a30aca48f3796a6aa135301f167f" + }, + { + "path": ".sf/NON-GOALS.md", + "template": ".sf/NON-GOALS.md", + "version": "2.75.3", + "appliedAt": "2026-05-12T23:29:27.296Z", + "stateAtApply": "pending", + "contentHash": "sha256:906860a75a8ae97d473efa0530675aded011c4897d858a8eff44ce42ea73a74a" + } + ] +} diff --git a/.siftignore b/.siftignore new file mode 100644 index 0000000..ba14dba --- /dev/null +++ b/.siftignore @@ -0,0 +1,19 @@ +.git/** +.sf/** +.bg-shell/** +.pytest_cache/** +.venv/** +venv/** +node_modules/** +**/node_modules/** +**/__pycache__/** +*.pyc +*.egg-info/** +build/** +dist/** +target/** +vendor/** +coverage/** +.cache/** +tmp/** +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2b85224 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ + +# Agent Map + +Keep this file short. Use it as a table of contents for agents and humans. + +- Treat the repo as a purpose-to-software pipeline: intent -> purpose/consumer/contract/evidence -> tests -> implementation -> verification. +- Read `ARCHITECTURE.md` first for the system map and invariants. +- Read `docs/PLANS.md` and `docs/exec-plans/active/` for current work. +- Read `docs/QUALITY_SCORE.md`, `docs/RELIABILITY.md`, and `docs/SECURITY.md` before changing production behavior. +- Put durable product decisions in `docs/product-specs/`. +- Put durable design and architecture decisions in `docs/design-docs/`. +- Put generated reference material in `docs/generated/`. +- Use `docs/RECORDS_KEEPER.md` as the repo-order checklist after meaningful changes. +- Use the `records-keeper` skill when repo docs, plans, or architecture records need triage. +- Follow deeper `AGENTS.md` files when present. The closest one to the changed file wins. + +Before implementation, inspect the relevant docs and source files, state observed facts before inferred facts, name the real consumer, and define the command or eval that proves the change. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..93e9348 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,21 @@ + +# Architecture + +This file is the short map of the codebase. Keep it current and compact. + +## Purpose + +Describe the product, its users, and the job this repository exists to do. + +## Codemap + +- `src/`: primary implementation. +- `tests/`: behavior and regression coverage. +- `docs/`: durable product, design, plan, reliability, and security context. + +## Invariants + +- Prefer small, named modules with clear ownership. +- Behavior changes need tests or an explicit eval. +- Keep generated artifacts out of hand-written design docs. +- Update this map when new top-level concepts or directories become important. diff --git a/Dockerfile b/Dockerfile index f270769..a86584f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,19 @@ COPY apps/centralcloud_my ./apps/centralcloud_my COPY apps/centralcloud_staff ./apps/centralcloud_staff COPY rel ./rel -RUN mix compile && \ - mix release centralcloud_my && \ +RUN mix compile + +# Install Tailwind CLI and compile CSS for both apps +RUN mix tailwind.install --if-missing +RUN mix tailwind staff --minify && mix tailwind my --minify + +# Copy phoenix.min.js + phoenix_live_view.min.js to the my app (my app's priv/static is empty) +RUN cp apps/centralcloud_staff/priv/static/phoenix.min.js \ + apps/centralcloud_my/priv/static/ && \ + cp apps/centralcloud_staff/priv/static/phoenix_live_view.min.js \ + apps/centralcloud_my/priv/static/ + +RUN mix release centralcloud_my && \ mix release centralcloud_staff # ── Shared runtime base ─────────────────────────────────────────────────────── diff --git a/_build/dev/.mix/compile.protocols b/_build/dev/.mix/compile.protocols index 52d5aab..63299e2 100644 Binary files a/_build/dev/.mix/compile.protocols and b/_build/dev/.mix/compile.protocols differ diff --git a/_build/dev/consolidated/Elixir.Jason.Encoder.beam b/_build/dev/consolidated/Elixir.Jason.Encoder.beam index 32d8bbc..1604538 100644 Binary files a/_build/dev/consolidated/Elixir.Jason.Encoder.beam and b/_build/dev/consolidated/Elixir.Jason.Encoder.beam differ diff --git a/_build/dev/consolidated/Elixir.Phoenix.HTML.FormData.beam b/_build/dev/consolidated/Elixir.Phoenix.HTML.FormData.beam index 2e34c1d..5353e52 100644 Binary files a/_build/dev/consolidated/Elixir.Phoenix.HTML.FormData.beam and b/_build/dev/consolidated/Elixir.Phoenix.HTML.FormData.beam differ diff --git a/_build/dev/consolidated/Elixir.Phoenix.HTML.Safe.beam b/_build/dev/consolidated/Elixir.Phoenix.HTML.Safe.beam index b6bf71f..e7b72f1 100644 Binary files a/_build/dev/consolidated/Elixir.Phoenix.HTML.Safe.beam and b/_build/dev/consolidated/Elixir.Phoenix.HTML.Safe.beam differ diff --git a/_build/dev/consolidated/Elixir.Plug.Exception.beam b/_build/dev/consolidated/Elixir.Plug.Exception.beam index f8e0db3..25188b6 100644 Binary files a/_build/dev/consolidated/Elixir.Plug.Exception.beam and b/_build/dev/consolidated/Elixir.Plug.Exception.beam differ diff --git a/_build/dev/lib/centralcloud_core/.mix/compile.elixir b/_build/dev/lib/centralcloud_core/.mix/compile.elixir index 40fa06d..6e7415c 100644 Binary files a/_build/dev/lib/centralcloud_core/.mix/compile.elixir and b/_build/dev/lib/centralcloud_core/.mix/compile.elixir differ diff --git a/_build/dev/lib/centralcloud_core/ebin/centralcloud_core.app b/_build/dev/lib/centralcloud_core/ebin/centralcloud_core.app index ac67ad8..efb104d 100644 --- a/_build/dev/lib/centralcloud_core/ebin/centralcloud_core.app +++ b/_build/dev/lib/centralcloud_core/ebin/centralcloud_core.app @@ -1,9 +1 @@ -{application,centralcloud_core, - [{modules,['Elixir.CentralcloudCore.DrPortal', - 'Elixir.CentralcloudCore.HostBill', - 'Elixir.CentralcloudCore.OnCall']}, - {optional_applications,[]}, - {applications,[kernel,stdlib,elixir,logger,req,jason,jose]}, - {description,"centralcloud_core"}, - {registered,[]}, - {vsn,"0.1.0"}]}. +{application,centralcloud_core,[{modules,['Elixir.CentralcloudCore.DrPortal','Elixir.CentralcloudCore.HostBill','Elixir.CentralcloudCore.OnCall','Elixir.CentralcloudCore.Security','Elixir.CentralcloudCore.WazuhMcp']},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,req,jason,jose]},{description,"centralcloud_core"},{registered,[]},{vsn,"0.1.0"}]}. \ No newline at end of file diff --git a/_build/dev/lib/centralcloud_my/.mix/compile.app_cache b/_build/dev/lib/centralcloud_my/.mix/compile.app_cache index d6a1528..0bd283b 100644 Binary files a/_build/dev/lib/centralcloud_my/.mix/compile.app_cache and b/_build/dev/lib/centralcloud_my/.mix/compile.app_cache differ diff --git a/_build/dev/lib/centralcloud_my/.mix/compile.elixir b/_build/dev/lib/centralcloud_my/.mix/compile.elixir index 2a1b642..553f4a1 100644 Binary files a/_build/dev/lib/centralcloud_my/.mix/compile.elixir and b/_build/dev/lib/centralcloud_my/.mix/compile.elixir differ diff --git a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Layouts.beam b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Layouts.beam index fbdfd91..67dc52e 100644 Binary files a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Layouts.beam and b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Layouts.beam differ diff --git a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.Helpers.beam b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.Helpers.beam index bcb03c9..e1e818b 100644 Binary files a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.Helpers.beam and b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.Helpers.beam differ diff --git a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.beam b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.beam index 2ab46a8..5335339 100644 Binary files a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.beam and b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.Router.beam differ diff --git a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.SessionController.beam b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.SessionController.beam index 00a1351..96f78f2 100644 Binary files a/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.SessionController.beam and b/_build/dev/lib/centralcloud_my/ebin/Elixir.CentralcloudMy.SessionController.beam differ diff --git a/_build/dev/lib/centralcloud_my/ebin/centralcloud_my.app b/_build/dev/lib/centralcloud_my/ebin/centralcloud_my.app index 9b2eff5..fce31b5 100644 --- a/_build/dev/lib/centralcloud_my/ebin/centralcloud_my.app +++ b/_build/dev/lib/centralcloud_my/ebin/centralcloud_my.app @@ -1,35 +1 @@ -{application,centralcloud_my, - [{modules, - ['Elixir.CentralcloudMy.Application', - 'Elixir.CentralcloudMy.BillingLive', - 'Elixir.CentralcloudMy.DashboardLive', - 'Elixir.CentralcloudMy.Endpoint', - 'Elixir.CentralcloudMy.HealthController', - 'Elixir.CentralcloudMy.Layouts', - 'Elixir.CentralcloudMy.Plugs.RequireAuth', - 'Elixir.CentralcloudMy.Release', - 'Elixir.CentralcloudMy.ReplicationLive', - 'Elixir.CentralcloudMy.Router', - 'Elixir.CentralcloudMy.Router.Helpers', - 'Elixir.CentralcloudMy.SessionController', - 'Elixir.CentralcloudMy.SupportLive']}, - {compile_env, - [{centralcloud_my, - ['Elixir.CentralcloudMy.Endpoint',code_reloader], - {ok,true}}, - {centralcloud_my, - ['Elixir.CentralcloudMy.Endpoint',debug_errors], - {ok,true}}, - {centralcloud_my, - ['Elixir.CentralcloudMy.Endpoint',force_ssl], - error}]}, - {optional_applications,[]}, - {applications, - [kernel,stdlib,elixir,logger,runtime_tools,centralcloud_core,phoenix, - phoenix_live_view,phoenix_html,phoenix_live_reload,bandit,ecto_sql, - postgrex,swoosh,finch,telemetry_metrics,telemetry_poller,jason, - dns_cluster,oidcc]}, - {description,"centralcloud_my"}, - {registered,[]}, - {vsn,"0.1.0"}, - {mod,{'Elixir.CentralcloudMy.Application',[]}}]}. +{application,centralcloud_my,[{modules,['Elixir.CentralcloudMy.Application','Elixir.CentralcloudMy.BillingLive','Elixir.CentralcloudMy.DashboardLive','Elixir.CentralcloudMy.Endpoint','Elixir.CentralcloudMy.HealthController','Elixir.CentralcloudMy.Layouts','Elixir.CentralcloudMy.Plugs.RequireAuth','Elixir.CentralcloudMy.Release','Elixir.CentralcloudMy.ReplicationLive','Elixir.CentralcloudMy.Router','Elixir.CentralcloudMy.Router.Helpers','Elixir.CentralcloudMy.SecurityLive','Elixir.CentralcloudMy.SessionController','Elixir.CentralcloudMy.SupportLive','Elixir.CentralcloudMyWeb']},{compile_env,[{centralcloud_my,['Elixir.CentralcloudMy.Endpoint',code_reloader],{ok,true}},{centralcloud_my,['Elixir.CentralcloudMy.Endpoint',debug_errors],{ok,true}},{centralcloud_my,['Elixir.CentralcloudMy.Endpoint',force_ssl],error}]},{optional_applications,[]},{applications,[kernel,stdlib,elixir,logger,runtime_tools,centralcloud_core,phoenix,phoenix_live_view,phoenix_html,phoenix_live_reload,bandit,ecto_sql,postgrex,swoosh,finch,telemetry_metrics,telemetry_poller,jason,dns_cluster,oidcc,petal_components]},{description,"centralcloud_my"},{registered,[]},{vsn,"0.1.0"},{mod,{'Elixir.CentralcloudMy.Application',[]}}]}. \ No newline at end of file diff --git a/apps/centralcloud_core/lib/centralcloud_core/oncall.ex b/apps/centralcloud_core/lib/centralcloud_core/oncall.ex index 15060d3..b86ed83 100644 --- a/apps/centralcloud_core/lib/centralcloud_core/oncall.ex +++ b/apps/centralcloud_core/lib/centralcloud_core/oncall.ex @@ -113,6 +113,6 @@ defmodule CentralcloudCore.OnCall do defp auth_headers do cfg = Application.fetch_env!(:centralcloud_core, :oncall) - [{"Authorization", "Token #{cfg[:token]}"}] + [{"Authorization", to_string(cfg[:token])}] end end diff --git a/apps/centralcloud_core/lib/centralcloud_core/security.ex b/apps/centralcloud_core/lib/centralcloud_core/security.ex new file mode 100644 index 0000000..f1caca7 --- /dev/null +++ b/apps/centralcloud_core/lib/centralcloud_core/security.ex @@ -0,0 +1,395 @@ +defmodule CentralcloudCore.Security do + @moduledoc """ + Client for the endpoint security monitoring platform. + + Provides customer-scoped agent listing, vulnerability counts, alert queries, + SCA compliance results, and HostBill service-gate logic for the Security Center. + + Customers are grouped by the convention `cc_client_`. + """ + + alias CentralcloudCore.HostBill + + # --------------------------------------------------------------------------- + # Public API — agents + # --------------------------------------------------------------------------- + + @doc "Return all monitored endpoints for a given customer." + def get_monitored_agents(customer_id) when is_integer(customer_id) do + with {:ok, token} <- authenticate() do + result = + do_get(token, "/agents", %{ + groups: customer_group(customer_id), + limit: 500, + select: "id,name,status,ip,version,lastKeepAlive,os.name,os.platform" + }) + + case result do + {:ok, %{"data" => %{"affected_items" => items}}} -> {:ok, items} + {:ok, _} -> {:ok, []} + err -> err + end + end + end + + @doc """ + List agents with optional scoping. + + Options: + - `:customer_id` — integer, scopes to cc_client_{id} group + - `:status` — string, e.g. "active", "disconnected" + - `:limit` — integer, max results (default 100, max 500) + """ + def list_agents(opts \\ []) do + customer_id = Keyword.get(opts, :customer_id) + status = Keyword.get(opts, :status) + limit = min(Keyword.get(opts, :limit, 100), 500) + + params = + %{ + limit: limit, + select: "id,name,status,ip,version,lastKeepAlive,os.name,os.platform,groups" + } + |> maybe_put(:groups, customer_id && customer_group(customer_id)) + |> maybe_put(:status, status) + + with {:ok, token} <- authenticate(), + {:ok, body} <- do_get(token, "/agents", params) do + items = get_in(body, ["data", "affected_items"]) || [] + total = get_in(body, ["data", "total_affected_items"]) || length(items) + {:ok, %{total: total, agents: items}} + end + end + + @doc "Connectivity health summary: active / disconnected / never_connected counts." + def agent_summary(opts \\ []) do + with {:ok, %{agents: agents}} <- list_agents(Keyword.put(opts, :limit, 500)) do + by_status = Enum.frequencies_by(agents, & &1["status"]) + + disconnected = + Enum.filter(agents, &(&1["status"] in ["disconnected", "never_connected"])) + |> Enum.map(&Map.take(&1, ["id", "name", "ip", "status", "lastKeepAlive"])) + + {:ok, %{total: length(agents), by_status: by_status, disconnected_agents: disconnected}} + end + end + + # --------------------------------------------------------------------------- + # Public API — vulnerabilities + # --------------------------------------------------------------------------- + + @doc """ + Return vulnerability counts by severity for a specific agent ID. + """ + def get_agent_vulnerability_counts(agent_id) do + with {:ok, token} <- authenticate() do + result = + do_get(token, "/vulnerability/#{agent_id}", %{ + limit: 500, + distinct: true, + select: "cve,severity" + }) + + case result do + {:ok, %{"data" => %{"affected_items" => items}}} -> + counts = + Enum.frequencies_by(items, fn item -> + String.downcase(item["severity"] || "unknown") + end) + + {:ok, counts} + + _ -> + {:ok, %{}} + end + end + end + + @doc """ + Return CVE vulnerabilities for a specific agent. + + Options: + - `:severity` — "Critical", "High", "Medium", "Low" + - `:limit` — max results (default 100) + """ + def get_vulnerabilities(agent_id, opts \\ []) do + severity = Keyword.get(opts, :severity) + limit = min(Keyword.get(opts, :limit, 100), 500) + + params = + %{ + limit: limit, + distinct: true, + select: "cve,severity,cvss3_score,cvss2_score,name,version,architecture,title,published" + } + |> maybe_put(:severity, severity) + + with {:ok, token} <- authenticate(), + {:ok, body} <- do_get(token, "/vulnerability/#{agent_id}", params) do + items = get_in(body, ["data", "affected_items"]) || [] + total = get_in(body, ["data", "total_affected_items"]) || length(items) + {:ok, %{agent_id: agent_id, total: total, vulnerabilities: items}} + end + end + + @doc """ + Top N critical CVEs across the fleet (or a customer), ranked by + affected-host count then CVSS score. + """ + def top_critical_cves(opts \\ []) do + top_n = Keyword.get(opts, :top_n, 20) + + with {:ok, %{agents: agents}} <- list_agents(Keyword.put(opts, :status, "active") |> Keyword.put(:limit, 500)) do + cve_map = + Enum.reduce(agents, %{}, fn agent, acc -> + aid = agent["id"] + + case get_vulnerabilities(aid, severity: "Critical", limit: 500) do + {:ok, %{vulnerabilities: vulns}} -> + Enum.reduce(vulns, acc, fn v, inner -> + cve_id = v["cve"] + + if cve_id do + entry = + Map.get_lazy(inner, cve_id, fn -> + %{ + cve: cve_id, + title: v["title"], + severity: v["severity"], + cvss3_score: v["cvss3_score"], + cvss2_score: v["cvss2_score"], + affected_agents: [] + } + end) + + updated = + Map.update!(entry, :affected_agents, fn agents -> + [%{id: aid, name: agent["name"]} | agents] + end) + + Map.put(inner, cve_id, updated) + else + inner + end + end) + + _ -> + acc + end + end) + + ranked = + cve_map + |> Map.values() + |> Enum.sort_by( + fn c -> + {length(c.affected_agents), + c.cvss3_score || c.cvss2_score || 0.0} + end, + :desc + ) + |> Enum.take(top_n) + + {:ok, + %{ + scanned_agents: length(agents), + unique_critical_cves: map_size(cve_map), + top_cves: ranked + }} + end + end + + # --------------------------------------------------------------------------- + # Public API — alerts + # --------------------------------------------------------------------------- + + @doc """ + Return recent SIEM alerts. + + Options: + - `:customer_id` — scope to a customer's agents + - `:agent_id` — scope to a single agent + - `:level` — minimum rule level (default 7) + - `:limit` — max results (default 50) + """ + def get_alerts(opts \\ []) do + customer_id = Keyword.get(opts, :customer_id) + agent_id = Keyword.get(opts, :agent_id) + level = Keyword.get(opts, :level, 7) + limit = min(Keyword.get(opts, :limit, 50), 500) + + params = %{ + limit: limit, + sort: "-timestamp", + select: + "id,timestamp,agent.id,agent.name,rule.id,rule.description,rule.level,rule.groups,data.srcip,data.dstuser", + level: "#{level}-15" + } + + with {:ok, token} <- authenticate() do + params = + cond do + agent_id -> + Map.put(params, :agents_list, agent_id) + + customer_id -> + case list_agents(customer_id: customer_id, limit: 500) do + {:ok, %{agents: agents}} -> + ids = Enum.map_join(agents, ",", & &1["id"]) + if ids == "", do: :empty, else: Map.put(params, :agents_list, ids) + + _ -> + params + end + + true -> + params + end + + case params do + :empty -> + {:ok, %{total: 0, alerts: []}} + + params -> + case do_get(token, "/alerts", params) do + {:ok, body} -> + items = get_in(body, ["data", "affected_items"]) || [] + total = get_in(body, ["data", "total_affected_items"]) || length(items) + {:ok, %{total: total, alerts: items}} + + err -> + err + end + end + end + end + + # --------------------------------------------------------------------------- + # Public API — SCA (CIS compliance) + # --------------------------------------------------------------------------- + + @doc """ + Return SCA/CIS benchmark results for an agent. + + If `policy_id` is given, returns individual check results for that policy. + Otherwise returns all policy summaries. + """ + def get_sca(agent_id, opts \\ []) do + policy_id = Keyword.get(opts, :policy_id) + + with {:ok, token} <- authenticate() do + if policy_id do + case do_get(token, "/sca/#{agent_id}/checks/#{policy_id}", %{limit: 500}) do + {:ok, body} -> + items = get_in(body, ["data", "affected_items"]) || [] + failed = Enum.filter(items, &(&1["result"] == "failed")) + + {:ok, + %{ + agent_id: agent_id, + policy_id: policy_id, + total_checks: length(items), + failed_checks: length(failed), + failed: failed + }} + + err -> + err + end + else + case do_get(token, "/sca/#{agent_id}", %{limit: 100}) do + {:ok, body} -> + policies = get_in(body, ["data", "affected_items"]) || [] + {:ok, %{agent_id: agent_id, policies: policies}} + + err -> + err + end + end + end + end + + # --------------------------------------------------------------------------- + # HostBill service gating + # --------------------------------------------------------------------------- + + @doc """ + Return true when the list of HostBill service products includes a security + monitoring subscription. Matches product names containing "security", + "threat", or "monitor" (case-insensitive). + """ + def has_security_service?(services) when is_list(services) do + Enum.any?(services, fn svc -> + name = String.downcase(svc["name"] || "") + String.contains?(name, "security") or + String.contains?(name, "threat") or + String.contains?(name, "monitor") + end) + end + + def has_security_service?(_), do: false + + @doc "Fetch HostBill services for a customer and gate on security service." + def customer_has_security_service?(customer_id) when is_integer(customer_id) do + case HostBill.get_client_services(customer_id) do + {:ok, %{"products" => products}} -> has_security_service?(products) + _ -> :unavailable + end + end + + @doc "The monitoring platform group name for a customer." + def customer_group(customer_id), do: "cc_client_#{customer_id}" + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp authenticate do + cfg = config() + url = cfg[:url] + credential = Base.encode64("#{cfg[:username]}:#{cfg[:password]}") + + case Req.post( + "#{url}/security/user/authenticate?raw=true", + headers: [{"authorization", "Basic #{credential}"}], + connect_options: ssl_opts(cfg) + ) do + {:ok, %{status: 200, body: token}} when is_binary(token) -> + {:ok, String.trim(token)} + + {:ok, %{status: status}} -> + {:error, {:auth_failed, status}} + + {:error, reason} -> + {:error, {:connect_error, reason}} + end + end + + defp do_get(token, path, params) do + cfg = config() + + case Req.get( + "#{cfg[:url]}#{path}", + headers: [{"authorization", "Bearer #{token}"}], + params: params, + connect_options: ssl_opts(cfg) + ) do + {:ok, %{status: 200, body: body}} -> {:ok, body} + {:ok, %{status: status}} -> {:error, {:http, status}} + {:error, reason} -> {:error, {:connect_error, reason}} + end + end + + defp ssl_opts(cfg) do + if cfg[:verify_ssl] == false do + [transport_opts: [verify: :verify_none]] + else + [] + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp config, do: Application.fetch_env!(:centralcloud_core, :security) +end diff --git a/apps/centralcloud_core/lib/centralcloud_core/wazuh_mcp.ex b/apps/centralcloud_core/lib/centralcloud_core/wazuh_mcp.ex new file mode 100644 index 0000000..004964a --- /dev/null +++ b/apps/centralcloud_core/lib/centralcloud_core/wazuh_mcp.ex @@ -0,0 +1,225 @@ +defmodule CentralcloudCore.WazuhMcp do + @moduledoc """ + MCP (Model Context Protocol) server implementation for Wazuh endpoint security. + + Implements the JSON-RPC 2.0 / MCP streamable-HTTP protocol so that the + Hermes security-analyst agent can call Wazuh tools directly from the + centralcloud_staff app — no separate Python MCP sidecar required. + + Supported methods: + initialize — handshake, returns capabilities + tools/list — enumerates available tools + JSON schemas + tools/call — dispatches a named tool call and returns the result + + All tools are read-only wrappers around CentralcloudCore.Security. + """ + + alias CentralcloudCore.Security + + @protocol_version "2024-11-05" + @server_info %{"name" => "wazuh-mcp", "version" => "1.0.0"} + + @tools [ + %{ + "name" => "list_agents", + "description" => + "List monitored endpoints, optionally scoped to a customer. " <> + "Pass customer_id (HostBill integer) to scope to cc_client_{id}.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "customer_id" => %{"type" => "integer", "description" => "HostBill customer ID"}, + "status" => %{ + "type" => "string", + "enum" => ["active", "disconnected", "pending", "never_connected"], + "description" => "Filter by agent status" + }, + "limit" => %{"type" => "integer", "description" => "Max agents (default 100, max 500)"} + } + } + }, + %{ + "name" => "get_vulnerabilities", + "description" => + "Return CVE vulnerabilities for a specific Wazuh agent, filterable by severity.", + "inputSchema" => %{ + "type" => "object", + "required" => ["agent_id"], + "properties" => %{ + "agent_id" => %{"type" => "string", "description" => "Wazuh agent ID, e.g. \"001\""}, + "severity" => %{ + "type" => "string", + "enum" => ["Critical", "High", "Medium", "Low"], + "description" => "Filter by severity" + }, + "limit" => %{"type" => "integer", "description" => "Max CVEs (default 100)"} + } + } + }, + %{ + "name" => "get_alerts", + "description" => + "Return recent SIEM alerts at or above a rule level, optionally scoped to a customer or agent.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "customer_id" => %{"type" => "integer", "description" => "Scope to a customer's agents"}, + "agent_id" => %{"type" => "string", "description" => "Scope to a single agent"}, + "level" => %{ + "type" => "integer", + "description" => "Minimum rule level 0-15 (default 7)" + }, + "limit" => %{"type" => "integer", "description" => "Max alerts (default 50)"} + } + } + }, + %{ + "name" => "get_sca", + "description" => + "Return SCA/CIS benchmark compliance results for an agent. " <> + "Omit policy_id to list all policies; provide it for per-check detail.", + "inputSchema" => %{ + "type" => "object", + "required" => ["agent_id"], + "properties" => %{ + "agent_id" => %{"type" => "string", "description" => "Wazuh agent ID"}, + "policy_id" => %{ + "type" => "string", + "description" => "SCA policy ID for per-check results" + } + } + } + }, + %{ + "name" => "agent_summary", + "description" => + "Connectivity health: counts of active / disconnected / never-connected agents.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "customer_id" => %{"type" => "integer", "description" => "Scope to a customer. Omit for all."} + } + } + }, + %{ + "name" => "top_critical_cves", + "description" => + "Top N Critical CVEs ranked by affected-host count + CVSS score across all active agents.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "customer_id" => %{"type" => "integer", "description" => "Scope to a customer. Omit for all."}, + "top_n" => %{"type" => "integer", "description" => "Number of CVEs to return (default 20)"} + } + } + } + ] + + # --------------------------------------------------------------------------- + # Dispatch + # --------------------------------------------------------------------------- + + @doc "Handle a single JSON-RPC request map. Returns a JSON-RPC result map." + def handle(%{"method" => "initialize", "id" => id}) do + result(%{ + "protocolVersion" => @protocol_version, + "capabilities" => %{"tools" => %{}}, + "serverInfo" => @server_info + }, id) + end + + def handle(%{"method" => "tools/list", "id" => id}) do + result(%{"tools" => @tools}, id) + end + + def handle(%{"method" => "tools/call", "id" => id, "params" => %{"name" => name} = params}) do + args = Map.get(params, "arguments", %{}) + + case call_tool(name, args) do + {:ok, content} -> + result(%{"content" => [%{"type" => "text", "text" => Jason.encode!(content)}]}, id) + + {:error, reason} -> + error_result(-32_000, format_error(reason), id) + end + end + + def handle(%{"method" => method, "id" => id}) do + error_result(-32_601, "Method not found: #{method}", id) + end + + def handle(_), do: error_result(-32_600, "Invalid request", nil) + + # --------------------------------------------------------------------------- + # Tool implementations + # --------------------------------------------------------------------------- + + defp call_tool("list_agents", args) do + opts = + [] + |> maybe_opt(:customer_id, args["customer_id"]) + |> maybe_opt(:status, args["status"]) + |> maybe_opt(:limit, args["limit"]) + + Security.list_agents(opts) + end + + defp call_tool("get_vulnerabilities", %{"agent_id" => agent_id} = args) do + opts = + [] + |> maybe_opt(:severity, args["severity"]) + |> maybe_opt(:limit, args["limit"]) + + Security.get_vulnerabilities(agent_id, opts) + end + + defp call_tool("get_alerts", args) do + opts = + [] + |> maybe_opt(:customer_id, args["customer_id"]) + |> maybe_opt(:agent_id, args["agent_id"]) + |> maybe_opt(:level, args["level"]) + |> maybe_opt(:limit, args["limit"]) + + Security.get_alerts(opts) + end + + defp call_tool("get_sca", %{"agent_id" => agent_id} = args) do + opts = maybe_opt([], :policy_id, args["policy_id"]) + Security.get_sca(agent_id, opts) + end + + defp call_tool("agent_summary", args) do + opts = maybe_opt([], :customer_id, args["customer_id"]) + Security.agent_summary(opts) + end + + defp call_tool("top_critical_cves", args) do + opts = + [] + |> maybe_opt(:customer_id, args["customer_id"]) + |> maybe_opt(:top_n, args["top_n"]) + + Security.top_critical_cves(opts) + end + + defp call_tool(name, _args), do: {:error, "Unknown tool: #{name}"} + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp result(data, id), do: %{"jsonrpc" => "2.0", "id" => id, "result" => data} + + defp error_result(code, message, id), + do: %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => code, "message" => message}} + + defp maybe_opt(opts, _key, nil), do: opts + defp maybe_opt(opts, key, value), do: Keyword.put(opts, key, value) + + defp format_error({:http, status}), do: "Wazuh API returned HTTP #{status}" + defp format_error({:auth_failed, status}), do: "Wazuh authentication failed (#{status})" + defp format_error({:connect_error, reason}), do: "Connection error: #{inspect(reason)}" + defp format_error(reason) when is_binary(reason), do: reason + defp format_error(reason), do: inspect(reason) +end diff --git a/apps/centralcloud_my/assets/css/app.css b/apps/centralcloud_my/assets/css/app.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/apps/centralcloud_my/assets/css/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/centralcloud_my/assets/tailwind.config.js b/apps/centralcloud_my/assets/tailwind.config.js new file mode 100644 index 0000000..b7f16a3 --- /dev/null +++ b/apps/centralcloud_my/assets/tailwind.config.js @@ -0,0 +1,14 @@ +module.exports = { + darkMode: "class", + content: [ + "apps/centralcloud_my/lib/**/*.{ex,heex}", + "deps/petal_components/**/*.ex", + ], + safelist: [ + { pattern: /^(bg|text|border)-(red|orange|amber|yellow|green|sky|blue|indigo|purple|slate|zinc|gray)-(100|200|300|400|500|600|700|800|900|950)$/ }, + ], + theme: { + extend: {}, + }, + plugins: [require("@tailwindcss/forms")], +}; diff --git a/apps/centralcloud_my/lib/centralcloud_my/layouts.ex b/apps/centralcloud_my/lib/centralcloud_my/layouts.ex index f71ca9b..41e4931 100644 --- a/apps/centralcloud_my/lib/centralcloud_my/layouts.ex +++ b/apps/centralcloud_my/lib/centralcloud_my/layouts.ex @@ -4,46 +4,68 @@ defmodule CentralcloudMy.Layouts do def render("root.html", assigns) do ~H""" - + - CentralCloud - + <.live_title suffix=" — CentralCloud"> {assigns[:page_title] || "My CentralCloud"} - -