diff --git a/README.md b/README.md
index 186f709..e211211 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ This project is intended to help teams investigate a missing security layer betw
| Pluggable model advisor | OpenAI Responses API, OpenAI-compatible endpoints, or a generic model gateway webhook | Adds an independent structured intent recommendation without making a model the enforcement authority |
| Protected integration API | OWASP CRS WAF, backend network isolation, bearer-token ingestion, and bounded requests | Reduces attack surface for the security signals that influence policy |
| Repeatable deployment | Docker Compose, Terraform, and Ansible | Makes the POC reproducible for labs, demos, and controlled evaluations |
+| Fleet administration | Endpoint inventory, security-group discovery, deployment plans, and agent-pull jobs | Demonstrates controlled network distribution without exposing arbitrary remote shell execution |
## Where it can be used
@@ -214,6 +215,8 @@ intentgate: BLOCK risk=100
| `uig-service` | Start signal-ingestion and metrics endpoints |
| `uig-collector config/integrations.json` | Poll configured security sources |
| `uig-notifier` | Deliver queued manager risk reports |
+| `uig-admin discover` | Snapshot enrolled endpoints and security groups over the authenticated API |
+| `uig-admin deploy --group engineering --version 0.4.0 --execute` | Queue the fixed install manifest to a discovered security group |
Exit codes are `0` for success, `2` for a non-allow dry run, `125` for review not approved, `126` for a blocked command, and `127` when the executable is missing.
@@ -300,8 +303,18 @@ Use **Set API token** in the console to enter the configured bearer token. The t
- Versioned, locally persisted review and block thresholds
- Versioned zero-trust step-up controls and a micro-segmentation flow designer
- An inspectable destructive-action rule catalog
+- Fleet discovery with endpoint, platform, security-group, online, and installed-version coverage
+- Dry-run and queued deployment waves for a fixed Intent Gate installation manifest
+- Endpoint security policy coverage for antivirus, malware prevention, DLP, behavior monitoring, and vulnerability/CVE scanning
+- A group-oriented inventory explorer showing policy assignments and every endpoint in each security group
-The console is intentionally **assessment-only**. Approving a review records human authorization but never launches the command from the browser. A trusted command broker remains the required production execution boundary.
+The Command Lab and review workflow are intentionally **assessment-only**. Approving a command review records human authorization but never launches that command from the browser. Fleet Admin can separately queue only the fixed software-install manifest; a trusted endpoint agent remains the required deployment execution boundary.
+
+### Fleet Admin
+
+The **Fleet Admin** section discovers enrolled endpoints and security groups, shows agent coverage, previews the equivalent `uig-admin` network command, and plans or queues deployment waves. Discovery is based on registered inventory and heartbeats rather than blind network scanning. Deployment jobs use a fixed `install-or-upgrade-intentgate` manifest and an agent-pull transport; arbitrary remote commands are not accepted.
+
+For command examples, endpoint-agent API routes, safety properties, and production requirements, see [Fleet Administration](docs/FLEET_ADMIN.md).
HTTP assessments use a standard `console-operator` execution context by default rather than inheriting the container service account's root identity. A trusted broker can submit an explicit `execution_context` containing the originating user and privilege level; production enforcement must authenticate that context rather than accepting it directly from an untrusted client.
diff --git a/docs/FLEET_ADMIN.md b/docs/FLEET_ADMIN.md
new file mode 100644
index 0000000..2427c78
--- /dev/null
+++ b/docs/FLEET_ADMIN.md
@@ -0,0 +1,66 @@
+# Fleet administration
+
+The Fleet Admin POC adds an authenticated deployment control plane to the Intent Gate service. It demonstrates scoped software distribution without turning the console into a general-purpose remote shell.
+
+## How it works
+
+1. Endpoint agents register hostname, address, operating system, security groups, and installed version with `POST /v1/endpoints/register`.
+2. A discovery session snapshots enrolled inventory and summarizes group, online, and managed coverage.
+3. An administrator targets a security group or explicit endpoint IDs and creates a dry-run plan.
+4. An executed plan creates one fixed `install-or-upgrade-intentgate` manifest per endpoint.
+5. Endpoint agents poll `GET /v1/deployment-jobs/next?endpoint_id=...` and report lifecycle state to `POST /v1/deployment-jobs/{job_id}`.
+
+The repository implements the control plane, API contract, demo inventory, network CLI, and UI. A production endpoint agent and artifact repository are intentionally separate trust components.
+
+## Network administration command
+
+Set the bearer token without putting it in shell history:
+
+```powershell
+$env:UIG_ADMIN_SERVER = "https://intentgate.example"
+$env:UIG_INGEST_TOKEN = "replace-with-a-secret-from-your-vault"
+```
+
+Run discovery and inspect inventory:
+
+```powershell
+uig-admin discover
+uig-admin inventory
+```
+
+Plan a deployment, then explicitly queue it:
+
+```powershell
+uig-admin deploy --group engineering --version 0.4.0
+uig-admin deploy --group engineering --version 0.4.0 --execute
+uig-admin deployments
+```
+
+Repeat `--endpoint` to target explicit enrolled endpoint IDs instead of a group. The token is accepted through `--token`, but environment or secret-store injection is preferred.
+
+## API routes
+
+| Route | Purpose |
+|---|---|
+| `GET /v1/endpoints` | Inventory, security groups, and coverage summary |
+| `POST /v1/endpoints/register` | Agent registration and heartbeat |
+| `POST /v1/discovery-sessions` | Create an inventory snapshot |
+| `GET /v1/deployments` | Recent deployment waves |
+| `POST /v1/deployments` | Plan or queue a scoped deployment |
+| `GET /v1/deployment-jobs/next` | Fetch the next queued endpoint manifest |
+| `POST /v1/deployment-jobs/{id}` | Record endpoint deployment state |
+
+Every route requires the existing bearer token. WAF, rate limiting, enterprise identity, and separate endpoint credentials should be applied before this control plane is exposed beyond a lab.
+
+## Safety properties
+
+- No endpoint accepts arbitrary command text from this API.
+- Deployment jobs contain a fixed package/action manifest.
+- Planning is the default; queueing requires the explicit `execute` field or `--execute` flag.
+- Offline endpoints are deferred rather than treated as successful.
+- Every deployment records the selector, requestor, target version, endpoint jobs, timestamps, and state transitions.
+- Discovery uses enrolled inventory and heartbeats; it does not perform unauthenticated subnet scanning.
+
+## Production additions
+
+Before real enterprise rollout, add mutual TLS and per-agent identity, signed artifacts and manifests, RBAC with approval separation, maintenance windows, phased rings/canaries, health-based pause and rollback, durable encrypted storage, rate limits, immutable audit export, inventory connectors for Entra ID/Intune/AD/CMDB, and an endpoint service that verifies signatures before installation.
diff --git a/pyproject.toml b/pyproject.toml
index 4b35852..982fd36 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ uig-scan = "intentgate.provenance:main"
uig-service = "intentgate.service:main"
uig-collector = "intentgate.collector:main"
uig-notifier = "intentgate.notifier:main"
+uig-admin = "intentgate.admin_cli:main"
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/intentgate/admin_cli.py b/src/intentgate/admin_cli.py
new file mode 100644
index 0000000..38a99bb
--- /dev/null
+++ b/src/intentgate/admin_cli.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+
+
+def _request(server: str, token: str, path: str, method: str = "GET", body: object | None = None) -> dict:
+ payload = json.dumps(body).encode("utf-8") if body is not None else None
+ request = urllib.request.Request(
+ server.rstrip("/") + path,
+ data=payload,
+ method=method,
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=15) as response:
+ return json.loads(response.read())
+ except urllib.error.HTTPError as exc:
+ try:
+ detail = json.loads(exc.read()).get("error", exc.reason)
+ except (json.JSONDecodeError, AttributeError):
+ detail = exc.reason
+ raise SystemExit(f"Intent Gate admin request failed: {detail}") from exc
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="uig-admin", description="Intent Gate network administration client")
+ parser.add_argument("--server", default=os.environ.get("UIG_ADMIN_SERVER", "http://127.0.0.1:8787"))
+ parser.add_argument("--token", default=os.environ.get("UIG_INGEST_TOKEN", ""))
+ sub = parser.add_subparsers(dest="action", required=True)
+ sub.add_parser("inventory", help="List discovered endpoints and security groups")
+ sub.add_parser("discover", help="Create an enrolled-endpoint discovery session")
+ deployments = sub.add_parser("deployments", help="List recent deployment waves")
+ deployments.add_argument("--limit", type=int, default=20)
+ deploy = sub.add_parser("deploy", help="Plan or queue the fixed Intent Gate deployment manifest")
+ deploy.add_argument("--group", help="Target a security group")
+ deploy.add_argument("--endpoint", action="append", default=[], help="Target an endpoint id; repeat as needed")
+ deploy.add_argument("--version", default="0.4.0")
+ deploy.add_argument("--execute", action="store_true", help="Queue jobs; omission creates a dry-run plan")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = _parser().parse_args(argv)
+ if not args.token:
+ raise SystemExit("Set UIG_INGEST_TOKEN or pass --token")
+ if args.action == "inventory":
+ result = _request(args.server, args.token, "/v1/endpoints")
+ elif args.action == "discover":
+ result = _request(args.server, args.token, "/v1/discovery-sessions", "POST", {"requested_by": "network-cli"})
+ elif args.action == "deployments":
+ result = _request(args.server, args.token, f"/v1/deployments?limit={max(1, min(args.limit, 250))}")
+ else:
+ result = _request(args.server, args.token, "/v1/deployments", "POST", {
+ "security_group": args.group,
+ "endpoint_ids": args.endpoint,
+ "version": args.version,
+ "execute": args.execute,
+ "requested_by": "network-cli",
+ })
+ print(json.dumps(result, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/intentgate/deployments.py b/src/intentgate/deployments.py
new file mode 100644
index 0000000..2c09e5d
--- /dev/null
+++ b/src/intentgate/deployments.py
@@ -0,0 +1,276 @@
+from __future__ import annotations
+
+import json
+import os
+import re
+import time
+import uuid
+from pathlib import Path
+from threading import Lock
+from typing import Any
+
+
+_LOCK = Lock()
+_ENDPOINT_ID = re.compile(r"^[a-zA-Z0-9._-]{1,80}$")
+_VERSION = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+-]{0,39}$")
+
+_DEMO_ENDPOINTS = (
+ ("dev-ws-042", "DEV-WS-042", "10.24.10.42", "Windows 11", ("engineering", "windows", "standard-trust"), "0.4.0", "online"),
+ ("dev-ws-117", "DEV-WS-117", "10.24.10.117", "Windows 11", ("engineering", "windows", "privileged-dev"), "0.4.0", "online"),
+ ("fin-lt-019", "FIN-LT-019", "10.24.20.19", "Windows 11", ("finance", "windows", "high-value"), None, "online"),
+ ("ops-pa-003", "OPS-PA-003", "10.24.30.3", "Windows Server 2025", ("operations", "windows", "privileged-access"), "0.3.2", "online"),
+ ("build-lnx-02", "BUILD-LNX-02", "10.24.40.22", "Ubuntu 24.04", ("engineering", "linux", "ci-runners"), "0.4.0", "online"),
+ ("kube-admin-01", "KUBE-ADMIN-01", "10.24.30.11", "Ubuntu 24.04", ("operations", "linux", "cluster-admins"), "0.4.0", "online"),
+ ("sales-lt-088", "SALES-LT-088", "10.24.50.88", "Windows 11", ("sales", "windows", "standard-trust"), None, "offline"),
+ ("sec-lab-07", "SEC-LAB-07", "10.24.60.7", "macOS 15", ("security", "macos", "purple-team"), "0.4.0", "online"),
+)
+
+
+def _state_dir() -> Path:
+ return Path(os.environ.get("UIG_STATE_DIR", Path.home() / ".intentgate"))
+
+
+def _path(name: str) -> Path:
+ return _state_dir() / name
+
+
+def _read(path: Path, default: Any) -> Any:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return default
+
+
+def _write(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
+ temporary.replace(path)
+
+
+def _demo_endpoints() -> list[dict[str, Any]]:
+ now = time.time()
+ return [
+ {
+ "id": endpoint_id,
+ "hostname": hostname,
+ "ip_address": ip_address,
+ "operating_system": operating_system,
+ "security_groups": list(groups),
+ "agent_version": agent_version,
+ "install_state": "managed" if agent_version else "not-installed",
+ "status": status,
+ "last_seen": now - (22 if status == "online" else 7_440),
+ "source": "demo-inventory",
+ }
+ for endpoint_id, hostname, ip_address, operating_system, groups, agent_version, status in _DEMO_ENDPOINTS
+ ]
+
+
+def list_endpoints() -> list[dict[str, Any]]:
+ endpoints = _read(_path("endpoints.json"), None)
+ if not isinstance(endpoints, list) or not endpoints:
+ endpoints = _demo_endpoints()
+ _write(_path("endpoints.json"), endpoints)
+ return sorted((item for item in endpoints if isinstance(item, dict)), key=lambda item: str(item.get("hostname", "")))
+
+
+def register_endpoint(value: object) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ValueError("endpoint registration must be an object")
+ hostname = str(value.get("hostname", "")).strip()[:120]
+ endpoint_id = str(value.get("id") or hostname.lower()).strip()
+ if not hostname or not _ENDPOINT_ID.fullmatch(endpoint_id):
+ raise ValueError("hostname and a valid endpoint id are required")
+ groups = value.get("security_groups", [])
+ if not isinstance(groups, list) or len(groups) > 20:
+ raise ValueError("security_groups must be an array with at most 20 entries")
+ groups = sorted({str(item).strip()[:80] for item in groups if str(item).strip()})
+ endpoint = {
+ "id": endpoint_id,
+ "hostname": hostname,
+ "ip_address": str(value.get("ip_address", "unknown"))[:80],
+ "operating_system": str(value.get("operating_system", "unknown"))[:120],
+ "security_groups": groups,
+ "agent_version": str(value.get("agent_version", ""))[:40] or None,
+ "install_state": str(value.get("install_state", "managed"))[:40],
+ "status": "online",
+ "last_seen": time.time(),
+ "source": str(value.get("source", "agent-registration"))[:80],
+ }
+ with _LOCK:
+ endpoints = list_endpoints()
+ endpoints = [item for item in endpoints if item.get("id") != endpoint_id]
+ endpoints.append(endpoint)
+ _write(_path("endpoints.json"), endpoints[-2_000:])
+ return endpoint
+
+
+def security_groups(endpoints: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
+ selected = endpoints or list_endpoints()
+ names = sorted({str(group) for item in selected for group in item.get("security_groups", [])})
+ return [
+ {
+ "name": name,
+ "endpoint_count": sum(name in item.get("security_groups", []) for item in selected),
+ "online_count": sum(name in item.get("security_groups", []) and item.get("status") == "online" for item in selected),
+ "managed_count": sum(name in item.get("security_groups", []) and bool(item.get("agent_version")) for item in selected),
+ }
+ for name in names
+ ]
+
+
+def inventory() -> dict[str, Any]:
+ endpoints = list_endpoints()
+ sessions = _read(_path("discovery-sessions.json"), [])
+ deployments = list_deployments()
+ return {
+ "endpoints": endpoints,
+ "security_groups": security_groups(endpoints),
+ "summary": {
+ "total": len(endpoints),
+ "online": sum(item.get("status") == "online" for item in endpoints),
+ "managed": sum(bool(item.get("agent_version")) for item in endpoints),
+ "groups": len(security_groups(endpoints)),
+ "queued_jobs": sum(job.get("status") in {"queued", "deferred"} for deployment in deployments for job in deployment.get("jobs", [])),
+ },
+ "latest_discovery": sessions[-1] if isinstance(sessions, list) and sessions else None,
+ }
+
+
+def create_discovery_session(value: object | None = None) -> dict[str, Any]:
+ if value is not None and not isinstance(value, dict):
+ raise ValueError("discovery request must be an object")
+ request = value or {}
+ started = time.time()
+ endpoints = list_endpoints()
+ session = {
+ "id": uuid.uuid4().hex[:12],
+ "created_at": started,
+ "completed_at": time.time(),
+ "status": "completed",
+ "mode": "enrolled-inventory",
+ "requested_by": str(request.get("requested_by", "admin-console"))[:120],
+ "endpoint_count": len(endpoints),
+ "online_count": sum(item.get("status") == "online" for item in endpoints),
+ "security_group_count": len(security_groups(endpoints)),
+ "sources": ["agent-registration", "demo-inventory"],
+ }
+ with _LOCK:
+ sessions = _read(_path("discovery-sessions.json"), [])
+ if not isinstance(sessions, list):
+ sessions = []
+ sessions.append(session)
+ _write(_path("discovery-sessions.json"), sessions[-100:])
+ return {**session, "endpoints": endpoints, "security_groups": security_groups(endpoints)}
+
+
+def _deployment_command(group: str | None, endpoint_ids: list[str], version: str, execute: bool) -> str:
+ selector = f"--group {group}" if group else " ".join(f"--endpoint {item}" for item in endpoint_ids)
+ suffix = " --execute" if execute else ""
+ return f"uig-admin deploy --server http://intentgate.example:8787 {selector} --version {version}{suffix}"
+
+
+def create_deployment(value: object) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ValueError("deployment request must be an object")
+ group = str(value.get("security_group", "")).strip()[:80] or None
+ endpoint_ids_value = value.get("endpoint_ids", [])
+ if not isinstance(endpoint_ids_value, list):
+ raise ValueError("endpoint_ids must be an array")
+ endpoint_ids = sorted({str(item) for item in endpoint_ids_value if _ENDPOINT_ID.fullmatch(str(item))})
+ if not group and not endpoint_ids:
+ raise ValueError("security_group or endpoint_ids is required")
+ version = str(value.get("version", "0.4.0")).strip()
+ if not _VERSION.fullmatch(version):
+ raise ValueError("version contains unsupported characters")
+ execute = bool(value.get("execute", False))
+ endpoints = list_endpoints()
+ selected = [item for item in endpoints if (group and group in item.get("security_groups", [])) or item.get("id") in endpoint_ids]
+ if not selected:
+ raise ValueError("selector did not match any enrolled endpoints")
+ deployment_id = uuid.uuid4().hex[:12]
+ created = time.time()
+ jobs = [
+ {
+ "id": uuid.uuid4().hex[:12],
+ "deployment_id": deployment_id,
+ "endpoint_id": item["id"],
+ "hostname": item["hostname"],
+ "status": "planned" if not execute else ("queued" if item.get("status") == "online" else "deferred"),
+ "transport": "agent-pull",
+ "manifest": {
+ "action": "install-or-upgrade-intentgate",
+ "package": "user-intent-gate",
+ "version": version,
+ "restart_service": True,
+ },
+ "created_at": created,
+ "updated_at": created,
+ }
+ for item in selected
+ ]
+ deployment = {
+ "id": deployment_id,
+ "created_at": created,
+ "requested_by": str(value.get("requested_by", "admin-console"))[:120],
+ "security_group": group,
+ "endpoint_ids": [item["id"] for item in selected],
+ "version": version,
+ "execute": execute,
+ "status": "queued" if execute else "planned",
+ "matched_endpoints": len(selected),
+ "network_command": _deployment_command(group, endpoint_ids or [item["id"] for item in selected], version, execute),
+ "jobs": jobs,
+ }
+ with _LOCK:
+ deployments = list_deployments()
+ deployments.append(deployment)
+ _write(_path("deployments.json"), deployments[-250:])
+ return deployment
+
+
+def list_deployments(limit: int = 50) -> list[dict[str, Any]]:
+ value = _read(_path("deployments.json"), [])
+ if not isinstance(value, list):
+ return []
+ return list(reversed([item for item in value if isinstance(item, dict)][-max(1, min(limit, 250)):]))
+
+
+def next_job(endpoint_id: str) -> dict[str, Any] | None:
+ if not _ENDPOINT_ID.fullmatch(endpoint_id):
+ raise ValueError("invalid endpoint id")
+ for deployment in list_deployments(250):
+ for job in deployment.get("jobs", []):
+ if job.get("endpoint_id") == endpoint_id and job.get("status") == "queued":
+ return job
+ return None
+
+
+def update_job(job_id: str, value: object) -> dict[str, Any] | None:
+ if not isinstance(value, dict):
+ raise ValueError("job update must be an object")
+ status = str(value.get("status", ""))
+ if status not in {"acknowledged", "running", "succeeded", "failed"}:
+ raise ValueError("status must be acknowledged, running, succeeded, or failed")
+ with _LOCK:
+ deployments = list(reversed(list_deployments(250)))
+ selected = None
+ for deployment in deployments:
+ for job in deployment.get("jobs", []):
+ if job.get("id") == job_id:
+ job["status"] = status
+ job["updated_at"] = time.time()
+ job["detail"] = str(value.get("detail", ""))[:500] or None
+ selected = job
+ break
+ statuses = {job.get("status") for job in deployment.get("jobs", [])}
+ if statuses and statuses <= {"succeeded"}:
+ deployment["status"] = "succeeded"
+ elif "failed" in statuses:
+ deployment["status"] = "attention-required"
+ elif statuses & {"acknowledged", "running"}:
+ deployment["status"] = "in-progress"
+ if selected is not None:
+ _write(_path("deployments.json"), deployments)
+ return selected
diff --git a/src/intentgate/security_policies.py b/src/intentgate/security_policies.py
new file mode 100644
index 0000000..02b8704
--- /dev/null
+++ b/src/intentgate/security_policies.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+from .deployments import list_endpoints
+
+
+DEFAULT_SECURITY_POLICIES: tuple[dict[str, Any], ...] = (
+ {
+ "id": "endpoint-av",
+ "name": "Endpoint antivirus",
+ "domain": "AV",
+ "description": "Real-time file, process, memory, and removable-media protection.",
+ "enabled": True,
+ "enforcement_mode": "enforce",
+ "assigned_groups": ["windows", "linux", "macos"],
+ "rules": ["Real-time protection", "Cloud-delivered detection", "Tamper protection", "Scheduled full scan"],
+ "source": "endpoint-protection-baseline",
+ },
+ {
+ "id": "malware-prevention",
+ "name": "Malware prevention",
+ "domain": "MALWARE",
+ "description": "Blocks known malware, suspicious scripts, ransomware behavior, and persistence techniques.",
+ "enabled": True,
+ "enforcement_mode": "enforce",
+ "assigned_groups": ["high-value", "privileged-access", "privileged-dev", "engineering"],
+ "rules": ["Known-malware deny", "Ransomware behavior block", "Script reputation", "Persistence prevention"],
+ "source": "malware-prevention-baseline",
+ },
+ {
+ "id": "data-loss-prevention",
+ "name": "Data loss prevention",
+ "domain": "DLP",
+ "description": "Detects sensitive-data movement to unapproved destinations and removable media.",
+ "enabled": True,
+ "enforcement_mode": "review",
+ "assigned_groups": ["finance", "high-value", "sales"],
+ "rules": ["Financial data classification", "Secrets and credentials", "External upload review", "Removable-media control"],
+ "source": "dlp-baseline",
+ },
+ {
+ "id": "behavior-monitoring",
+ "name": "Behavior monitoring",
+ "domain": "UEBA",
+ "description": "Correlates command rarity, velocity, privilege, sequence, and identity baselines.",
+ "enabled": True,
+ "enforcement_mode": "review",
+ "assigned_groups": ["operations", "privileged-access", "privileged-dev", "cluster-admins"],
+ "rules": ["Rare command family", "Rapid file mutation", "Privilege anomaly", "Secret-to-egress sequence"],
+ "source": "intentgate-local-baseline",
+ },
+ {
+ "id": "vulnerability-cve",
+ "name": "Vulnerability & CVE scanning",
+ "domain": "CVE",
+ "description": "Prioritizes vulnerable software using severity, asset exposure, and known exploitation.",
+ "enabled": True,
+ "enforcement_mode": "enforce",
+ "assigned_groups": ["windows", "linux", "macos", "high-value", "ci-runners"],
+ "rules": ["Daily software inventory", "Known-exploited escalation", "Critical CVE deployment gate", "Remediation SLA tracking"],
+ "source": "demo-kev-feed",
+ "cve_alerts": [
+ {"id": "CVE-2021-44228", "severity": "critical", "known_exploited": True, "affected_endpoints": 1, "status": "patch-required", "summary": "Log4j remote code execution exposure detected in a demo build dependency."},
+ {"id": "CVE-2023-34362", "severity": "critical", "known_exploited": True, "affected_endpoints": 1, "status": "isolated", "summary": "MOVEit transfer service signature observed on a demo high-value endpoint."},
+ {"id": "CVE-2024-3094", "severity": "high", "known_exploited": False, "affected_endpoints": 1, "status": "investigate", "summary": "XZ package provenance requires validation on a demo Linux runner."},
+ ],
+ },
+)
+
+
+def _path() -> Path:
+ return Path(os.environ.get("UIG_STATE_DIR", Path.home() / ".intentgate")) / "security-policies.json"
+
+
+def _stored() -> list[dict[str, Any]]:
+ try:
+ value = json.loads(_path().read_text(encoding="utf-8"))
+ return value if isinstance(value, list) else []
+ except (OSError, json.JSONDecodeError):
+ return []
+
+
+def _write(value: list[dict[str, Any]]) -> None:
+ path = _path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(".tmp")
+ temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
+ temporary.replace(path)
+
+
+def _coverage(policy: dict[str, Any], endpoints: list[dict[str, Any]]) -> dict[str, Any]:
+ groups = set(map(str, policy.get("assigned_groups", [])))
+ matched = [item for item in endpoints if groups.intersection(map(str, item.get("security_groups", [])))]
+ return {
+ **policy,
+ "covered_endpoint_count": len(matched),
+ "total_endpoint_count": len(endpoints),
+ "online_endpoint_count": sum(item.get("status") == "online" for item in matched),
+ "covered_endpoints": [
+ {"id": item.get("id"), "hostname": item.get("hostname"), "status": item.get("status"), "agent_version": item.get("agent_version")}
+ for item in matched
+ ],
+ }
+
+
+def list_security_policies() -> dict[str, Any]:
+ defaults = {item["id"]: json.loads(json.dumps(item)) for item in DEFAULT_SECURITY_POLICIES}
+ for item in _stored():
+ if isinstance(item, dict) and item.get("id") in defaults:
+ defaults[item["id"]].update({
+ "enabled": bool(item.get("enabled", defaults[item["id"]]["enabled"])),
+ "enforcement_mode": str(item.get("enforcement_mode", defaults[item["id"]]["enforcement_mode"])),
+ "assigned_groups": list(item.get("assigned_groups", defaults[item["id"]]["assigned_groups"])),
+ })
+ endpoints = list_endpoints()
+ policies = [_coverage(defaults[item["id"]], endpoints) for item in DEFAULT_SECURITY_POLICIES]
+ groups = sorted({str(group) for endpoint in endpoints for group in endpoint.get("security_groups", [])})
+ return {
+ "policies": policies,
+ "available_groups": groups,
+ "summary": {
+ "active": sum(item["enabled"] for item in policies),
+ "domains": len(policies),
+ "endpoint_count": len(endpoints),
+ "known_exploited_cves": sum(alert.get("known_exploited") is True for item in policies for alert in item.get("cve_alerts", [])),
+ },
+ "intelligence_notice": "CVE findings use a labeled demo intelligence feed for this POC; connect an authenticated scanner and current KEV source for production.",
+ }
+
+
+def save_security_policy(value: object) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ValueError("security policy update must be an object")
+ policy_id = str(value.get("id", ""))
+ defaults = {item["id"]: item for item in DEFAULT_SECURITY_POLICIES}
+ if policy_id not in defaults:
+ raise ValueError("unknown security policy id")
+ mode = str(value.get("enforcement_mode", defaults[policy_id]["enforcement_mode"]))
+ if mode not in {"monitor", "review", "enforce"}:
+ raise ValueError("enforcement_mode must be monitor, review, or enforce")
+ groups = value.get("assigned_groups", defaults[policy_id]["assigned_groups"])
+ if not isinstance(groups, list):
+ raise ValueError("assigned_groups must be an array")
+ available = set(list_security_policies()["available_groups"])
+ selected_groups = sorted({str(item) for item in groups if str(item) in available})
+ if not selected_groups:
+ raise ValueError("at least one discovered security group is required")
+ saved = {
+ "id": policy_id,
+ "enabled": bool(value.get("enabled", True)),
+ "enforcement_mode": mode,
+ "assigned_groups": selected_groups,
+ }
+ items = [item for item in _stored() if isinstance(item, dict) and item.get("id") != policy_id]
+ items.append(saved)
+ _write(items)
+ return next(item for item in list_security_policies()["policies"] if item["id"] == policy_id)
diff --git a/src/intentgate/service.py b/src/intentgate/service.py
index 9e74dc3..bf94253 100644
--- a/src/intentgate/service.py
+++ b/src/intentgate/service.py
@@ -19,6 +19,15 @@
from .behavior import assess_anomaly
from .catalog import DESTRUCTIVE_ACTIONS
from .context import HISTORY_FILE, collect_context
+from .deployments import (
+ create_deployment,
+ create_discovery_session,
+ inventory,
+ list_deployments,
+ next_job,
+ register_endpoint,
+ update_job,
+)
from .engine import assess
from .integrations import ingest, read_posture, source_postures
from .model_advisory import model_status, request_model_advisory
@@ -26,6 +35,7 @@
from .policy import load_policy, save_policy
from .reporting import pending_report_count
from .reviews import create_review, decide_review, list_reviews
+from .security_policies import list_security_policies, save_security_policy
from .trust_controls import apply_zero_trust, load_trust_controls, save_trust_controls
@@ -216,7 +226,11 @@ def do_GET(self) -> None:
self.end_headers()
self.wfile.write(payload)
return
- if parsed.path in {"/v1/audit", "/v1/reviews", "/v1/policy", "/v1/model", "/v1/trust-controls"} and not self._authorized():
+ protected = {
+ "/v1/audit", "/v1/reviews", "/v1/policy", "/v1/model", "/v1/trust-controls",
+ "/v1/endpoints", "/v1/deployments", "/v1/deployment-jobs/next", "/v1/security-policies",
+ }
+ if parsed.path in protected and not self._authorized():
self._json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"})
return
if parsed.path == "/v1/audit":
@@ -250,13 +264,36 @@ def do_GET(self) -> None:
if parsed.path == "/v1/trust-controls":
self._json(HTTPStatus.OK, load_trust_controls())
return
+ if parsed.path == "/v1/security-policies":
+ self._json(HTTPStatus.OK, list_security_policies())
+ return
+ if parsed.path == "/v1/endpoints":
+ self._json(HTTPStatus.OK, inventory())
+ return
+ if parsed.path == "/v1/deployments":
+ query = parse_qs(parsed.query)
+ try:
+ limit = max(1, min(int(query.get("limit", ["50"])[0]), 250))
+ except ValueError:
+ limit = 50
+ self._json(HTTPStatus.OK, {"deployments": list_deployments(limit)})
+ return
+ if parsed.path == "/v1/deployment-jobs/next":
+ endpoint_id = parse_qs(parsed.query).get("endpoint_id", [""])[0]
+ self._json(HTTPStatus.OK, {"job": next_job(endpoint_id)})
+ return
self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})
def do_POST(self) -> None:
parsed = urlparse(self.path)
- known = parsed.path in {"/v1/signals", "/v1/events", "/v1/assess", "/v1/model-assess", "/v1/policy", "/v1/trust-controls"}
+ known = parsed.path in {
+ "/v1/signals", "/v1/events", "/v1/assess", "/v1/model-assess", "/v1/policy",
+ "/v1/trust-controls", "/v1/endpoints/register", "/v1/discovery-sessions", "/v1/deployments",
+ "/v1/security-policies",
+ }
review_match = re.fullmatch(r"/v1/reviews/([a-f0-9]{12})", parsed.path)
- if not known and review_match is None:
+ job_match = re.fullmatch(r"/v1/deployment-jobs/([a-f0-9]{12})", parsed.path)
+ if not known and review_match is None and job_match is None:
self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})
return
if not self._authorized():
@@ -292,6 +329,22 @@ def do_POST(self) -> None:
controls = save_trust_controls(body)
self._json(HTTPStatus.OK, controls)
return
+ if parsed.path == "/v1/security-policies":
+ policy = save_security_policy(body)
+ self._json(HTTPStatus.OK, policy)
+ return
+ if parsed.path == "/v1/endpoints/register":
+ endpoint = register_endpoint(body)
+ self._json(HTTPStatus.OK, endpoint)
+ return
+ if parsed.path == "/v1/discovery-sessions":
+ session = create_discovery_session(body)
+ self._json(HTTPStatus.OK, session)
+ return
+ if parsed.path == "/v1/deployments":
+ deployment = create_deployment(body)
+ self._json(HTTPStatus.ACCEPTED, deployment)
+ return
if review_match is not None:
if not isinstance(body, dict):
raise ValueError("payload must be an object")
@@ -303,6 +356,13 @@ def do_POST(self) -> None:
else:
self._json(HTTPStatus.OK, selected)
return
+ if job_match is not None:
+ selected = update_job(job_match.group(1), body)
+ if selected is None:
+ self._json(HTTPStatus.NOT_FOUND, {"error": "deployment job not found"})
+ else:
+ self._json(HTTPStatus.OK, selected)
+ return
items = body if isinstance(body, list) else [body]
if not all(isinstance(item, dict) for item in items):
raise ValueError("payload must be an object or list of objects")
diff --git a/src/intentgate/web/console.css b/src/intentgate/web/console.css
index 12d9d74..ddb1826 100644
--- a/src/intentgate/web/console.css
+++ b/src/intentgate/web/console.css
@@ -71,12 +71,17 @@ h2 { margin-bottom: 0; font: 500 24px/1.2 Georgia, serif; }
.intelligence-fabric { grid-column: 1 / -1; position: relative; overflow: hidden; padding: 30px; color: #edf7f2; background: #0d1d1b; border: 1px solid #2b4942; box-shadow: 0 20px 55px #13201d1a; }
.fabric-grid { position: absolute; inset: 0; opacity: .16; background-image: linear-gradient(#70d7ac19 1px, transparent 1px), linear-gradient(90deg, #70d7ac19 1px, transparent 1px); background-size: 35px 35px; mask-image: linear-gradient(to bottom, black, transparent 88%); }
.intelligence-fabric::after { content: ""; position: absolute; width: 42%; height: 1px; left: -42%; top: 0; background: linear-gradient(90deg, transparent, #80f7c5, transparent); animation: fabric-scan 4s linear infinite; box-shadow: 0 0 18px #80f7c5; }
-.fabric-heading, .fabric-body, .capability-grid { position: relative; z-index: 1; }
+.fabric-heading, .attention-board, .fabric-body, .capability-grid { position: relative; z-index: 1; }
.fabric-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; }
.fabric-heading h2 { font-size: 29px; }
+.fabric-summary { max-width: 760px; margin: 8px 0 0; color: #84a198; font-size: 11px; }
.intelligence-fabric .eyebrow { color: #7ee0b6; }
.live-chip { padding: 7px 10px; border: 1px solid #3d665b; color: #91cbb5; font: 700 8px/1 Consolas, monospace; letter-spacing: .12em; }
.live-chip i { display: inline-block; width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; background: #6ce3ad; box-shadow: 0 0 0 4px #6ce3ad20, 0 0 14px #6ce3ad; animation: status-pulse 1.8s ease-out infinite; }
+.attention-board { margin-top: 20px; padding: 10px; border: 1px solid #315149; display: grid; grid-template-columns: 1.2fr repeat(3, 1fr); gap: 1px; background: #315149; }
+.attention-board-label, .attention-action { min-height: 92px; padding: 14px 16px; background: #162d29; }
+.attention-board-label { display: flex; flex-direction: column; justify-content: center; }.attention-board-label span { color: #6f9688; font: 700 7px/1 Consolas, monospace; letter-spacing: .13em; }.attention-board-label strong { margin-top: 8px; color: #e2f2eb; font: 500 17px/1.2 Georgia, serif; }
+.attention-action { border: 0; display: grid; grid-template-columns: 1fr auto; gap: 7px 12px; color: #c8ddd4; text-align: left; text-decoration: none; cursor: pointer; transition: background .18s ease, transform .18s ease; }.attention-action:hover { background: #1d3933; transform: translateY(-1px); }.attention-action span { align-self: center; color: #789b8e; font: 700 7px/1 Consolas, monospace; letter-spacing: .1em; }.attention-action strong { grid-row: 1 / span 2; grid-column: 2; align-self: center; color: #f08a7f; font: 500 30px/1 Georgia, serif; }.attention-action small { color: #91b1a5; font: 9px/1.2 system-ui, sans-serif; }.attention-action.warning strong { color: #efb16e; }.attention-action.review strong { color: #8de6bd; }
.fabric-body { margin-top: 29px; display: grid; grid-template-columns: 210px 1fr; gap: 26px; align-items: stretch; }
.ai-core-wrap { min-height: 190px; border: 1px solid #294840; background: #132825b3; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.ai-core { position: relative; width: 92px; height: 92px; display: grid; place-items: center; border: 1px solid #64d7a6; border-radius: 50%; color: #b8f4d8; background: radial-gradient(circle, #2a745b 0, #153b32 45%, #10231f 70%); box-shadow: inset 0 0 25px #6be7b534, 0 0 35px #52c6982e; }
@@ -97,16 +102,17 @@ h2 { margin-bottom: 0; font: 500 24px/1.2 Georgia, serif; }
.stream-event::before { content: ""; position: absolute; left: 1px; width: 6px; height: 6px; border-radius: 50%; background: #6ed3a8; box-shadow: 0 0 8px #6ed3a8; }
.stream-event.review::before { background: #efa85d; box-shadow: 0 0 8px #efa85d; }.stream-event.block::before { background: #ef746a; box-shadow: 0 0 8px #ef746a; }
.stream-event time { color: #69877d; font: 8px/1 Consolas, monospace; }.stream-event code { overflow: hidden; color: #d8e7e0; font: 10px/1 Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.stream-event b { text-align: right; color: #8bb6a6; font: 700 8px/1 Consolas, monospace; }
-.capability-grid { margin-top: 13px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
-.capability-card { min-height: 248px; padding: 17px; border: 1px solid #294840; background: linear-gradient(145deg, #152c28e8, #10221fe8); display: flex; flex-direction: column; }
+.capability-grid { margin-top: 13px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
+.capability-card { min-height: 285px; padding: 21px; border: 1px solid #294840; background: linear-gradient(145deg, #152c28e8, #10221fe8); display: flex; flex-direction: column; }
.capability-card:hover { border-color: #477c6d; transform: translateY(-2px); transition: .2s ease; }
-.capability-top { display: flex; align-items: center; gap: 7px; color: #91aa9f; font: 700 8px/1 Consolas, monospace; letter-spacing: .09em; }
+.capability-top { display: flex; align-items: center; gap: 9px; color: #91aa9f; font: 700 9px/1 Consolas, monospace; letter-spacing: .09em; }
.capability-icon { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3e685c; color: #79dbb2; font-size: 12px; }
.capability-state { margin-left: auto; color: #65d4a7; font: normal 7px/1 Consolas, monospace; }
.capability-state.alert { color: #efad68; }
-.capability-card > strong { display: block; margin-top: 19px; color: #e3f1eb; font: 500 16px/1.2 Georgia, serif; }
-.capability-card > p { margin: 8px 0 12px; color: #819c92; font-size: 10px; }
+.capability-card > strong { display: block; margin-top: 19px; color: #e3f1eb; font: 500 20px/1.2 Georgia, serif; }
+.capability-card > p { margin: 8px 0 12px; color: #819c92; font-size: 11px; }
.observation-list { margin-top: 10px; border-top: 1px solid #29443e; }.observation-list p { min-height: 42px; margin: 0; padding: 8px 0; border-bottom: 1px solid #223b35; }.observation-list span, .observation-list b { display: block; }.observation-list span { margin-bottom: 4px; color: #64877a; font: 700 6px/1 Consolas, monospace; letter-spacing: .12em; }.observation-list b { overflow: hidden; color: #9db7ad; font-size: 9px; font-weight: 500; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
+.next-action { margin: 12px 0; padding: 11px 12px; border-left: 3px solid #66d6a8; background: #19322d; }.next-action span, .next-action b { display: block; }.next-action span { color: #6e9a89; font: 700 6px/1 Consolas, monospace; letter-spacing: .13em; }.next-action b { margin-top: 5px; color: #b7d2c7; font-size: 10px; font-weight: 500; line-height: 1.4; }
.capability-action { width: 100%; min-height: 31px; margin-top: auto; padding: 8px 0 0; border: 0; border-top: 1px solid #315249; display: flex; align-items: center; justify-content: space-between; color: #71dab0; background: transparent; font: 700 8px/1 Consolas, monospace; letter-spacing: .07em; text-decoration: none; }.capability-action:hover { color: #b4f3d7; }.capability-action b { font-size: 12px; }
.velocity-bars { height: 24px; display: flex; align-items: end; gap: 3px; }
.velocity-bars i { flex: 1; height: 25%; background: #447263; animation: bar-rise 1.8s ease-in-out infinite alternate; }.velocity-bars i:nth-child(2n) { height: 60%; animation-delay: -.5s; }.velocity-bars i:nth-child(3n) { height: 95%; animation-delay: -1s; }.velocity-bars i:nth-child(5n) { background: #c87f42; }
@@ -176,6 +182,9 @@ td code { color: var(--ink); font-family: Consolas, monospace; }
.audit-risk-value { display: inline-grid; width: 34px; height: 34px; place-items: center; border: 1px solid currentColor; border-radius: 50%; font: 700 10px/1 Consolas, monospace; }.risk-allow { color: var(--green); }.risk-review { color: var(--amber); }.risk-block { color: var(--red); }
.audit-detail-row td { padding: 0 10px 16px; background: #edf3ee; }.audit-explanation { padding: 22px; border: 1px solid #b8c8bf; border-left: 4px solid currentColor; color: var(--green); background: #fbfcf9; box-shadow: 0 12px 28px #18352d0d; }.audit-explanation.decision-review { color: var(--amber); }.audit-explanation.decision-block { color: var(--red); }
.audit-filter-bar { margin: -8px 0 18px; padding: 11px 13px; border-left: 3px solid var(--amber); background: #f6eee4; display: flex; align-items: center; justify-content: space-between; gap: 15px; }.audit-filter-bar[hidden] { display: none; }.audit-filter-bar span, .audit-filter-bar strong { display: block; }.audit-filter-bar span { color: #9a6a35; font: 700 7px/1 Consolas, monospace; letter-spacing: .12em; }.audit-filter-bar strong { margin-top: 4px; font-size: 11px; }.audit-filter-bar button { padding: 6px 8px; border: 1px solid #d4b38c; color: #8a541f; background: transparent; font-size: 9px; }
+.audit-page-tools { min-height: 48px; padding: 8px 11px; border: 1px solid var(--line); border-bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 15px; background: #f3f5f1; }.audit-page-tools strong, .audit-page-tools span { display: block; }.audit-page-tools strong { color: var(--ink); font: 700 10px/1.2 Consolas, monospace; }.audit-page-tools span { margin-top: 3px; color: #84918b; font-size: 8px; }.audit-page-tools label { margin: 0; display: flex; align-items: center; gap: 8px; color: #6c7974; font-size: 9px; }.audit-page-tools select { height: 30px; min-width: 62px; padding: 0 8px; border: 1px solid #c7d0c9; background: white; }
+.audit-table-wrap { max-height: 650px; border: 1px solid var(--line); overflow: auto; }.audit-table-wrap table { min-width: 850px; }.audit-table-wrap thead th { position: sticky; top: 0; z-index: 2; background: #edf1ec; box-shadow: 0 1px #cfd7d0; }
+.audit-pagination { min-height: 50px; padding: 10px 0 0; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }.audit-pagination button { min-width: 92px; padding: 8px 10px; border: 1px solid #bdc9c1; color: var(--green); background: #f7f8f4; font-size: 9px; }.audit-pagination button:hover:not(:disabled) { border-color: var(--green); background: var(--green-soft); }.audit-pagination button:disabled { color: #a1aaa5; cursor: default; opacity: .55; }.audit-pagination span { min-width: 88px; color: #66736e; font: 700 9px/1 Consolas, monospace; text-align: center; }
.audit-explanation-head { display: flex; gap: 20px; align-items: center; }.audit-explanation-head > div:first-child { flex: 1; }.audit-explanation-head h3 { margin: 0; color: var(--ink); font: 500 23px/1.2 Georgia, serif; }.audit-explanation-head p:not(.eyebrow) { margin: 5px 0 0; color: var(--muted); }.audit-risk-orb { flex: 0 0 74px; height: 74px; border: 1px solid currentColor; border-radius: 50%; display: grid; place-content: center; text-align: center; }.audit-risk-orb strong { color: var(--ink); font: 500 25px/1 Georgia, serif; }.audit-risk-orb small { margin-top: 4px; font: 7px/1 Consolas, monospace; }
.audit-risk-track { height: 4px; margin: 18px 0; overflow: hidden; background: #dfe6e1; }.audit-risk-track span { display: block; height: 100%; background: currentColor; transition: width .5s ease; }
.audit-context-grid { display: grid; grid-template-columns: .8fr 1fr 1.5fr .8fr; gap: 1px; border: 1px solid var(--line); background: var(--line); }.audit-context-grid > div { min-height: 63px; padding: 12px; background: #f3f6f2; }.audit-context-grid small, .audit-context-grid strong { display: block; }.audit-context-grid small { color: #84918b; font: 7px/1 Consolas, monospace; letter-spacing: .1em; }.audit-context-grid strong { margin-top: 7px; color: var(--ink); font-size: 11px; }
@@ -183,6 +192,11 @@ td code { color: var(--ink); font-family: Consolas, monospace; }
.decision-pill { padding: 4px 7px; font: 700 8px/1 Consolas, monospace; text-transform: uppercase; }
.decision-pill.allow { color: var(--green); background: var(--green-soft); }.decision-pill.review { color: #8f5016; background: var(--amber-soft); }.decision-pill.block { color: var(--red); background: var(--red-soft); }
.policy-grid { display: grid; grid-template-columns: .85fr 1.15fr; gap: 20px; align-items: start; }
+.security-policy-panel { grid-column: 1 / -1; border-top: 3px solid #315f50; }.security-policy-panel .section-heading p:not(.eyebrow) { max-width: 760px; color: var(--muted); }
+.security-policy-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.security-policy-card { min-width: 0; padding: 19px; border: 1px solid #d0d9d2; border-top: 3px solid #3a8a6c; background: #f5f7f3; }.security-policy-card.disabled { opacity: .65; border-top-color: #929e98; }.security-policy-card.has-cve { grid-column: 1 / -1; }.security-policy-head { display: flex; justify-content: space-between; align-items: center; }.security-policy-head > span { width: 44px; height: 29px; display: grid; place-items: center; color: #eafff5; background: var(--green); font: 700 8px/1 Consolas, monospace; letter-spacing: .08em; }.security-policy-head > div { display: flex; gap: 6px; align-items: center; }.security-policy-head small, .security-policy-head b { padding: 4px 6px; font: 700 7px/1 Consolas, monospace; }.security-policy-head small { color: var(--green); background: var(--green-soft); }.security-policy-head b { color: #52665e; border: 1px solid #c6d1ca; }.security-policy-card h3 { margin: 15px 0 5px; color: var(--ink); font: 500 20px/1.2 Georgia, serif; }.security-policy-card > p:not(.config-caption) { min-height: 34px; margin: 0; color: var(--muted); font-size: 10px; }
+.policy-coverage { margin-top: 15px; padding: 11px; display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: center; background: #eaf0eb; }.policy-coverage div strong, .policy-coverage div span { display: block; }.policy-coverage div strong { font: 600 15px/1 Consolas, monospace; }.policy-coverage div span { margin-top: 4px; color: #7c8c85; font: 6px/1 Consolas, monospace; letter-spacing: .1em; }.policy-coverage > b { color: var(--green); font: 500 25px/1 Georgia, serif; }.policy-coverage-track { height: 4px; background: #d5ddd7; }.policy-coverage-track span { display: block; height: 100%; background: linear-gradient(90deg, #20815f, #70d9ac); }
+.policy-rule-list { margin: 13px 0; display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }.policy-rule-list span { color: #52665e; font-size: 9px; }.policy-rule-list span::first-letter { color: var(--green); }.policy-group-list { display: flex; flex-wrap: wrap; gap: 5px; }.policy-group-list button { padding: 5px 7px; border: 1px solid #b9c9bf; color: var(--green); background: #edf3ef; font: 700 7px/1 Consolas, monospace; }.policy-group-list button:hover { border-color: var(--green); background: #dcece3; }
+.policy-cve-list { margin-top: 17px; padding-top: 14px; border-top: 1px solid var(--line); display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }.policy-cve-list > .config-caption { grid-column: 1 / -1; margin-top: 0; }.policy-cve-alert { padding: 11px; border-left: 3px solid var(--amber); background: #f2eee7; }.policy-cve-alert.critical { border-left-color: var(--red); background: #f4eae8; }.policy-cve-alert > div { display: flex; justify-content: space-between; gap: 8px; }.policy-cve-alert strong { font: 700 10px/1 Consolas, monospace; }.policy-cve-alert span { color: var(--red); font: 700 6px/1 Consolas, monospace; }.policy-cve-alert p { min-height: 43px; margin: 8px 0; color: #5d6763; font-size: 9px; }.policy-cve-alert small { color: #7d8984; font: 7px/1 Consolas, monospace; text-transform: uppercase; }.policy-endpoints { margin-top: 14px; border-top: 1px solid var(--line); }.policy-endpoints summary { padding: 10px 0 0; color: var(--green); cursor: pointer; font: 700 8px/1 Consolas, monospace; }.policy-endpoints ul { margin: 10px 0 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; list-style: none; }.policy-endpoints li { padding: 7px; display: flex; justify-content: space-between; align-items: center; background: #eaf0eb; }.policy-endpoints li small { color: #7a8983; font-size: 8px; }.policy-intelligence-notice { margin: 13px 0 0; padding: 10px 12px; border-left: 3px solid var(--amber); color: #746557; background: #f6f1e8; font-size: 9px; }
#policy-form > input { margin-bottom: 19px; }
.threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.threshold-row label { padding: 14px; background: #f1f4ef; }
@@ -211,6 +225,15 @@ select:focus { border-color: var(--green); box-shadow: 0 0 0 2px #d9ebe2; }
.flow-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }.flow-selector label { min-height: 47px; margin: 0; padding: 9px 10px; border: 1px solid #d1d8d2; display: grid; grid-template-columns: 37px 16px 37px 1fr; gap: 3px; align-items: center; cursor: pointer; color: #74827c; transition: .18s ease; }.flow-selector label span { padding: 4px; border: 1px solid #cbd5cd; font: 700 7px/1 Consolas, monospace; text-align: center; }.flow-selector label b { color: #9ba7a1; text-align: center; }.flow-selector label small { text-align: right; font-size: 8px; }.flow-selector label:has(input:checked) { border-color: #5a9d83; color: var(--green); background: #e8f3ed; }.flow-selector label:has(input:checked) span { border-color: #78ad98; }
.deployment-note { margin: 17px 0 0; padding: 10px 12px; border-left: 3px solid var(--amber); color: var(--muted); background: #f6f1e9; font-size: 10px; }.deployment-note b { color: #83501f; }
.trust-save-bar { grid-column: 1 / -1; min-height: 66px; padding: 12px 14px 12px 18px; border: 1px solid #31534a; background: #203a35; display: flex; align-items: center; justify-content: space-between; gap: 20px; }.trust-save-bar strong, .trust-save-bar small { display: block; }.trust-save-bar strong { color: #dff0e8; font-size: 12px; }.trust-save-bar small { margin-top: 3px; color: #779087; font-size: 9px; }
+.admin-panel { padding: 30px; border: 1px solid #294840; color: #dcebe5; background: #10221f; box-shadow: 0 20px 55px #13201d1a; }
+.admin-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; }.admin-heading h2 { margin: 0; color: #eef8f3; font-size: 29px; }.admin-heading > div:first-child > p:last-child { max-width: 680px; color: #84a096; }.admin-actions { display: flex; align-items: center; gap: 10px; }.admin-actions .control-live-dot { color: #81e1b9; background: #18332d; }.admin-actions .control-live-dot i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #70e3b2; box-shadow: 0 0 10px #70e3b2; }.admin-actions .secondary-button { white-space: nowrap; }
+.admin-stat-grid { margin: 24px 0 12px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; border: 1px solid #315149; background: #315149; }.admin-stat-grid article { min-height: 102px; padding: 17px; background: #19312d; }.admin-stat-grid small, .admin-stat-grid strong, .admin-stat-grid span { display: block; }.admin-stat-grid small { color: #72978a; font: 700 7px/1 Consolas, monospace; letter-spacing: .11em; }.admin-stat-grid strong { margin: 11px 0 6px; color: #9af0ca; font: 500 27px/1 Georgia, serif; }.admin-stat-grid span { color: #68857b; font-size: 9px; }
+.admin-layout { display: grid; grid-template-columns: 1.35fr .85fr; gap: 12px; }.admin-layout .panel { min-width: 0; }.discovery-card, .deployment-card { background: #f5f7f3; }.security-group-list { margin-bottom: 16px; display: flex; flex-wrap: wrap; gap: 7px; }.security-group-list button { padding: 9px 10px; border: 1px solid #c8d4cd; color: #50615b; background: #fff; text-align: left; }.security-group-list button:hover { border-color: #4f967b; background: #e8f3ed; }.security-group-list b, .security-group-list span { display: block; }.security-group-list b { font: 700 9px/1 Consolas, monospace; }.security-group-list span { margin-top: 4px; color: #7d8c86; font-size: 8px; }
+.endpoint-table-wrap { max-height: 435px; overflow: auto; }.endpoint-table-wrap table { min-width: 840px; }.endpoint-table-wrap td { vertical-align: middle; }.endpoint-table-wrap td > strong, .endpoint-table-wrap td > small { display: block; }.endpoint-table-wrap td > small { margin-top: 3px; color: #8a9791; font: 7px/1 Consolas, monospace; }.endpoint-table-wrap td code { font-size: 9px; }.endpoint-table-wrap td em { color: #a26328; font-size: 9px; }.endpoint-groups { display: flex; flex-wrap: wrap; gap: 3px; }.endpoint-groups span { padding: 3px 4px; color: #527666; background: #e5eee8; font: 6px/1 Consolas, monospace; }.endpoint-state { display: inline-flex; align-items: center; gap: 5px; text-transform: uppercase; font: 700 7px/1 Consolas, monospace; }.endpoint-state i { width: 6px; height: 6px; border-radius: 50%; background: #84928d; }.endpoint-state.online { color: var(--green); }.endpoint-state.online i { background: #4fc38f; box-shadow: 0 0 8px #4fc38f; }.endpoint-state.offline { color: #8c6d50; }
+.deployment-fields { grid-template-columns: 1fr 1fr; }.network-command { min-height: 68px; padding: 13px; border: 1px solid #284b42; display: block; color: #a7e7cc; background: #142b26; font: 9px/1.6 Consolas, monospace; word-break: break-word; }.deployment-guard { padding: 10px 12px; border-left: 3px solid var(--green); color: #687870; background: #edf2ed; font-size: 10px; }.deployment-guard b { color: #315d4b; }.deployment-buttons { margin-top: 16px; display: flex; gap: 8px; }.deployment-history-heading { margin: 25px 0 10px; padding-top: 17px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }.deployment-history-heading p { margin: 0; }.deployment-list { max-height: 250px; overflow: auto; display: grid; gap: 6px; }.deployment-wave { padding: 11px; border-left: 3px solid #809189; display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; color: var(--ink); background: #edf1ed; }.deployment-wave.queued, .deployment-wave.in-progress { border-left-color: var(--green); }.deployment-wave.attention-required { border-left-color: var(--red); }.deployment-wave strong, .deployment-wave small { display: block; }.deployment-wave strong { font-size: 10px; }.deployment-wave small { margin-top: 4px; color: var(--muted); font-size: 8px; }.deployment-wave span { color: var(--green); font: 700 7px/1 Consolas, monospace; }.deployment-wave > b { grid-column: 1 / -1; color: #77857f; font-size: 8px; }
+.inventory-panel { border-top: 3px solid #315f50; }.inventory-heading > div:first-child p:not(.eyebrow) { max-width: 760px; color: var(--muted); }.inventory-search { width: min(320px, 100%); }.inventory-search input { height: 38px; }
+.inventory-summary-grid { margin-bottom: 15px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; border: 1px solid var(--line); background: var(--line); }.inventory-summary-grid div { padding: 13px 15px; background: #eff3ee; }.inventory-summary-grid small, .inventory-summary-grid strong { display: block; }.inventory-summary-grid small { color: #7c8a84; font: 700 7px/1 Consolas, monospace; letter-spacing: .12em; }.inventory-summary-grid strong { margin-top: 7px; color: var(--ink); font: 500 22px/1 Georgia, serif; }
+.inventory-group-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; }.inventory-group-card { min-width: 0; padding: 16px; border: 1px solid #ccd6ce; background: #f5f7f3; }.inventory-group-head { display: flex; justify-content: space-between; align-items: center; }.inventory-group-head span { color: #819089; font: 700 6px/1 Consolas, monospace; letter-spacing: .12em; }.inventory-group-head h3 { margin: 5px 0 0; font: 500 18px/1.2 Georgia, serif; }.inventory-group-head > strong { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid #7da38f; border-radius: 50%; color: var(--green); font: 500 16px/1 Georgia, serif; }.inventory-group-stats { margin: 13px 0 8px; display: flex; flex-wrap: wrap; gap: 5px; }.inventory-group-stats span { padding: 4px 6px; color: #5e7269; background: #e4ebe5; font: 7px/1 Consolas, monospace; }.inventory-policy-tags { min-height: 31px; display: flex; flex-wrap: wrap; gap: 4px; }.inventory-policy-tags span { padding: 4px 5px; color: #2f7359; border: 1px solid #bdd0c5; font: 6px/1 Consolas, monospace; }.inventory-group-card ul { margin: 11px 0 0; padding: 0; list-style: none; }.inventory-group-card li { padding: 9px 0; border-top: 1px solid #dce3dd; display: flex; justify-content: space-between; gap: 8px; align-items: center; }.inventory-group-card li > div > small { display: block; margin-top: 4px; color: #7b8983; font-size: 8px; }.inventory-group-card li > b { color: #5d6f67; font: 700 7px/1 Consolas, monospace; }
.model-advisory-result { margin-top: 18px; padding: 18px; border: 1px solid #aabbb3; background: #edf3ef; }
.model-advisory-head { display: flex; align-items: center; gap: 12px; }
.model-orb { width: 37px; height: 37px; border: 1px solid var(--green); border-radius: 50%; display: grid; place-items: center; color: var(--green); font: 700 9px/1 Consolas, monospace; }
@@ -240,9 +263,11 @@ dialog h2 { margin-bottom: 10px; } dialog p:not(.eyebrow) { color: var(--muted);
#toast.show { transform: translateY(0); opacity: 1; }
@media (max-width: 1050px) {
- .app-shell { grid-template-columns: 82px 1fr; }.sidebar { padding: 27px 13px; }.brand > span:last-child, .nav-link:not(.active) { font-size: 0; }.brand { justify-content: center; }.sidebar nav { margin-top: 45px; }.nav-link { justify-content: center; gap: 0; }.nav-link span { font-size: 9px; }.nav-link b { display: none; }.sidebar-foot div { display: none; }.sidebar-foot { justify-content: center; }.overview-grid, .policy-grid { grid-template-columns: 1fr; }.capability-grid, .trust-controls-layout { grid-template-columns: 1fr 1fr; }.recent-model-determinations { grid-template-columns: 1fr 1fr; }
+ .app-shell { grid-template-columns: 82px 1fr; }.sidebar { padding: 27px 13px; }.brand > span:last-child, .nav-link:not(.active) { font-size: 0; }.brand { justify-content: center; }.sidebar nav { margin-top: 45px; }.nav-link { justify-content: center; gap: 0; }.nav-link span { font-size: 9px; }.nav-link b { display: none; }.sidebar-foot div { display: none; }.sidebar-foot { justify-content: center; }.overview-grid, .policy-grid { grid-template-columns: 1fr; }.capability-grid, .trust-controls-layout { grid-template-columns: 1fr 1fr; }.admin-stat-grid { grid-template-columns: repeat(3, 1fr); }.admin-layout { grid-template-columns: 1fr; }.recent-model-determinations { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 700px) {
- .app-shell { display: block; }.sidebar { position: static; width: 100%; height: auto; padding: 15px 18px; flex-direction: row; align-items: center; }.sidebar nav { margin: 0 0 0 auto; display: flex; }.nav-link { padding: 9px; }.nav-link span { display: none; }.nav-link.active { font-size: 0; }.nav-link.active::after { content: "MENU"; font-size: 9px; }.sidebar-foot { display: none; } main { padding: 0 17px 20px; }.topbar { height: 88px; }.topbar h1 { font-size: 23px; }.token-button span:last-child { display: none; }.overview-grid { grid-template-columns: 1fr; }.hero-card { min-height: 315px; padding: 25px; flex-direction: column; align-items: flex-start; }.hero-card h2 { font-size: 29px; }.posture-ring { margin: 25px 0 0; }.metric-grid { grid-template-columns: 1fr 1fr; }.panel { padding: 21px 17px; }.intelligence-fabric, .trust-controls-grid { padding: 22px 16px; }.fabric-heading h2 { font-size: 24px; }.fabric-body, .capability-grid, .trust-controls-layout { grid-template-columns: 1fr; }.ai-core-wrap { min-height: 180px; }.form-row, .threshold-row, .model-config-grid, .recent-model-determinations { grid-template-columns: 1fr; }.model-live-banner { grid-template-columns: 55px 1fr; }.model-health-grid { grid-column: 1 / -1; grid-template-columns: repeat(3, 1fr); }.model-pipeline { align-items: flex-start; flex-direction: column; }.zone-selector { grid-template-columns: 1fr 1fr; }.flow-selector { grid-template-columns: 1fr; }.audit-context-grid { grid-template-columns: 1fr 1fr; }.safe-label { display: none; }.signal { grid-template-columns: 42px 1fr; }.signal span { grid-column: 1 / -1; }.review-item { grid-template-columns: 1fr; }.review-actions { justify-content: flex-start; } footer { gap: 15px; flex-direction: column; }
+ .app-shell { display: block; }.sidebar { position: static; width: 100%; height: auto; padding: 15px 18px; flex-direction: row; align-items: center; }.sidebar nav { margin: 0 0 0 auto; display: flex; }.nav-link { padding: 9px; }.nav-link span { display: none; }.nav-link.active { font-size: 0; }.nav-link.active::after { content: "MENU"; font-size: 9px; }.sidebar-foot { display: none; } main { padding: 0 17px 20px; }.topbar { height: 88px; }.topbar h1 { font-size: 23px; }.token-button span:last-child { display: none; }.overview-grid { grid-template-columns: 1fr; }.hero-card { min-height: 315px; padding: 25px; flex-direction: column; align-items: flex-start; }.hero-card h2 { font-size: 29px; }.posture-ring { margin: 25px 0 0; }.metric-grid { grid-template-columns: 1fr 1fr; }.panel { padding: 21px 17px; }.intelligence-fabric, .trust-controls-grid, .admin-panel { padding: 22px 16px; }.fabric-heading h2 { font-size: 24px; }.fabric-body, .capability-grid, .trust-controls-layout, .admin-layout { grid-template-columns: 1fr; }.admin-heading { flex-direction: column; }.admin-actions { width: 100%; justify-content: space-between; }.admin-stat-grid { grid-template-columns: 1fr 1fr; }.deployment-buttons { align-items: stretch; flex-direction: column; }.ai-core-wrap { min-height: 180px; }.form-row, .threshold-row, .model-config-grid, .recent-model-determinations { grid-template-columns: 1fr; }.model-live-banner { grid-template-columns: 55px 1fr; }.model-health-grid { grid-column: 1 / -1; grid-template-columns: repeat(3, 1fr); }.model-pipeline { align-items: flex-start; flex-direction: column; }.zone-selector { grid-template-columns: 1fr 1fr; }.flow-selector { grid-template-columns: 1fr; }.audit-context-grid { grid-template-columns: 1fr 1fr; }.safe-label { display: none; }.signal { grid-template-columns: 42px 1fr; }.signal span { grid-column: 1 / -1; }.review-item { grid-template-columns: 1fr; }.review-actions { justify-content: flex-start; } footer { gap: 15px; flex-direction: column; }
}
+@media (max-width: 1050px) { .attention-board { grid-template-columns: 1fr 1fr; }.security-policy-cards { grid-template-columns: 1fr; }.security-policy-card.has-cve { grid-column: auto; }.inventory-group-grid { grid-template-columns: 1fr 1fr; } }
+@media (max-width: 700px) { .attention-board { grid-template-columns: 1fr; }.policy-cve-list, .policy-endpoints ul, .inventory-group-grid { grid-template-columns: 1fr; }.inventory-heading { align-items: stretch; flex-direction: column; }.inventory-search { width: 100%; }.inventory-summary-grid { grid-template-columns: 1fr 1fr; }.audit-page-tools { align-items: flex-start; flex-direction: column; }.audit-pagination { justify-content: space-between; }.audit-pagination button { min-width: 82px; }.audit-table-wrap { max-height: 540px; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } }
diff --git a/src/intentgate/web/console.js b/src/intentgate/web/console.js
index df2a38c..53c0ed4 100644
--- a/src/intentgate/web/console.js
+++ b/src/intentgate/web/console.js
@@ -2,10 +2,15 @@ const isLoopbackDemo = ["127.0.0.1", "localhost", "::1"].includes(window.locatio
const state = {
token: sessionStorage.getItem("uig-token") || (isLoopbackDemo ? "local-dev-change-me" : ""),
policy: null,
+ securityPolicies: null,
model: null,
trustControls: null,
+ inventory: null,
+ deployments: [],
auditEvents: [],
auditFilter: null,
+ auditPage: 1,
+ auditPageSize: 10,
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
@@ -64,6 +69,12 @@ function renderIntelligenceFabric(events) {
const controls = state.trustControls;
const zero = controls?.zero_trust;
const micro = controls?.microsegmentation;
+ const urgentConditions = highRisk.length || trustGaps.length || burst.length >= 3;
+ $("#priority-high-risk").textContent = highRisk.length;
+ $("#priority-trust-gaps").textContent = trustGaps.length;
+ $("#attention-summary").textContent = urgentConditions
+ ? `${highRisk.length} high-risk actions and ${trustGaps.length} trust-context gaps need triage. Start with scored evidence.`
+ : "No urgent conditions detected. Continue monitoring the live decision stream.";
$("#fabric-event-count").textContent = `${events.length} EVENTS`;
$("#ml-sample-count").textContent = events.length;
$("#ml-coverage").textContent = `${maturity}%`;
@@ -85,6 +96,11 @@ function renderIntelligenceFabric(events) {
$("#behavior-outlier").textContent = topAnomaly
? `${topAnomaly.command} · ${topAnomaly.user_name || "unknown user"} · deviation ${Number(topAnomaly.anomaly_score || 0)}/40`
: "No behavioral outlier in the current window.";
+ $("#behavior-recommendation").textContent = burst.length >= 3
+ ? `Validate the ${burst.length}-command mutation burst and temporarily step up the actor if unexpected.`
+ : topAnomaly
+ ? `Confirm whether ${topAnomaly.user_name || "the operator"} expected the highest-deviation command sequence.`
+ : "Keep collecting representative behavior before tightening anomaly policy.";
$("#micro-headline").textContent = `${micro?.enabled_zones?.length ?? 5} zones · default ${micro?.default_action || "deny"}`;
$("#micro-flows").textContent = `${micro?.allowed_flows?.length ?? 5} explicitly allowed service paths`;
const pendingTopology = micro?.deployment_status === "redeploy-required";
@@ -93,6 +109,11 @@ function renderIntelligenceFabric(events) {
$("#micro-observation").textContent = pendingTopology
? "A saved topology change is waiting for Docker redeployment."
: `${micro?.log_denied === false ? "Denied-flow logging is disabled." : "Denied flows are logged; no topology drift is pending."}`;
+ $("#micro-recommendation").textContent = pendingTopology
+ ? "Review the staged path changes and schedule a controlled redeployment."
+ : micro?.log_denied === false
+ ? "Enable denied-flow logging before changing the allowed-path policy."
+ : "Review denied-flow telemetry before adding any new service path.";
$("#trust-headline").textContent = `${coverage}% actor + endpoint coverage`;
$("#trust-policy").textContent = zero
? `${zero.enforcement_mode} mode · step-up at ${zero.step_up_threshold}/100`
@@ -102,6 +123,12 @@ function renderIntelligenceFabric(events) {
: "No identity, endpoint, or intent gaps in the current window.";
$("#trust-state").textContent = trustGaps.length ? "GAPS FOUND" : "VERIFIED";
$("#trust-state").classList.toggle("alert", trustGaps.length > 0);
+ $("#trust-recommendation").textContent = trustGaps.length
+ ? `Enrich ${trustGaps.length} records with identity, endpoint, or declared intent before enforce mode.`
+ : "Trust context is complete; review step-up thresholds for least privilege.";
+ $("#ml-recommendation").textContent = highRisk.length
+ ? `Label the ${highRisk.length} high-risk outliers as expected or suspicious to improve precision.`
+ : maturity < 80 ? "Collect more representative command families before relying on rarity." : "Baseline coverage is mature; review drift weekly.";
const visible = events.slice(0, 5);
$("#fabric-event-stream").innerHTML = visible.length ? visible.map((item) => `
@@ -213,11 +240,12 @@ function getFilteredAuditEvents() {
: state.auditFilter === "trust-gap"
? events.filter((item) => !item.purpose || !item.user_name || !item.endpoint_name || item.endpoint_name === "unknown-endpoint")
: events;
- return filtered.slice(0, 100);
+ return filtered;
}
function setAuditFilter(filter) {
state.auditFilter = filter;
+ state.auditPage = 1;
const labels = {
behavior: "Behavioral anomalies and sequence deviations",
"high-risk": "High-risk policy outliers (80+)",
@@ -232,10 +260,18 @@ function setAuditFilter(filter) {
function renderAudit(events) {
const body = $("#audit-body");
- if (!events.length) { body.innerHTML = '
| No assessments recorded. |
'; return; }
- body.innerHTML = events.map((item, index) => {
+ const pageCount = Math.max(1, Math.ceil(events.length / state.auditPageSize));
+ state.auditPage = Math.max(1, Math.min(state.auditPage, pageCount));
+ const start = (state.auditPage - 1) * state.auditPageSize;
+ const visible = events.slice(start, start + state.auditPageSize);
+ $("#audit-range").textContent = events.length ? `Showing ${start + 1}–${start + visible.length} of ${events.length}` : "0 events";
+ $("#audit-page-label").textContent = `Page ${state.auditPage} of ${pageCount}`;
+ $("#audit-previous").disabled = state.auditPage <= 1;
+ $("#audit-next").disabled = state.auditPage >= pageCount;
+ if (!visible.length) { body.innerHTML = '
| No assessments recorded. |
'; return; }
+ body.innerHTML = visible.map((item, index) => {
const risk = Math.max(0, Math.min(100, Number(item.risk_score || 0)));
- const detailId = `audit-detail-${index}`;
+ const detailId = `audit-detail-${start + index}`;
const evidence = Array.isArray(item.signal_details) && item.signal_details.length
? item.signal_details
: (item.signals || []).map((name) => ({ name, score: null, detail: "Signal recorded before detailed evidence capture was enabled." }));
@@ -267,6 +303,12 @@ function renderAudit(events) {
}).join("");
}
+function changeAuditPage(delta) {
+ state.auditPage += delta;
+ renderAudit(getFilteredAuditEvents());
+ $("#audit").scrollIntoView({ behavior: "smooth", block: "start" });
+}
+
function toggleAuditDetail(row) {
const detail = document.getElementById(row.dataset.auditToggle);
if (!detail) return;
@@ -284,6 +326,7 @@ async function loadAudit() {
function renderReviews(reviews) {
const pending = reviews.filter((item) => item.status === "pending");
$("#review-badge").textContent = pending.length;
+ $("#priority-reviews").textContent = pending.length;
const list = $("#review-list");
if (!reviews.length) { list.innerHTML = '
No commands are waiting for review.
'; return; }
list.innerHTML = reviews.slice(0, 30).map((item) => `
@@ -408,6 +451,145 @@ async function loadTrustControls() {
catch (error) { if (state.token) toast(error.message); }
}
+function renderSecurityPolicies(data) {
+ state.securityPolicies = data;
+ const summary = data.summary || {};
+ $("#security-policy-summary").textContent = `${summary.active || 0}/${summary.domains || 0} ACTIVE · ${summary.known_exploited_cves || 0} EXPLOITED CVEs`;
+ $("#cve-intelligence-notice").textContent = data.intelligence_notice || "";
+ $("#security-policy-cards").innerHTML = (data.policies || []).map((policy) => {
+ const coverage = policy.total_endpoint_count ? Math.round(policy.covered_endpoint_count / policy.total_endpoint_count * 100) : 0;
+ const groups = (policy.assigned_groups || []).map((group) => ``).join("");
+ const endpoints = (policy.covered_endpoints || []).map((endpoint) => `${escapeHtml(endpoint.hostname)}${endpoint.agent_version ? `agent v${escapeHtml(endpoint.agent_version)}` : "agent not installed"}`).join("");
+ const cves = (policy.cve_alerts || []).map((alert) => `${escapeHtml(alert.id)}${alert.known_exploited ? "KNOWN EXPLOITED" : "INVESTIGATE"}
${escapeHtml(alert.summary)}
${alert.affected_endpoints} affected · ${escapeHtml(alert.status)} `).join("");
+ return `
+ ${escapeHtml(policy.domain)}${policy.enabled ? "ACTIVE" : "DISABLED"}${escapeHtml(policy.enforcement_mode).toUpperCase()}
+ ${escapeHtml(policy.name)}
${escapeHtml(policy.description)}
+ ${policy.covered_endpoint_count}/${policy.total_endpoint_count}ENDPOINTS COVERED
${policy.online_endpoint_count}ONLINE
${coverage}%
+
+ ${(policy.rules || []).map((rule) => `✓ ${escapeHtml(rule)}`).join("")}
+ ASSIGNED SECURITY GROUPS
${groups}
+ ${cves ? `MALICIOUS CVE INTELLIGENCE
${cves}
` : ""}
+ View ${policy.covered_endpoint_count} covered endpoints
+ `;
+ }).join("");
+ if (state.inventory) renderGroupInventory(state.inventory, $("#inventory-search")?.value || "");
+}
+
+async function loadSecurityPolicies() {
+ try { renderSecurityPolicies(await api("/v1/security-policies")); }
+ catch (error) { if (state.token) toast(error.message); }
+}
+
+function updateDeploymentCommand() {
+ const group = $("#deploy-group").value || "";
+ const version = $("#deploy-version").value || "0.4.0";
+ $("#network-command-preview").textContent = `uig-admin deploy --server http://intentgate.example:8787 --group ${group} --version ${version}`;
+}
+
+function renderInventory(data) {
+ state.inventory = data;
+ const summary = data.summary || {};
+ $("#admin-total").textContent = summary.total || 0;
+ $("#admin-online").textContent = summary.online || 0;
+ $("#admin-managed").textContent = summary.managed || 0;
+ $("#admin-groups").textContent = summary.groups || 0;
+ $("#admin-queued").textContent = summary.queued_jobs || 0;
+ const latest = data.latest_discovery;
+ $("#discovery-status").textContent = latest ? `${String(latest.status).toUpperCase()} · ${formatTime(latest.completed_at)}` : "INVENTORY READY";
+ const groups = data.security_groups || [];
+ $("#security-group-list").innerHTML = groups.length ? groups.map((group) => ``).join("") : 'No security groups discovered.
';
+ const selectedGroup = $("#deploy-group").value;
+ $("#deploy-group").innerHTML = '' + groups.map((group) => ``).join("");
+ if (groups.some((group) => group.name === selectedGroup)) $("#deploy-group").value = selectedGroup;
+ const endpoints = data.endpoints || [];
+ $("#endpoint-body").innerHTML = endpoints.length ? endpoints.map((item) => `
+ | ${escapeHtml(item.hostname)}${escapeHtml(item.id)} |
+ ${escapeHtml(item.ip_address)} | ${escapeHtml(item.operating_system)} |
+ ${(item.security_groups || []).map((group) => `${escapeHtml(group)}`).join("")} |
+ ${item.agent_version ? `v${escapeHtml(item.agent_version)}` : 'not installed'} |
+ ${escapeHtml(item.status)} |
+
`).join("") : '| No endpoints discovered. |
';
+ renderGroupInventory(data, $("#inventory-search")?.value || "");
+ updateDeploymentCommand();
+}
+
+function renderGroupInventory(data, query = "") {
+ const endpoints = data.endpoints || [];
+ const groups = data.security_groups || [];
+ const normalized = query.trim().toLowerCase();
+ $("#inventory-group-count").textContent = groups.length;
+ $("#inventory-endpoint-count").textContent = endpoints.length;
+ $("#inventory-online-count").textContent = endpoints.filter((item) => item.status === "online").length;
+ $("#inventory-managed-count").textContent = endpoints.filter((item) => item.agent_version).length;
+ const policies = state.securityPolicies?.policies || [];
+ const visible = groups.map((group) => ({
+ ...group,
+ endpoints: endpoints.filter((item) => (item.security_groups || []).includes(group.name)),
+ policies: policies.filter((policy) => (policy.assigned_groups || []).includes(group.name)),
+ })).filter((group) => !normalized || group.name.toLowerCase().includes(normalized) || group.endpoints.some((item) => `${item.hostname} ${item.id}`.toLowerCase().includes(normalized)));
+ $("#inventory-group-grid").innerHTML = visible.length ? visible.map((group) => `
+ SECURITY GROUP
${escapeHtml(group.name)}
${group.endpoint_count}
+ ${group.online_count} online${group.managed_count} managed${group.policies.length} policies
+ ${group.policies.length ? group.policies.map((policy) => `${escapeHtml(policy.domain)} · ${escapeHtml(policy.enforcement_mode)}`).join("") : "NO POLICY ASSIGNMENT"}
+
+ `).join("") : 'No security groups or endpoints match this filter.
';
+}
+
+function openInventoryGroup(group) {
+ $("#inventory-search").value = group;
+ if (state.inventory) renderGroupInventory(state.inventory, group);
+ window.location.hash = "inventory";
+ $("#inventory").scrollIntoView({ behavior: "smooth", block: "start" });
+}
+
+async function loadInventory() {
+ try { renderInventory(await api("/v1/endpoints")); }
+ catch (error) { if (state.token) toast(error.message); }
+}
+
+function renderDeployments(deployments) {
+ state.deployments = deployments;
+ $("#deployment-list").innerHTML = deployments.length ? deployments.slice(0, 8).map((item) => {
+ const queued = (item.jobs || []).filter((job) => ["queued", "deferred"].includes(job.status)).length;
+ return `${escapeHtml(item.security_group || "selected endpoints")} · v${escapeHtml(item.version)}${formatTime(item.created_at)} · ${escapeHtml(item.requested_by)}
${item.execute ? escapeHtml(item.status).toUpperCase() : "DRY-RUN PLAN"}${item.matched_endpoints} targets${queued ? ` · ${queued} queued` : ""}`;
+ }).join("") : 'No deployments have been planned.
';
+}
+
+async function loadDeployments() {
+ try { const data = await api("/v1/deployments?limit=20"); renderDeployments(data.deployments || []); }
+ catch (error) { if (state.token) toast(error.message); }
+}
+
+async function runDiscovery() {
+ const button = $("#run-discovery");
+ button.disabled = true;
+ button.textContent = "Discovering…";
+ try {
+ const result = await api("/v1/discovery-sessions", { method: "POST", body: JSON.stringify({ requested_by: $("#deploy-requestor").value || "console-admin" }) });
+ toast(`Discovery complete: ${result.endpoint_count} endpoints across ${result.security_group_count} groups.`);
+ await loadInventory();
+ } catch (error) { toast(error.message); }
+ finally { button.disabled = false; button.textContent = "Run discovery session"; }
+}
+
+async function submitDeployment(event) {
+ event.preventDefault();
+ const execute = event.submitter?.value === "execute";
+ const payload = {
+ security_group: $("#deploy-group").value,
+ version: $("#deploy-version").value,
+ execute,
+ requested_by: $("#deploy-requestor").value,
+ };
+ event.submitter.disabled = true;
+ try {
+ const result = await api("/v1/deployments", { method: "POST", body: JSON.stringify(payload) });
+ toast(execute ? `Deployment queued to ${result.matched_endpoints} endpoints.` : `Plan validated for ${result.matched_endpoints} endpoints.`);
+ await Promise.all([loadDeployments(), loadInventory()]);
+ } catch (error) { toast(error.message); }
+ finally { event.submitter.disabled = false; }
+}
+
async function saveTrustControls(event) {
event.preventDefault();
const submit = event.submitter;
@@ -455,9 +637,30 @@ function bindEvents() {
$$("[data-investigate]").forEach((button) => button.addEventListener("click", () => setAuditFilter(button.dataset.investigate)));
$("#clear-audit-filter").addEventListener("click", () => setAuditFilter(null));
$("#refresh-audit").addEventListener("click", loadAudit);
+ $("#audit-previous").addEventListener("click", () => changeAuditPage(-1));
+ $("#audit-next").addEventListener("click", () => changeAuditPage(1));
+ $("#audit-page-size").addEventListener("change", (event) => {
+ state.auditPageSize = Number(event.target.value);
+ state.auditPage = 1;
+ renderAudit(getFilteredAuditEvents());
+ });
$("#refresh-reviews").addEventListener("click", loadReviews);
$("#policy-form").addEventListener("submit", savePolicy);
+ $("#security-policy-cards").addEventListener("click", (event) => { const group = event.target.closest("[data-inventory-group]"); if (group) openInventoryGroup(group.dataset.inventoryGroup); });
$("#trust-controls-form").addEventListener("submit", saveTrustControls);
+ $("#run-discovery").addEventListener("click", runDiscovery);
+ $("#deployment-form").addEventListener("submit", submitDeployment);
+ $("#refresh-deployments").addEventListener("click", loadDeployments);
+ $("#deploy-group").addEventListener("change", updateDeploymentCommand);
+ $("#deploy-version").addEventListener("input", updateDeploymentCommand);
+ $("#inventory-search").addEventListener("input", (event) => { if (state.inventory) renderGroupInventory(state.inventory, event.target.value); });
+ $("#security-group-list").addEventListener("click", (event) => {
+ const selected = event.target.closest("[data-deploy-group]");
+ if (!selected) return;
+ $("#deploy-group").value = selected.dataset.deployGroup;
+ updateDeploymentCommand();
+ $("#deployment-form").scrollIntoView({ behavior: "smooth", block: "center" });
+ });
$("#token-button").addEventListener("click", openTokenDialog);
$("#token-form").addEventListener("submit", (event) => {
if (event.submitter?.value === "cancel") return;
@@ -474,7 +677,7 @@ function bindEvents() {
sections.forEach((section) => observer.observe(section));
}
-async function refreshAll() { await Promise.all([loadOverview(), loadReviews(), loadPolicy(), loadModelStatus(), loadTrustControls()]); }
+async function refreshAll() { await Promise.all([loadOverview(), loadReviews(), loadPolicy(), loadSecurityPolicies(), loadModelStatus(), loadTrustControls(), loadInventory(), loadDeployments()]); }
bindEvents();
$("#token-label").textContent = state.token ? "Token configured" : "Set API token";
diff --git a/src/intentgate/web/index.html b/src/intentgate/web/index.html
index d1e5db4..9d35368 100644
--- a/src/intentgate/web/index.html
+++ b/src/intentgate/web/index.html
@@ -21,7 +21,9 @@
04Audit Trail
05Policy
06Trust Controls
- 07AI Advisor
+ 07Fleet Admin
+ 08Inventory
+ 09AI Advisor
+
AI
@@ -85,6 +94,7 @@
AI-assisted. Behavior-aware. Zero trust.
OBSERVATIONBuilding a behavioral baseline.
TOP OUTLIERNot enough evidence yet
+
NEXT BEST ACTIONValidate the highest-deviation sequence with its owner.
@@ -95,6 +105,7 @@
AI-assisted. Behavior-aware. Zero trust.
OBSERVATIONCollecting representative command families.
+ NEXT BEST ACTIONLabel high-risk outliers to improve baseline precision.
@@ -105,6 +116,7 @@
AI-assisted. Behavior-aware. Zero trust.
OBSERVATIONNo pending topology changes.
EDGEAPPOBSMGMTOUT
+ NEXT BEST ACTIONReview denied-flow telemetry before changing paths.
Edit network paths →
@@ -115,6 +127,7 @@
AI-assisted. Behavior-aware. Zero trust.
TRUST GAPSAnalyzing recent decisions.
IDENTITY→INTENT→POLICY→AUDIT
+ NEXT BEST ACTIONRestore identity, endpoint, and intent coverage.
@@ -161,15 +174,29 @@ AI-assisted. Behavior-aware. Zero trust.
ACTIVE INVESTIGATIONFiltered evidence
-
+
+
0 eventsNewest decisions first
+
+
+
| Time | Decision | User | Endpoint | Command | Risk |
| No assessments recorded. |
+
+
+ DEFENSE POLICY CATALOG
Endpoint security policies
See enforcement, rules, assigned security groups, endpoint coverage, and current vulnerability intelligence.
LOADING
+ Loading endpoint security policies.
+
+
ACTIVE POLICY
Decision thresholds
v1
+
+
+
NETWORK DEPLOYMENT CONTROL PLANE
Fleet administration
Discover enrolled assets, inspect security-group coverage, and distribute a fixed Intent Gate installation manifest.
+
ORCHESTRATOR ONLINE
+
+
+
DISCOVERED ENDPOINTS0inventory records
+
ONLINE NOW0agent heartbeat
+
MANAGED0Intent Gate installed
+
SECURITY GROUPS0deployment selectors
+
QUEUED JOBS0agent-pull transport
+
+
+
+
+ LATEST DISCOVERY
Endpoints & security groups
NOT RUN
+ Connect to load security groups.
+
+
+ | Endpoint | Address | OS | Groups | Agent | Status |
+ | No endpoints discovered. |
+
+
+
+
+
+ CONTROLLED SOFTWARE DISTRIBUTION
Deploy Intent Gate
FIXED MANIFEST
+
+
+ No deployments have been planned.
+
+
+
+
+
+
+
ASSET & IDENTITY INVENTORY
Security groups and endpoints
Explore policy targets by group and inspect every member endpoint, platform, agent version, and connectivity state.
+
+
+
+
GROUPS0
+
ENDPOINTS0
+
ONLINE0
+
MANAGED0
+
+ Connect to load group inventory.
+
+
PLUGGABLE INTELLIGENCE
AI model advisor
diff --git a/tests/test_service.py b/tests/test_service.py
index 3187454..7f332dd 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -127,6 +127,62 @@ def test_operator_console_assessment_review_policy_and_audit(self):
self.assertEqual(detailed["endpoint_name"], "DEV-WS-042")
self.assertTrue(detailed["signal_details"])
+ def test_fleet_discovery_and_controlled_deployment(self):
+ token = "synthetic-admin-token"
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
+ os.environ, {"UIG_STATE_DIR": directory, "UIG_INGEST_TOKEN": token}
+ ):
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ base = f"http://127.0.0.1:{server.server_port}"
+ try:
+ inventory = json.loads(self._request(f"{base}/v1/endpoints", token).read())
+ security_policies = json.loads(self._request(f"{base}/v1/security-policies", token).read())
+ updated_dlp = json.loads(self._request(
+ f"{base}/v1/security-policies", token, "POST",
+ {"id": "data-loss-prevention", "enabled": True, "enforcement_mode": "enforce", "assigned_groups": ["finance", "high-value"]},
+ ).read())
+ discovery = json.loads(self._request(
+ f"{base}/v1/discovery-sessions", token, "POST", {"requested_by": "test-admin"}
+ ).read())
+ planned = json.loads(self._request(
+ f"{base}/v1/deployments", token, "POST",
+ {"security_group": "engineering", "version": "0.4.0", "execute": False},
+ ).read())
+ queued = json.loads(self._request(
+ f"{base}/v1/deployments", token, "POST",
+ {"security_group": "engineering", "version": "0.4.0", "execute": True},
+ ).read())
+ job = json.loads(self._request(
+ f"{base}/v1/deployment-jobs/next?endpoint_id=dev-ws-042", token
+ ).read())["job"]
+ completed = json.loads(self._request(
+ f"{base}/v1/deployment-jobs/{job['id']}", token, "POST",
+ {"status": "succeeded", "detail": "package installed"},
+ ).read())
+ deployments = json.loads(self._request(f"{base}/v1/deployments", token).read())
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=2)
+ self.assertEqual(inventory["summary"]["total"], 8)
+ self.assertGreaterEqual(inventory["summary"]["groups"], 8)
+ self.assertEqual(security_policies["summary"]["domains"], 5)
+ self.assertEqual(security_policies["summary"]["known_exploited_cves"], 2)
+ self.assertEqual(updated_dlp["enforcement_mode"], "enforce")
+ self.assertEqual(updated_dlp["assigned_groups"], ["finance", "high-value"])
+ self.assertEqual(updated_dlp["covered_endpoint_count"], 1)
+ self.assertEqual(discovery["status"], "completed")
+ self.assertEqual(discovery["endpoint_count"], 8)
+ self.assertEqual(planned["status"], "planned")
+ self.assertEqual(planned["matched_endpoints"], 3)
+ self.assertEqual(queued["status"], "queued")
+ self.assertTrue(all(item["manifest"]["action"] == "install-or-upgrade-intentgate" for item in queued["jobs"]))
+ self.assertNotIn("command", queued["jobs"][0]["manifest"])
+ self.assertEqual(completed["status"], "succeeded")
+ self.assertEqual(len(deployments["deployments"]), 2)
+
if __name__ == "__main__":
unittest.main()