From 7509afd1a2600cfe60f383caa50d71c0b1deb3cc Mon Sep 17 00:00:00 2001 From: Chris Botelho Date: Thu, 7 May 2026 09:21:17 -0500 Subject: [PATCH 01/14] auth login: allow user-scoped API key without --oid (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator currently rejects `auth login --uid X --api-key Y` with "Error: --oid and --api-key are required for API key login.", even though `--uid` is documented as the flag for user-scoped API keys. This makes brand-new accounts un-bootstrappable from the CLI because they have no organization yet — chicken-and-egg, since the user can't list/create their first org without working CLI auth, but can't auth without an OID, but doesn't have an OID until they create an org. The User API Key itself is a fully valid credential — env-var auth (LC_UID + LC_API_KEY) works for `auth list-orgs` already, which is the only call needed to bootstrap from "I have a User API Key" to "I know my OIDs". The bug was purely in the `auth login` validator forcing an --oid constraint that the rest of the CLI doesn't need. Fix: rework the validator to accept either: --oid + --api-key (org-scoped key — existing behavior) --uid + --api-key (user-scoped key — new path, --oid optional) When neither --oid nor --uid is provided, emit a clear error distinguishing the two key types. The `write_credentials` call already handles oid=None correctly (skips writing the field), so no downstream changes needed. Tested: - `auth login --uid X --api-key Y` (no --oid) → succeeds - `auth list-orgs` against those credentials → returns the user's orgs - `auth login --api-key Y` (neither --oid nor --uid) → clear error - `auth login --oid X --api-key Y` (existing org-scoped path) → unchanged - `auth login --oauth --provider google` → unchanged --- limacharlie/commands/auth.py | 40 +++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/limacharlie/commands/auth.py b/limacharlie/commands/auth.py index 405e5544..c14409c6 100644 --- a/limacharlie/commands/auth.py +++ b/limacharlie/commands/auth.py @@ -129,18 +129,34 @@ def login(ctx: click.Context, oid: str | None, api_key: str | None, environment: if oauth: _login_oauth(ctx, oid, env_name, provider, no_browser) - else: - if not oid or not api_key: - click.echo( - "Error: --oid and --api-key are required for API key login.\n" - "Suggestion: Use --oauth for browser-based OAuth login, or provide both --oid and --api-key.", - err=True, - ) - ctx.exit(4) - return - write_credentials(env_name, oid=oid, api_key=api_key, uid=uid or "") - if not ctx.obj.quiet: - click.echo(f"Credentials saved for environment '{env_name}'.") + return + + # API key login. Two valid shapes: + # 1. --oid + --api-key — org-scoped key (and optional --uid for service accounts). + # 2. --uid + --api-key (oid optional) — user-scoped key on a brand-new account with no orgs yet. + if not api_key: + click.echo( + "Error: --api-key is required for API key login.\n" + "Suggestion: Use --oauth for browser-based OAuth login, or provide --api-key with " + "either --oid (org-scoped key) or --uid (user-scoped key).", + err=True, + ) + ctx.exit(4) + return + + if not oid and not uid: + click.echo( + "Error: provide either --oid (org-scoped key) or --uid (user-scoped key) along with --api-key.\n" + "Suggestion: --uid is correct for User API Keys generated under your account profile; " + "--oid is correct for Organization API Keys generated under an org's settings.", + err=True, + ) + ctx.exit(4) + return + + write_credentials(env_name, oid=oid, api_key=api_key, uid=uid or "") + if not ctx.obj.quiet: + click.echo(f"Credentials saved for environment '{env_name}'.") def _login_oauth(ctx: click.Context, oid: str | None, env_name: str, provider: str, no_browser: bool) -> None: From 82f8777bf1c591a7193e40ed4861758b9264eb0a Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 7 May 2026 08:33:01 -0700 Subject: [PATCH 02/14] auth login: clear stale oid on user-scoped re-login + tests (#291) Follow-up to #289. When a user re-runs `auth login --uid X --api-key Y` in an environment that previously held an `--oid + --api-key` login, the old `oid` survived in config because `write_credentials` only writes fields it is given. Subsequent commands would silently pair the new user-scoped credentials with the stale org context. Fix it by dropping the `oid` field from the affected env block right after the write whenever the login was user-scoped (`uid` set, `oid` unset). Mirrors the post-write cleanup pattern already used by the OAuth flow for `api_key`. Also refresh the `--ai-help` text so it advertises the user-scoped shape (`--uid + --api-key`, no `--oid`) that #289 enabled, and add unit tests for the validator branches and the stale-oid cleanup -- neither was covered before. Co-authored-by: Claude Opus 4.7 (1M context) --- limacharlie/commands/auth.py | 32 +++- tests/unit/test_cli_auth_login.py | 242 ++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_cli_auth_login.py diff --git a/limacharlie/commands/auth.py b/limacharlie/commands/auth.py index c14409c6..4205a939 100644 --- a/limacharlie/commands/auth.py +++ b/limacharlie/commands/auth.py @@ -70,11 +70,16 @@ def group() -> None: Two authentication methods are supported: - API Key: limacharlie auth login --oid --api-key - OAuth: limacharlie auth login --oauth [--oid ] + API Key (org-scoped): limacharlie auth login --oid --api-key + API Key (user-scoped): limacharlie auth login --uid --api-key + OAuth: limacharlie auth login --oauth [--oid ] -For API key login, supply --oid and --api-key. If you are using a -user-scoped API key, also pass --uid. +For API key login, supply --api-key together with either --oid (for an +Organization API Key generated under an org's settings) or --uid (for a +User API Key generated under your account profile). User-scoped keys +do not require --oid and are the right choice for brand-new accounts +that have not created an org yet -- run 'auth list-orgs' afterwards to +discover your OIDs. For OAuth login, pass --oauth to authenticate via your browser using Google or Microsoft. Use --provider to choose (default: google). @@ -155,10 +160,29 @@ def login(ctx: click.Context, oid: str | None, api_key: str | None, environment: return write_credentials(env_name, oid=oid, api_key=api_key, uid=uid or "") + + # User-scoped login (--uid + --api-key, no --oid): clear any stale oid + # left over from a previous org-scoped login in this env, otherwise the + # new user creds would silently inherit the old org context. + if not oid and uid: + _clear_stale_oid(env_name) + if not ctx.obj.quiet: click.echo(f"Credentials saved for environment '{env_name}'.") +def _clear_stale_oid(env_name: str) -> None: + """Remove any stale ``oid`` field from the given environment block.""" + config = load_config() or {} + if env_name == "default" or env_name is None: + if config.pop("oid", None) is not None: + save_config(config) + else: + env_data = config.get("env", {}).get(env_name, {}) + if env_data.pop("oid", None) is not None: + save_config(config) + + def _login_oauth(ctx: click.Context, oid: str | None, env_name: str, provider: str, no_browser: bool) -> None: """Perform OAuth login via browser.""" try: diff --git a/tests/unit/test_cli_auth_login.py b/tests/unit/test_cli_auth_login.py new file mode 100644 index 00000000..c5f42561 --- /dev/null +++ b/tests/unit/test_cli_auth_login.py @@ -0,0 +1,242 @@ +"""Tests for ``limacharlie auth login`` argument validation and credential +persistence. + +Covers the three login shapes supported by the CLI: + +* ``--oid + --api-key`` -- org-scoped API key +* ``--uid + --api-key`` (with optional --oid) -- user-scoped API key +* ``--oauth`` -- not exercised here + +and the error paths when required flags are missing. Also asserts that a +user-scoped login clears any stale oid persisted from a previous org-scoped +login in the same environment. +""" + +import os + +import pytest +from click.testing import CliRunner + +from limacharlie.cli import cli +from limacharlie.config import load_config + + +@pytest.fixture +def tmp_config_file(monkeypatch, tmp_path): + """Point config resolution at a fresh temp directory. + + Mirrors the fixture in ``test_config.py`` so each test starts with no + persisted credentials and a clean cache. + """ + from limacharlie.config import _reset_config_cache + from limacharlie.paths import _reset_path_cache + + config_dir = str(tmp_path / "lc_config") + os.makedirs(config_dir, exist_ok=True) + config_path = os.path.join(config_dir, "config.yaml") + + monkeypatch.setenv("LC_CONFIG_DIR", config_dir) + monkeypatch.delenv("LC_CREDS_FILE", raising=False) + monkeypatch.delenv("LC_LEGACY_CONFIG", raising=False) + monkeypatch.delenv("LC_OID", raising=False) + monkeypatch.delenv("LC_API_KEY", raising=False) + monkeypatch.delenv("LC_UID", raising=False) + monkeypatch.delenv("LC_CURRENT_ENV", raising=False) + monkeypatch.delenv("LC_EPHEMERAL_CREDS", raising=False) + _reset_path_cache() + _reset_config_cache() + yield config_path + _reset_path_cache() + _reset_config_cache() + + +class TestLoginValidator: + """The validator should accept any of the three valid flag shapes and + reject everything else with a clear error and exit code 4.""" + + def test_org_scoped_login_succeeds(self, tmp_config_file): + runner = CliRunner() + result = runner.invoke(cli, [ + "auth", "login", + "--oid", "org-1", + "--api-key", "key-1", + ]) + assert result.exit_code == 0, result.output + config = load_config() + assert config["oid"] == "org-1" + assert config["api_key"] == "key-1" + assert "uid" not in config + + def test_user_scoped_login_without_oid_succeeds(self, tmp_config_file): + runner = CliRunner() + result = runner.invoke(cli, [ + "auth", "login", + "--uid", "user-1", + "--api-key", "key-1", + ]) + assert result.exit_code == 0, result.output + config = load_config() + assert config["api_key"] == "key-1" + assert config["uid"] == "user-1" + assert "oid" not in config + + def test_service_account_login_with_all_three_flags(self, tmp_config_file): + runner = CliRunner() + result = runner.invoke(cli, [ + "auth", "login", + "--oid", "org-1", + "--uid", "user-1", + "--api-key", "key-1", + ]) + assert result.exit_code == 0, result.output + config = load_config() + assert config["oid"] == "org-1" + assert config["api_key"] == "key-1" + assert config["uid"] == "user-1" + + def test_missing_api_key_errors(self, tmp_config_file): + runner = CliRunner() + result = runner.invoke(cli, ["auth", "login", "--oid", "org-1"]) + assert result.exit_code == 4 + assert "--api-key is required" in result.output + assert load_config() is None + + def test_missing_oid_and_uid_errors(self, tmp_config_file): + runner = CliRunner() + result = runner.invoke(cli, ["auth", "login", "--api-key", "key-1"]) + assert result.exit_code == 4 + # The error should distinguish the two key types so the user can + # pick the right one from the LC web UI. + assert "--oid" in result.output + assert "--uid" in result.output + assert load_config() is None + + +class TestLoginNamedEnvironment: + """Login should write under the correct env block, not the default + block, when --env is supplied.""" + + def test_user_scoped_login_writes_to_named_env(self, tmp_config_file): + runner = CliRunner() + result = runner.invoke(cli, [ + "auth", "login", + "--env", "staging", + "--uid", "user-1", + "--api-key", "key-1", + ]) + assert result.exit_code == 0, result.output + config = load_config() + assert config["env"]["staging"]["api_key"] == "key-1" + assert config["env"]["staging"]["uid"] == "user-1" + assert "oid" not in config["env"]["staging"] + + +class TestStaleOidCleanup: + """Switching to user-scoped credentials must drop any stale oid left + behind by a previous org-scoped login in the same environment. + Otherwise subsequent commands would pair the new user-scoped api key + with the wrong org context.""" + + def test_user_scoped_relogin_clears_default_oid(self, tmp_config_file): + runner = CliRunner() + # First, an org-scoped login persists oid=org-old. + first = runner.invoke(cli, [ + "auth", "login", + "--oid", "org-old", + "--api-key", "key-old", + ]) + assert first.exit_code == 0, first.output + assert load_config()["oid"] == "org-old" + + # Then a user-scoped login on the same env (no --oid) must drop it. + second = runner.invoke(cli, [ + "auth", "login", + "--uid", "user-new", + "--api-key", "key-new", + ]) + assert second.exit_code == 0, second.output + config = load_config() + assert "oid" not in config + assert config["api_key"] == "key-new" + assert config["uid"] == "user-new" + + def test_user_scoped_relogin_clears_named_env_oid(self, tmp_config_file): + runner = CliRunner() + first = runner.invoke(cli, [ + "auth", "login", + "--env", "staging", + "--oid", "org-old", + "--api-key", "key-old", + ]) + assert first.exit_code == 0, first.output + assert load_config()["env"]["staging"]["oid"] == "org-old" + + second = runner.invoke(cli, [ + "auth", "login", + "--env", "staging", + "--uid", "user-new", + "--api-key", "key-new", + ]) + assert second.exit_code == 0, second.output + env_block = load_config()["env"]["staging"] + assert "oid" not in env_block + assert env_block["api_key"] == "key-new" + assert env_block["uid"] == "user-new" + + def test_user_scoped_relogin_does_not_touch_other_envs(self, tmp_config_file): + runner = CliRunner() + # Persist an oid in 'production' that should be left alone. + prod_login = runner.invoke(cli, [ + "auth", "login", + "--env", "production", + "--oid", "org-prod", + "--api-key", "key-prod", + ]) + assert prod_login.exit_code == 0, prod_login.output + + # User-scoped login in 'staging' must not affect 'production'. + staging_login = runner.invoke(cli, [ + "auth", "login", + "--env", "staging", + "--uid", "user-1", + "--api-key", "key-1", + ]) + assert staging_login.exit_code == 0, staging_login.output + + config = load_config() + assert config["env"]["production"]["oid"] == "org-prod" + assert config["env"]["production"]["api_key"] == "key-prod" + assert "oid" not in config["env"]["staging"] + + def test_org_scoped_relogin_overwrites_oid(self, tmp_config_file): + """Sanity check: org-scoped re-login should *update* the oid, not + leave the old one in place. (This already worked pre-fix; the test + guards against a regression where the cleanup helper accidentally + clobbers the new value.)""" + runner = CliRunner() + first = runner.invoke(cli, [ + "auth", "login", + "--oid", "org-old", + "--api-key", "key-old", + ]) + assert first.exit_code == 0, first.output + + second = runner.invoke(cli, [ + "auth", "login", + "--oid", "org-new", + "--api-key", "key-new", + ]) + assert second.exit_code == 0, second.output + config = load_config() + assert config["oid"] == "org-new" + assert config["api_key"] == "key-new" + + +class TestExplainText: + """The --ai-help text should advertise the user-scoped shape so users + discovering the CLI know the flag combo is supported.""" + + def test_explain_mentions_user_scoped_form(self): + from limacharlie.commands.auth import _EXPLAIN_LOGIN + assert "--uid --api-key " in _EXPLAIN_LOGIN + assert "user-scoped" in _EXPLAIN_LOGIN.lower() From 27aae1f535bc10046f6e55105b5d8b326e80738f Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Tue, 12 May 2026 20:31:02 -0700 Subject: [PATCH 03/14] vulnerability: fix search payload key + complete extension coverage (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's search dict was sending {"search": , ...} but the extension's parseSearchOp reads s["field"] — so every --search-* call was silently dropped server-side. Switch to {"field": ...} and update the assertion that locked in the wrong shape. Round out the rest of the surface the extension exposes: - new flags: --include-enrichment, --filter-via-state on the list commands; --normalized-package-name on cve hosts; --rollup-subpackages on host packages; --include-enrichment on cve get / cve packages - new subcommands: - vuln cve epss-history (query_epss_history) - vuln finding resolve / bulk-resolve / list / reset (set_finding_resolution, bulk_set_finding_resolution, list_finding_resolutions, reset_asset_findings) - vuln snapshot list (query_daily_snapshots) - help text now mentions lc_risk as a valid --sort-by on cve list and host packages Co-authored-by: Claude Opus 4.7 (1M context) --- limacharlie/commands/vulnerability.py | 425 ++++++++++++++++-- limacharlie/sdk/vulnerability.py | 236 +++++++++- .../unit/test_cli_lazy_loading_regression.py | 4 +- tests/unit/test_cli_vulnerability.py | 343 +++++++++++++- tests/unit/test_sdk_vulnerability.py | 136 +++++- 5 files changed, 1090 insertions(+), 54 deletions(-) diff --git a/limacharlie/commands/vulnerability.py b/limacharlie/commands/vulnerability.py index c0bf4a60..fa3d9129 100644 --- a/limacharlie/commands/vulnerability.py +++ b/limacharlie/commands/vulnerability.py @@ -2,10 +2,11 @@ Commands for querying the ``ext-vulnerability-reporting`` extension: list CVEs, list affected endpoints, get the dashboard, drill into a -single host or CVE, and trigger an on-demand scan of a sensor. +single host or CVE, trigger an on-demand scan, manage finding +resolutions, and read daily / EPSS time-series. Subscription management lives under ``limacharlie extension``; this -module is purely the user-facing query surface. +module is purely the user-facing query + write surface. """ from __future__ import annotations @@ -41,8 +42,8 @@ limacharlie vulnerability scan --sid -Once data has flowed in, use the cve / host / dashboard subcommands -to query it. +Once data has flowed in, use the cve / host / dashboard / finding / +snapshot subcommands to query it. """ _EXPLAIN_SCAN = """\ @@ -80,7 +81,7 @@ --search-field cve --search-op contains --search-value CVE-2025 -Sort by 'cve', 'count', or 'severity'. +Sort by 'cve', 'count', 'severity', or 'lc_risk'. Examples: limacharlie vulnerability cve list @@ -92,7 +93,9 @@ Return raw details for a single CVE id. The response shape is the upstream NVD record (descriptions, CVSS -metrics, weaknesses, references, configurations). +metrics, weaknesses, references, configurations). KEV / EPSS / +exploit-ref enrichment is attached by default; pass +``--no-include-enrichment`` for cheap admin lookups. Examples: limacharlie vulnerability cve get CVE-2021-44228 @@ -104,6 +107,11 @@ Sort by 'hostname' (default) or 'platform_string'. +When you already know the normalized package name (e.g. you reached +this view by drilling from cve packages), pass it via +``--normalized-package-name`` so the resolution overlay can read +host-scope rows too — without it, only org-scope state hits land. + Examples: limacharlie vulnerability cve hosts CVE-2021-44228 limacharlie vulnerability cve hosts CVE-2021-44228 --include-tags @@ -121,6 +129,17 @@ limacharlie vulnerability cve packages CVE-2021-44228 --sort-by package_name --sort-asc """ +_EXPLAIN_CVE_EPSS_HISTORY = """\ +Return per-day EPSS score / percentile rows for one CVE. + +Used to render a sparkline of how the exploit-prediction score has +moved over time. Result is ordered snapshot_date ASC. + +Examples: + limacharlie vulnerability cve epss-history CVE-2024-1234 + limacharlie vulnerability cve epss-history CVE-2024-1234 --days 30 +""" + _EXPLAIN_HOST_LIST = """\ List endpoints with their vulnerability counts. @@ -134,22 +153,103 @@ _EXPLAIN_HOST_PACKAGES = """\ List vulnerable packages and their CVEs on a single host. -Sort by 'cve' (default), 'score', 'severity', 'package_name', or -'package_name_package_version_cve'. +Sort by 'cve' (default), 'score', 'severity', 'lc_risk', +'package_name', or 'package_name_package_version_cve'. + +Pass ``--rollup-subpackages`` to collapse rows for binary packages +built from the same source-package family (e.g. the vim / openssh / +systemd binaries co-installed from one source) into a single row +per (root, version, CVE). Examples: limacharlie vulnerability host packages 11111111-2222-3333-4444-555555555555 limacharlie vulnerability host packages --sort-by score """ +_EXPLAIN_FINDING_RESOLVE = """\ +Set or clear the resolution on a single finding. + +Identify the finding either by ``--fingerprint`` OR by +``--cve`` + ``--normalized-package-name`` (+ ``--sid`` when +``--scope host``). Pass ``--reopen`` (or omit ``--resolution``) to +remove the overlay row and let the finding revert to implicit +"open". + +Examples: + # Mark a chrome CVE accepted org-wide for 90 days + limacharlie vulnerability finding resolve --scope org \\ + --cve CVE-2024-1234 --normalized-package-name chrome \\ + --resolution accepted --expires-at 2026-08-12T00:00:00Z + + # Reopen the same finding + limacharlie vulnerability finding resolve --scope org \\ + --cve CVE-2024-1234 --normalized-package-name chrome --reopen +""" + +_EXPLAIN_FINDING_BULK_RESOLVE = """\ +Apply one resolution to up to 100 findings in a single call. + +``--targets-json`` takes a JSON array; each entry needs ``scope`` + +either ``fingerprint`` OR ``cve`` + ``normalized_package_name`` +(+ ``sid`` for host scope). Per-target failures are returned +alongside successes so partial successes are observable. + +Example: + limacharlie vulnerability finding bulk-resolve \\ + --resolution mitigated \\ + --targets-json '[{"scope":"org","fingerprint":"deadbeef..."}, + {"scope":"host","cve":"CVE-2024-1","normalized_package_name":"openssl","sid":""}]' +""" + +_EXPLAIN_FINDING_LIST = """\ +Page through the org's resolved findings (rows in vuln_finding_state). + +Only resolved rows are stored; an absent row means the finding is +implicitly open. Filters stack as AND. + +Examples: + limacharlie vulnerability finding list + limacharlie vulnerability finding list --scope org --resolution accepted +""" + +_EXPLAIN_FINDING_RESET = """\ +Wipe every stored finding for one sensor. + +Use this when a host has been reformatted / reimaged or +decommissioned and the existing findings no longer reflect reality. +Org-scope fingerprints that were only on this sensor fire +vuln_finding.closed events. The next package scan repopulates +findings from scratch. + +Example: + limacharlie vulnerability finding reset --sid 11111111-2222-3333-4444-555555555555 +""" + +_EXPLAIN_SNAPSHOT_LIST = """\ +Read the daily open-finding burndown counts. + +Returns persisted per-day per-severity counts (and the KEV subset) +ordered (snapshot_date ASC, severity ASC). + +Examples: + limacharlie vulnerability snapshot list + limacharlie vulnerability snapshot list --days 90 --severity critical --severity high +""" + register_explain("vulnerability.scan", _EXPLAIN_SCAN) register_explain("vulnerability.dashboard", _EXPLAIN_DASHBOARD) register_explain("vulnerability.cve.list", _EXPLAIN_CVE_LIST) register_explain("vulnerability.cve.get", _EXPLAIN_CVE_GET) register_explain("vulnerability.cve.hosts", _EXPLAIN_CVE_HOSTS) register_explain("vulnerability.cve.packages", _EXPLAIN_CVE_PACKAGES) +register_explain("vulnerability.cve.epss-history", _EXPLAIN_CVE_EPSS_HISTORY) register_explain("vulnerability.host.list", _EXPLAIN_HOST_LIST) register_explain("vulnerability.host.packages", _EXPLAIN_HOST_PACKAGES) +register_explain("vulnerability.finding.resolve", _EXPLAIN_FINDING_RESOLVE) +register_explain("vulnerability.finding.bulk-resolve", _EXPLAIN_FINDING_BULK_RESOLVE) +register_explain("vulnerability.finding.list", _EXPLAIN_FINDING_LIST) +register_explain("vulnerability.finding.reset", _EXPLAIN_FINDING_RESET) +register_explain("vulnerability.snapshot.list", _EXPLAIN_SNAPSHOT_LIST) # --------------------------------------------------------------------------- @@ -220,8 +320,10 @@ def _build_search( ) -> dict[str, Any] | None: """Build the search dict expected by the extension. - The extension shape is ``{"search": , "op": "is|contains", - "value": }``. All three flags must be set together, or none. + The extension shape is ``{"field": , "op": "is|contains", + "value": }`` — see parseSearchOp in + ext-vulnerability-reporting/ext/datasource.go which reads + ``s["field"]``. All three flags must be set together, or none. """ provided = [x is not None for x in (field, op, value)] if not any(provided): @@ -230,7 +332,7 @@ def _build_search( raise click.UsageError( "--search-field, --search-op, and --search-value must be used together", ) - return {"search": field, "op": op, "value": value} + return {"field": field, "op": op, "value": value} # --------------------------------------------------------------------------- @@ -238,6 +340,13 @@ def _build_search( # --------------------------------------------------------------------------- _SEARCH_OP_CHOICES = click.Choice(["is", "contains"], case_sensitive=False) +_SCOPE_CHOICES = click.Choice(["org", "host"], case_sensitive=False) +_RESOLUTION_CHOICES = click.Choice( + ["mitigated", "accepted", "false_positive"], case_sensitive=False, +) +_SEVERITY_CHOICES = click.Choice( + ["critical", "high", "medium", "low"], case_sensitive=False, +) def _common_query_options(f): @@ -246,6 +355,10 @@ def _common_query_options(f): Order matters in Click: decorators stack bottom-up, so the option that should appear first in --help is applied last. """ + f = click.option( + "--filter-via-state/--no-filter-via-state", default=None, + help="Apply state-overlay filters (default true server-side).", + )(f) f = click.option( "--include-tags", is_flag=True, default=False, help="Include sensor tags on each returned row.", @@ -289,6 +402,13 @@ def _common_query_options(f): return f +def _enrichment_option(f): + return click.option( + "--include-enrichment/--no-include-enrichment", default=None, + help="Attach KEV / EPSS data (default true server-side).", + )(f) + + # --------------------------------------------------------------------------- # Group # --------------------------------------------------------------------------- @@ -298,18 +418,25 @@ def group() -> None: """Query vulnerability data (ext-vulnerability-reporting). Provides CVE, host, and dashboard views over the per-org - vulnerability index, plus an on-demand scan trigger. + vulnerability index, plus an on-demand scan trigger, finding + resolution overlay, and burndown / EPSS time-series. \b Subgroups / commands: - scan Trigger an on-demand os_packages scan - dashboard Per-org vulnerability dashboard graphs - cve list List CVEs observed in the org - cve get Get raw CVE details - cve hosts Hosts affected by a CVE - cve packages Packages affected by a CVE - host list Endpoints with vulnerability counts - host packages Vulnerable packages on a single host + scan Trigger an on-demand os_packages scan + dashboard Per-org vulnerability dashboard graphs + cve list List CVEs observed in the org + cve get Get raw CVE details + cve hosts Hosts affected by a CVE + cve packages Packages affected by a CVE + cve epss-history Per-day EPSS history for one CVE + host list Endpoints with vulnerability counts + host packages Vulnerable packages on a single host + finding resolve Set/clear resolution on a finding + finding bulk-resolve Bulk-apply a resolution + finding list List resolved findings + finding reset Wipe all findings for a sensor + snapshot list Daily open-finding burndown counts """ @@ -357,18 +484,21 @@ def dashboard(ctx, sort_asc) -> None: @group.group("cve") def cve_group() -> None: - """CVE-centric views: list, get, hosts affected, packages affected.""" + """CVE-centric views: list, get, hosts affected, packages affected, EPSS history.""" @cve_group.command("list") @_common_query_options +@_enrichment_option @pass_context def cve_list(ctx, cursor, limit, sort_by, sort_asc, filter_pairs, filters_json, - search_field, search_op, search_value, include_tags) -> None: + search_field, search_op, search_value, + include_tags, filter_via_state, + include_enrichment) -> None: """List CVEs observed across the org. - Valid --sort-by values: cve, count, severity. + Valid --sort-by values: cve, count, severity, lc_risk. \b Examples: @@ -384,14 +514,17 @@ def cve_list(ctx, cursor, limit, sort_by, sort_asc, filters=_parse_filters(filter_pairs, filters_json), search=_build_search(search_field, search_op, search_value), include_tags=include_tags or None, + include_enrichment=include_enrichment, + filter_via_state=filter_via_state, ) _output(ctx, data) @cve_group.command("get") @click.argument("cve_id") +@_enrichment_option @pass_context -def cve_get(ctx, cve_id) -> None: +def cve_get(ctx, cve_id, include_enrichment) -> None: """Return raw details for CVE_ID (e.g. CVE-2021-44228). \b @@ -399,17 +532,23 @@ def cve_get(ctx, cve_id) -> None: limacharlie vulnerability cve get CVE-2021-44228 """ v = _get_vuln(ctx) - data = v.get_cve(cve_id) + data = v.get_cve(cve_id, include_enrichment=include_enrichment) _output(ctx, data) @cve_group.command("hosts") @click.argument("cve_id") @_common_query_options +@click.option( + "--normalized-package-name", default=None, + help="Normalized package name; when set, host-scope resolution rows are read.", +) @pass_context def cve_hosts(ctx, cve_id, cursor, limit, sort_by, sort_asc, filter_pairs, filters_json, - search_field, search_op, search_value, include_tags) -> None: + search_field, search_op, search_value, + include_tags, filter_via_state, + normalized_package_name) -> None: """List hosts affected by CVE_ID. Valid --sort-by values: hostname (default), platform_string. @@ -428,6 +567,8 @@ def cve_hosts(ctx, cve_id, cursor, limit, sort_by, sort_asc, filters=_parse_filters(filter_pairs, filters_json), search=_build_search(search_field, search_op, search_value), include_tags=include_tags or None, + filter_via_state=filter_via_state, + normalized_package_name=normalized_package_name, ) _output(ctx, data) @@ -439,8 +580,10 @@ def cve_hosts(ctx, cve_id, cursor, limit, sort_by, sort_asc, @click.option("--sort-by", default=None, help="Field to sort by: count (default), package_name, package_version.") @click.option("--sort-asc", is_flag=True, default=False, help="Sort ascending.") +@_enrichment_option @pass_context -def cve_packages(ctx, cve_id, cursor, limit, sort_by, sort_asc) -> None: +def cve_packages(ctx, cve_id, cursor, limit, sort_by, sort_asc, + include_enrichment) -> None: """List (package_name, package_version) pairs affected by CVE_ID. \b @@ -454,10 +597,30 @@ def cve_packages(ctx, cve_id, cursor, limit, sort_by, sort_asc) -> None: limit=limit, sort_by=sort_by, sort_asc=sort_asc or None, + include_enrichment=include_enrichment, ) _output(ctx, data) +@cve_group.command("epss-history") +@click.argument("cve_id") +@click.option( + "--days", default=None, type=int, + help="Days back from today (default 90, max 365).", +) +@pass_context +def cve_epss_history(ctx, cve_id, days) -> None: + """Per-day EPSS score / percentile history for CVE_ID. + + \b + Example: + limacharlie vulnerability cve epss-history CVE-2024-1234 --days 30 + """ + v = _get_vuln(ctx) + data = v.query_epss_history(cve_id, days=days) + _output(ctx, data) + + # --------------------------------------------------------------------------- # host subgroup # --------------------------------------------------------------------------- @@ -472,7 +635,8 @@ def host_group() -> None: @pass_context def host_list(ctx, cursor, limit, sort_by, sort_asc, filter_pairs, filters_json, - search_field, search_op, search_value, include_tags) -> None: + search_field, search_op, search_value, + include_tags, filter_via_state) -> None: """List endpoints with their vulnerability counts. Valid --sort-by values: hostname, platform_string, count, severity. @@ -490,6 +654,7 @@ def host_list(ctx, cursor, limit, sort_by, sort_asc, filters=_parse_filters(filter_pairs, filters_json), search=_build_search(search_field, search_op, search_value), include_tags=include_tags or None, + filter_via_state=filter_via_state, ) _output(ctx, data) @@ -497,14 +662,21 @@ def host_list(ctx, cursor, limit, sort_by, sort_asc, @host_group.command("packages") @click.argument("sid") @_common_query_options +@_enrichment_option +@click.option( + "--rollup-subpackages/--no-rollup-subpackages", default=None, + help="Collapse binaries from the same source package family into one row.", +) @pass_context def host_packages(ctx, sid, cursor, limit, sort_by, sort_asc, filter_pairs, filters_json, - search_field, search_op, search_value, include_tags) -> None: + search_field, search_op, search_value, + include_tags, filter_via_state, + include_enrichment, rollup_subpackages) -> None: """List vulnerable packages and their CVEs on host SID. - Valid --sort-by values: cve (default), score, severity, package_name, - package_name_package_version_cve. + Valid --sort-by values: cve (default), score, severity, lc_risk, + package_name, package_name_package_version_cve. \b Example: @@ -520,5 +692,196 @@ def host_packages(ctx, sid, cursor, limit, sort_by, sort_asc, filters=_parse_filters(filter_pairs, filters_json), search=_build_search(search_field, search_op, search_value), include_tags=include_tags or None, + include_enrichment=include_enrichment, + filter_via_state=filter_via_state, + rollup_subpackages=rollup_subpackages, + ) + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# finding subgroup +# --------------------------------------------------------------------------- + +@group.group("finding") +def finding_group() -> None: + """Finding resolution overlay: resolve, bulk-resolve, list, reset.""" + + +@finding_group.command("resolve") +@click.option("--scope", type=_SCOPE_CHOICES, required=True, + help="Resolution scope: org (org-wide) or host (single sid).") +@click.option("--fingerprint", default=None, + help="Fingerprint hex (alternative to --cve + --normalized-package-name).") +@click.option("--cve", default=None, + help="CVE id (when --fingerprint is not supplied).") +@click.option("--normalized-package-name", default=None, + help="Normalized package name (when --fingerprint is not supplied).") +@click.option("--sid", default=None, + help="Sensor id (required when --scope host and --fingerprint not supplied).") +@click.option("--resolution", type=_RESOLUTION_CHOICES, default=None, + help="Resolution to apply. Omit (or pass --reopen) to clear.") +@click.option("--reopen", is_flag=True, default=False, + help="Explicitly reopen the finding (delete the overlay row).") +@click.option("--expires-at", default=None, + help="RFC3339 expiry; only meaningful with --resolution accepted.") +@click.option("--case-number", type=int, default=None, + help="Optional ext-cases linkage.") +@pass_context +def finding_resolve(ctx, scope, fingerprint, cve, normalized_package_name, sid, + resolution, reopen, expires_at, case_number) -> None: + """Set or clear the resolution on a single finding. + + \b + Example: + limacharlie vulnerability finding resolve --scope org \\ + --cve CVE-2024-1234 --normalized-package-name chrome \\ + --resolution accepted --expires-at 2026-08-12T00:00:00Z + """ + if reopen and resolution is not None: + raise click.UsageError("--reopen is incompatible with --resolution") + if fingerprint is None and (cve is None or normalized_package_name is None): + raise click.UsageError( + "must supply --fingerprint OR (--cve + --normalized-package-name)", + ) + if scope == "host" and fingerprint is None and sid is None: + raise click.UsageError("--scope host requires --sid (or --fingerprint)") + v = _get_vuln(ctx) + data = v.set_finding_resolution( + scope=scope, + fingerprint=fingerprint, + cve=cve, + normalized_package_name=normalized_package_name, + sid=sid, + resolution=None if reopen else resolution, + expires_at=expires_at, + case_number=case_number, + ) + _output(ctx, data) + + +@finding_group.command("bulk-resolve") +@click.option("--targets-json", required=True, + help='JSON array of targets. Each: {"scope": "org|host", ' + '"fingerprint": "..."} or {"scope": "...", "cve": "...", ' + '"normalized_package_name": "...", "sid": "..."}.') +@click.option("--resolution", type=_RESOLUTION_CHOICES, default=None, + help="Resolution to apply. Omit (or pass --reopen) to clear all.") +@click.option("--reopen", is_flag=True, default=False, + help="Explicitly reopen every target (delete the overlay rows).") +@click.option("--expires-at", default=None, + help="RFC3339 expiry; only meaningful with --resolution accepted.") +@click.option("--case-number", type=int, default=None, + help="Optional ext-cases linkage.") +@pass_context +def finding_bulk_resolve(ctx, targets_json, resolution, reopen, + expires_at, case_number) -> None: + """Apply one resolution to up to 100 findings. + + \b + Example: + limacharlie vulnerability finding bulk-resolve \\ + --resolution mitigated \\ + --targets-json '[{"scope":"org","fingerprint":"..."}]' + """ + if reopen and resolution is not None: + raise click.UsageError("--reopen is incompatible with --resolution") + try: + targets = json.loads(targets_json) + except json.JSONDecodeError as exc: + raise click.BadParameter(f"invalid JSON: {exc}", param_hint="--targets-json") + if not isinstance(targets, list): + raise click.BadParameter( + "must decode to a JSON array of target objects", + param_hint="--targets-json", + ) + v = _get_vuln(ctx) + data = v.bulk_set_finding_resolution( + targets, + resolution=None if reopen else resolution, + expires_at=expires_at, + case_number=case_number, + ) + _output(ctx, data) + + +@finding_group.command("list") +@click.option("--scope", type=_SCOPE_CHOICES, default=None, + help="Filter by scope.") +@click.option("--resolution", "resolutions", type=_RESOLUTION_CHOICES, + multiple=True, + help="Filter by resolution; repeat for multiple.") +@click.option("--cursor", default=None, help="Pagination cursor.") +@click.option("--limit", type=int, default=None, + help="Page size (default 100, max 1000).") +@pass_context +def finding_list(ctx, scope, resolutions, cursor, limit) -> None: + """Page through the org's resolved findings. + + \b + Example: + limacharlie vulnerability finding list --scope org --resolution accepted + """ + v = _get_vuln(ctx) + data = v.list_finding_resolutions( + scope=scope, + resolutions=list(resolutions) if resolutions else None, + cursor=cursor, + limit=limit, + ) + _output(ctx, data) + + +@finding_group.command("reset") +@click.option("--sid", required=True, help="Sensor ID whose findings to wipe.") +@click.confirmation_option( + prompt="This wipes every stored finding for the sensor. Continue?", +) +@pass_context +def finding_reset(ctx, sid) -> None: + """Wipe every stored finding for one sensor. + + Use after reformat / reimage / decommission. The next scan + repopulates findings from scratch. + + \b + Example: + limacharlie vulnerability finding reset --sid 11111111-... + """ + v = _get_vuln(ctx) + data = v.reset_asset_findings(sid) + _output(ctx, data) + + +# --------------------------------------------------------------------------- +# snapshot subgroup +# --------------------------------------------------------------------------- + +@group.group("snapshot") +def snapshot_group() -> None: + """Daily burndown snapshots of open-finding counts.""" + + +@snapshot_group.command("list") +@click.option( + "--days", default=None, type=int, + help="Days back from today (default 30, max 365).", +) +@click.option( + "--severity", "severities", type=_SEVERITY_CHOICES, multiple=True, + help="Severity filter; repeat for multiple. Defaults to all four.", +) +@pass_context +def snapshot_list(ctx, days, severities) -> None: + """Read daily per-severity open-finding counts. + + \b + Example: + limacharlie vulnerability snapshot list --days 90 --severity critical --severity high + """ + v = _get_vuln(ctx) + data = v.query_daily_snapshots( + days=days, + severities=list(severities) if severities else None, ) _output(ctx, data) diff --git a/limacharlie/sdk/vulnerability.py b/limacharlie/sdk/vulnerability.py index 15296ea8..48886bd1 100644 --- a/limacharlie/sdk/vulnerability.py +++ b/limacharlie/sdk/vulnerability.py @@ -2,8 +2,8 @@ Wraps the ext-vulnerability-reporting extension. The extension keeps a per-org index of OS packages reported by sensors and joins it against -a CVE database, giving per-CVE / per-host vulnerability views and a -dashboard summary. +a CVE database, giving per-CVE / per-host vulnerability views, a +dashboard summary, and per-finding resolution state. The extension exposes a small set of RPC actions over the standard extension request channel; this module is a thin typed shim around @@ -31,6 +31,8 @@ def _build_query( filters: dict[str, list[str]] | None = None, search: dict[str, Any] | None = None, include_tags: bool | None = None, + include_enrichment: bool | None = None, + filter_via_state: bool | None = None, ) -> dict[str, Any]: """Assemble the optional query parameters shared by the list endpoints. @@ -52,6 +54,10 @@ def _build_query( data["search"] = search if include_tags is not None: data["include_tags"] = include_tags + if include_enrichment is not None: + data["include_enrichment"] = include_enrichment + if filter_via_state is not None: + data["filter_via_state"] = filter_via_state return data @@ -79,17 +85,21 @@ def query_cves( filters: dict[str, list[str]] | None = None, search: dict[str, Any] | None = None, include_tags: bool | None = None, + include_enrichment: bool | None = None, + filter_via_state: bool | None = None, ) -> dict[str, Any]: """List CVEs observed across the org. Args: cursor: Pagination cursor returned by a previous call. limit: Maximum number of rows to return (default 100). - sort_by: One of ``cve``, ``count``, ``severity``. + sort_by: One of ``cve``, ``count``, ``severity``, ``lc_risk``. sort_asc: Sort ascending instead of descending. filters: ``{field: [values]}`` filter map (e.g. ``{"severity": ["HIGH","CRITICAL"]}``). - search: ``{"search": field, "op": "is|contains", "value": str}`` substring search. + search: ``{"field": , "op": "is|contains", "value": }`` substring search. include_tags: Include sensor tags on returned rows. + include_enrichment: Attach KEV / EPSS data (default true server-side). + filter_via_state: Apply state-based filters (default true server-side). Returns: ``{"results": [...], "next_cursor": str, "total_return_count": int}``. @@ -106,17 +116,27 @@ def query_cves( filters=filters, search=search, include_tags=include_tags, + include_enrichment=include_enrichment, + filter_via_state=filter_via_state, ), unwrap=True, ) - def get_cve(self, cve_id: str) -> dict[str, Any]: + def get_cve( + self, + cve_id: str, + *, + include_enrichment: bool | None = None, + ) -> dict[str, Any]: """Return raw details for a single CVE id (e.g. ``CVE-2021-44228``).""" + data: dict[str, Any] = {"cve_id": cve_id} + if include_enrichment is not None: + data["include_enrichment"] = include_enrichment ext = Extensions(self._org) return ext.request( _EXTENSION_NAME, "query_cve", - data={"cve_id": cve_id}, + data=data, unwrap=True, ) @@ -131,6 +151,8 @@ def query_cve_hosts( filters: dict[str, list[str]] | None = None, search: dict[str, Any] | None = None, include_tags: bool | None = None, + filter_via_state: bool | None = None, + normalized_package_name: str | None = None, ) -> dict[str, Any]: """List hosts in the org affected by ``cve``. @@ -139,6 +161,9 @@ def query_cve_hosts( cursor, limit, sort_by, sort_asc: pagination + ordering. ``sort_by`` accepts ``hostname`` (default) or ``platform_string``. filters, search, include_tags: see :meth:`query_cves`. + filter_via_state: Apply state-based filters (default true). + normalized_package_name: When set, the resolution overlay reads + host-scope rows; otherwise org-scope only. Returns: ``{"hosts": [...], "cursor": str, "total": int}``. @@ -151,8 +176,11 @@ def query_cve_hosts( filters=filters, search=search, include_tags=include_tags, + filter_via_state=filter_via_state, ) data["cve"] = cve + if normalized_package_name is not None: + data["normalized_package_name"] = normalized_package_name ext = Extensions(self._org) return ext.request(_EXTENSION_NAME, "query_cve_vuln_hosts", data=data, unwrap=True) @@ -164,6 +192,7 @@ def query_cve_packages( limit: int | None = None, sort_by: str | None = None, sort_asc: bool | None = None, + include_enrichment: bool | None = None, ) -> dict[str, Any]: """List ``(package_name, package_version)`` pairs in the org affected by ``cve``. @@ -182,6 +211,8 @@ def query_cve_packages( data["sort_by"] = sort_by if sort_asc is not None: data["sort_asc"] = sort_asc + if include_enrichment is not None: + data["include_enrichment"] = include_enrichment ext = Extensions(self._org) return ext.request(_EXTENSION_NAME, "query_cve_vuln_packages", data=data, unwrap=True) @@ -199,6 +230,7 @@ def query_endpoints( filters: dict[str, list[str]] | None = None, search: dict[str, Any] | None = None, include_tags: bool | None = None, + filter_via_state: bool | None = None, ) -> dict[str, Any]: """List endpoints with vulnerability counts. @@ -220,6 +252,7 @@ def query_endpoints( filters=filters, search=search, include_tags=include_tags, + filter_via_state=filter_via_state, ), unwrap=True, ) @@ -235,10 +268,13 @@ def query_host_packages( filters: dict[str, list[str]] | None = None, search: dict[str, Any] | None = None, include_tags: bool | None = None, + include_enrichment: bool | None = None, + filter_via_state: bool | None = None, + rollup_subpackages: bool | None = None, ) -> dict[str, Any]: """List vulnerable packages and their CVEs on a single host. - ``sort_by`` accepts ``cve``, ``score``, ``severity``, + ``sort_by`` accepts ``cve``, ``score``, ``severity``, ``lc_risk``, ``package_name``, or ``package_name_package_version_cve``. Returns: @@ -252,8 +288,12 @@ def query_host_packages( filters=filters, search=search, include_tags=include_tags, + include_enrichment=include_enrichment, + filter_via_state=filter_via_state, ) data["sid"] = sid + if rollup_subpackages is not None: + data["rollup_subpackages"] = rollup_subpackages ext = Extensions(self._org) return ext.request(_EXTENSION_NAME, "query_host_vuln_packages", data=data, unwrap=True) @@ -286,3 +326,185 @@ def scan(self, sid: str) -> dict[str, Any]: return ext.request( _EXTENSION_NAME, "scan_packages", data={"sid": sid}, unwrap=True, ) + + # ------------------------------------------------------------------ + # Finding resolution overlay + # ------------------------------------------------------------------ + + def set_finding_resolution( + self, + *, + scope: str, + fingerprint: str | None = None, + cve: str | None = None, + normalized_package_name: str | None = None, + sid: str | None = None, + resolution: str | None = None, + expires_at: str | None = None, + case_number: int | None = None, + ) -> dict[str, Any]: + """Set or clear the resolution on a single finding. + + Either ``fingerprint`` OR (``cve`` + ``normalized_package_name`` + [+ ``sid`` for ``scope=host``]) must be supplied. + + ``resolution`` is one of ``mitigated``, ``accepted``, + ``false_positive``. Passing ``None`` reopens the finding (deletes + the overlay row). + + ``expires_at`` is RFC3339 and only meaningful when + ``resolution=accepted``. + """ + data: dict[str, Any] = {"scope": scope} + if fingerprint is not None: + data["fingerprint"] = fingerprint + if cve is not None: + data["cve"] = cve + if normalized_package_name is not None: + data["normalized_package_name"] = normalized_package_name + if sid is not None: + data["sid"] = sid + if resolution is not None: + data["resolution"] = resolution + if expires_at is not None: + data["expires_at"] = expires_at + if case_number is not None: + data["case_number"] = case_number + ext = Extensions(self._org) + return ext.request( + _EXTENSION_NAME, "set_finding_resolution", data=data, unwrap=True, + ) + + def bulk_set_finding_resolution( + self, + targets: list[dict[str, Any]], + *, + resolution: str | None = None, + expires_at: str | None = None, + case_number: int | None = None, + ) -> dict[str, Any]: + """Apply one resolution to up to 100 findings. + + Args: + targets: List of finding identifiers. Each entry needs + ``scope`` + (``fingerprint`` OR + ``cve`` + ``normalized_package_name`` [+ ``sid`` for host]). + resolution: ``mitigated`` / ``accepted`` / ``false_positive``. + ``None`` reopens every target. + expires_at: RFC3339 expiry (only meaningful when accepted). + case_number: Optional ext-cases linkage. + + Returns: + ``{"applied": int, "errors": [...], "results": [...]}``. + """ + data: dict[str, Any] = {"targets": list(targets)} + if resolution is not None: + data["resolution"] = resolution + if expires_at is not None: + data["expires_at"] = expires_at + if case_number is not None: + data["case_number"] = case_number + ext = Extensions(self._org) + return ext.request( + _EXTENSION_NAME, "bulk_set_finding_resolution", data=data, unwrap=True, + ) + + def list_finding_resolutions( + self, + *, + scope: str | None = None, + resolutions: list[str] | None = None, + cursor: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + """Page through the org's resolved findings. + + Filters stack as AND. ``resolutions``, when set, must be a subset + of ``{mitigated, accepted, false_positive}``. + + Returns: + ``{"resolutions": [...], "next_cursor": str}``. + """ + data: dict[str, Any] = {} + if scope is not None: + data["scope"] = scope + if resolutions is not None: + data["resolutions"] = resolutions + if cursor is not None: + data["cursor"] = cursor + if limit is not None: + data["limit"] = limit + ext = Extensions(self._org) + return ext.request( + _EXTENSION_NAME, "list_finding_resolutions", data=data, unwrap=True, + ) + + def reset_asset_findings(self, sid: str) -> dict[str, Any]: + """Wipe every stored finding for one sensor. + + Use when the host was reformatted/reimaged or decommissioned and + the existing findings no longer reflect reality. Org-scope + fingerprints that were only on this sensor fire + ``vuln_finding.closed`` events. The next package scan repopulates + findings from scratch. + + Returns: + ``{"sid": str, "closed": int}``. + """ + ext = Extensions(self._org) + return ext.request( + _EXTENSION_NAME, "reset_asset_findings", data={"sid": sid}, unwrap=True, + ) + + # ------------------------------------------------------------------ + # Daily snapshots / EPSS history + # ------------------------------------------------------------------ + + def query_daily_snapshots( + self, + *, + days: int | None = None, + severities: list[str] | None = None, + ) -> dict[str, Any]: + """Read the per-day per-severity open-finding burndown counts. + + Args: + days: Days back from today (default 30 server-side, max 365). + severities: Severity filter (defaults to all four buckets). + + Returns: + ``{"snapshots": [...]}`` ordered (snapshot_date ASC, severity ASC). + """ + data: dict[str, Any] = {} + if days is not None: + data["days"] = days + if severities is not None: + data["severities"] = severities + ext = Extensions(self._org) + return ext.request( + _EXTENSION_NAME, "query_daily_snapshots", data=data, unwrap=True, + ) + + def query_epss_history( + self, + cve: str, + *, + days: int | None = None, + ) -> dict[str, Any]: + """Read the per-day EPSS history for one CVE. + + Args: + cve: CVE id (e.g. ``CVE-2024-1234``). + days: Days back (default 90 server-side, max 365). + + Returns: + ``{"history": [{"snapshot_date": ..., "score": ..., "percentile": ...}, ...]}`` + ordered snapshot_date ASC. + """ + data: dict[str, Any] = {"cve": cve} + if days is not None: + data["days"] = days + ext = Extensions(self._org) + return ext.request( + _EXTENSION_NAME, "query_epss_history", data=data, unwrap=True, + ) diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 63ad63ba..2154225e 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -211,7 +211,9 @@ }), "user": frozenset({"invite", "list", "permissions", "remove"}), "usp": frozenset({"validate"}), - "vulnerability": frozenset({"cve", "dashboard", "host", "scan"}), + "vulnerability": frozenset({ + "cve", "dashboard", "finding", "host", "scan", "snapshot", + }), "yara": frozenset({ "rule-add", "rule-delete", "rules-list", "scan", "source-add", "source-delete", "source-get", "sources-list", diff --git a/tests/unit/test_cli_vulnerability.py b/tests/unit/test_cli_vulnerability.py index 3a16340e..cefd279b 100644 --- a/tests/unit/test_cli_vulnerability.py +++ b/tests/unit/test_cli_vulnerability.py @@ -1,5 +1,7 @@ """Tests for limacharlie vulnerability CLI commands.""" +import json + from unittest.mock import patch, MagicMock from click.testing import CliRunner @@ -15,7 +17,18 @@ def _patches(): ) -def _invoke(args, mock_vuln_cls, return_value=None): +_SDK_METHODS = [ + "scan", "query_dashboard", + "query_cves", "get_cve", + "query_cve_hosts", "query_cve_packages", + "query_endpoints", "query_host_packages", + "set_finding_resolution", "bulk_set_finding_resolution", + "list_finding_resolutions", "reset_asset_findings", + "query_daily_snapshots", "query_epss_history", +] + + +def _invoke(args, mock_vuln_cls, return_value=None, stdin=None): """Run the CLI with a mocked Vulnerability instance. All SDK methods on the mocked instance return ``return_value`` so the @@ -25,15 +38,10 @@ def _invoke(args, mock_vuln_cls, return_value=None): inst = MagicMock() mock_vuln_cls.return_value = inst if return_value is not None: - for name in [ - "scan", "query_dashboard", - "query_cves", "get_cve", - "query_cve_hosts", "query_cve_packages", - "query_endpoints", "query_host_packages", - ]: + for name in _SDK_METHODS: getattr(inst, name).return_value = return_value runner = CliRunner() - result = runner.invoke(cli, ["--output", "json"] + args) + result = runner.invoke(cli, ["--output", "json"] + args, input=stdin) return result, inst @@ -47,14 +55,14 @@ def test_root_help_lists_subcommands(self): runner = CliRunner() result = runner.invoke(cli, ["vulnerability", "--help"]) assert result.exit_code == 0 - for cmd in ["scan", "dashboard", "cve", "host"]: + for cmd in ["scan", "dashboard", "cve", "host", "finding", "snapshot"]: assert cmd in result.output def test_cve_subgroup_help(self): runner = CliRunner() result = runner.invoke(cli, ["vulnerability", "cve", "--help"]) assert result.exit_code == 0 - for cmd in ["list", "get", "hosts", "packages"]: + for cmd in ["list", "get", "hosts", "packages", "epss-history"]: assert cmd in result.output def test_host_subgroup_help(self): @@ -64,6 +72,19 @@ def test_host_subgroup_help(self): for cmd in ["list", "packages"]: assert cmd in result.output + def test_finding_subgroup_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["vulnerability", "finding", "--help"]) + assert result.exit_code == 0 + for cmd in ["resolve", "bulk-resolve", "list", "reset"]: + assert cmd in result.output + + def test_snapshot_subgroup_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["vulnerability", "snapshot", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + # --------------------------------------------------------------------------- # scan @@ -142,6 +163,8 @@ def test_default(self): filters=None, search=None, include_tags=None, + include_enrichment=None, + filter_via_state=None, ) def test_filter_repeats_become_list(self): @@ -169,6 +192,8 @@ def test_filter_repeats_become_list(self): filters={"severity": ["HIGH", "CRITICAL"], "platform": ["1"]}, search=None, include_tags=None, + include_enrichment=None, + filter_via_state=None, ) def test_filters_json_merges_with_filter_pairs(self): @@ -211,6 +236,7 @@ def test_search_requires_all_three(self): assert "must be used together" in result.output def test_search_built_correctly(self): + """Search dict uses 'field' key (matches parseSearchOp in extension).""" p1, p2, p3 = _patches() with p1, p2, p3 as cls: result, inst = _invoke( @@ -226,12 +252,34 @@ def test_search_built_correctly(self): assert result.exit_code == 0, result.output kwargs = inst.query_cves.call_args[1] assert kwargs["search"] == { - "search": "cve", "op": "contains", "value": "CVE-2025", + "field": "cve", "op": "contains", "value": "CVE-2025", } + def test_include_enrichment_toggles(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + ["vulnerability", "cve", "list", "--no-include-enrichment"], + cls, + return_value={"results": []}, + ) + assert result.exit_code == 0, result.output + assert inst.query_cves.call_args[1]["include_enrichment"] is False + + def test_filter_via_state_toggles(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + ["vulnerability", "cve", "list", "--no-filter-via-state"], + cls, + return_value={"results": []}, + ) + assert result.exit_code == 0, result.output + assert inst.query_cves.call_args[1]["filter_via_state"] is False + # --------------------------------------------------------------------------- -# cve get / hosts / packages +# cve get / hosts / packages / epss-history # --------------------------------------------------------------------------- @@ -245,7 +293,25 @@ def test_get(self): return_value={"cve": {}}, ) assert result.exit_code == 0, result.output - inst.get_cve.assert_called_once_with("CVE-2021-44228") + inst.get_cve.assert_called_once_with( + "CVE-2021-44228", include_enrichment=None, + ) + + def test_get_no_enrichment(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + [ + "vulnerability", "cve", "get", "CVE-2021-44228", + "--no-include-enrichment", + ], + cls, + return_value={"cve": {}}, + ) + assert result.exit_code == 0, result.output + inst.get_cve.assert_called_once_with( + "CVE-2021-44228", include_enrichment=False, + ) def test_hosts(self): p1, p2, p3 = _patches() @@ -254,6 +320,7 @@ def test_hosts(self): [ "vulnerability", "cve", "hosts", "CVE-2021-44228", "--include-tags", + "--normalized-package-name", "openssl", ], cls, return_value={"hosts": []}, @@ -268,6 +335,8 @@ def test_hosts(self): filters=None, search=None, include_tags=True, + filter_via_state=None, + normalized_package_name="openssl", ) def test_packages(self): @@ -288,6 +357,23 @@ def test_packages(self): limit=None, sort_by="package_name", sort_asc=True, + include_enrichment=None, + ) + + def test_epss_history(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + [ + "vulnerability", "cve", "epss-history", "CVE-2024-1234", + "--days", "30", + ], + cls, + return_value={"history": []}, + ) + assert result.exit_code == 0, result.output + inst.query_epss_history.assert_called_once_with( + "CVE-2024-1234", days=30, ) @@ -318,6 +404,7 @@ def test_host_list(self): filters={"platform": ["1"]}, search=None, include_tags=None, + filter_via_state=None, ) def test_host_packages(self): @@ -327,6 +414,7 @@ def test_host_packages(self): [ "vulnerability", "host", "packages", "sid-1", "--sort-by", "score", + "--rollup-subpackages", ], cls, return_value={"packages": []}, @@ -341,4 +429,233 @@ def test_host_packages(self): filters=None, search=None, include_tags=None, + include_enrichment=None, + filter_via_state=None, + rollup_subpackages=True, + ) + + +# --------------------------------------------------------------------------- +# finding +# --------------------------------------------------------------------------- + + +class TestVulnerabilityFinding: + def test_resolve_by_cve_and_package(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + [ + "vulnerability", "finding", "resolve", + "--scope", "org", + "--cve", "CVE-2024-1234", + "--normalized-package-name", "chrome", + "--resolution", "accepted", + "--expires-at", "2026-08-12T00:00:00Z", + ], + cls, + return_value={"scope": "org", "fingerprint": "abc"}, + ) + assert result.exit_code == 0, result.output + inst.set_finding_resolution.assert_called_once_with( + scope="org", + fingerprint=None, + cve="CVE-2024-1234", + normalized_package_name="chrome", + sid=None, + resolution="accepted", + expires_at="2026-08-12T00:00:00Z", + case_number=None, + ) + + def test_resolve_reopen_clears_resolution(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + [ + "vulnerability", "finding", "resolve", + "--scope", "org", + "--fingerprint", "deadbeef", + "--reopen", + ], + cls, + return_value={"scope": "org", "fingerprint": "deadbeef"}, + ) + assert result.exit_code == 0, result.output + inst.set_finding_resolution.assert_called_once_with( + scope="org", + fingerprint="deadbeef", + cve=None, + normalized_package_name=None, + sid=None, + resolution=None, + expires_at=None, + case_number=None, + ) + + def test_resolve_rejects_reopen_with_resolution(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + cls.return_value.set_finding_resolution.return_value = {} + runner = CliRunner() + result = runner.invoke(cli, [ + "vulnerability", "finding", "resolve", + "--scope", "org", + "--fingerprint", "x", + "--resolution", "mitigated", + "--reopen", + ]) + assert result.exit_code != 0 + assert "incompatible" in result.output + + def test_resolve_requires_fingerprint_or_pair(self): + runner = CliRunner() + result = runner.invoke(cli, [ + "vulnerability", "finding", "resolve", "--scope", "org", + "--resolution", "mitigated", + ]) + assert result.exit_code != 0 + assert "fingerprint" in result.output.lower() + + def test_resolve_host_requires_sid(self): + runner = CliRunner() + result = runner.invoke(cli, [ + "vulnerability", "finding", "resolve", "--scope", "host", + "--cve", "CVE-2024-1", "--normalized-package-name", "openssl", + "--resolution", "mitigated", + ]) + assert result.exit_code != 0 + assert "--sid" in result.output + + def test_bulk_resolve(self): + p1, p2, p3 = _patches() + targets = [ + {"scope": "org", "fingerprint": "aaaa"}, + {"scope": "host", "cve": "CVE-2024-1", + "normalized_package_name": "openssl", "sid": "sid-2"}, + ] + with p1, p2, p3 as cls: + result, inst = _invoke( + [ + "vulnerability", "finding", "bulk-resolve", + "--resolution", "mitigated", + "--targets-json", json.dumps(targets), + ], + cls, + return_value={"applied": 2}, + ) + assert result.exit_code == 0, result.output + inst.bulk_set_finding_resolution.assert_called_once_with( + targets, + resolution="mitigated", + expires_at=None, + case_number=None, + ) + + def test_bulk_resolve_rejects_non_array(self): + runner = CliRunner() + result = runner.invoke(cli, [ + "vulnerability", "finding", "bulk-resolve", + "--targets-json", '{"scope":"org"}', + "--resolution", "mitigated", + ]) + assert result.exit_code != 0 + assert "array" in result.output.lower() + + def test_list_no_filters(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + ["vulnerability", "finding", "list"], + cls, + return_value={"resolutions": []}, + ) + assert result.exit_code == 0, result.output + inst.list_finding_resolutions.assert_called_once_with( + scope=None, resolutions=None, cursor=None, limit=None, + ) + + def test_list_multi_resolution_filter(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + [ + "vulnerability", "finding", "list", + "--scope", "org", + "--resolution", "accepted", + "--resolution", "mitigated", + "--limit", "50", + ], + cls, + return_value={"resolutions": []}, + ) + assert result.exit_code == 0, result.output + inst.list_finding_resolutions.assert_called_once_with( + scope="org", + resolutions=["accepted", "mitigated"], + cursor=None, + limit=50, + ) + + def test_reset_requires_confirmation(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + # Provide --yes-equivalent via stdin "y" + result, inst = _invoke( + ["vulnerability", "finding", "reset", "--sid", "sid-1"], + cls, + return_value={"sid": "sid-1", "closed": 0}, + stdin="y\n", + ) + assert result.exit_code == 0, result.output + inst.reset_asset_findings.assert_called_once_with("sid-1") + + def test_reset_aborted_when_declined(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + ["vulnerability", "finding", "reset", "--sid", "sid-1"], + cls, + return_value={"sid": "sid-1", "closed": 0}, + stdin="n\n", + ) + assert result.exit_code != 0 + inst.reset_asset_findings.assert_not_called() + + +# --------------------------------------------------------------------------- +# snapshot +# --------------------------------------------------------------------------- + + +class TestVulnerabilitySnapshot: + def test_list_defaults(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + ["vulnerability", "snapshot", "list"], + cls, + return_value={"snapshots": []}, + ) + assert result.exit_code == 0, result.output + inst.query_daily_snapshots.assert_called_once_with( + days=None, severities=None, + ) + + def test_list_with_filters(self): + p1, p2, p3 = _patches() + with p1, p2, p3 as cls: + result, inst = _invoke( + [ + "vulnerability", "snapshot", "list", + "--days", "90", + "--severity", "critical", + "--severity", "high", + ], + cls, + return_value={"snapshots": []}, + ) + assert result.exit_code == 0, result.output + inst.query_daily_snapshots.assert_called_once_with( + days=90, severities=["critical", "high"], ) diff --git a/tests/unit/test_sdk_vulnerability.py b/tests/unit/test_sdk_vulnerability.py index 9d9850e8..c110130e 100644 --- a/tests/unit/test_sdk_vulnerability.py +++ b/tests/unit/test_sdk_vulnerability.py @@ -55,8 +55,10 @@ def test_passes_all_options(self, vuln, mock_org): sort_by="severity", sort_asc=True, filters={"severity": ["HIGH", "CRITICAL"]}, - search={"search": "cve", "op": "contains", "value": "CVE-2025"}, + search={"field": "cve", "op": "contains", "value": "CVE-2025"}, include_tags=True, + include_enrichment=False, + filter_via_state=False, ) action, data = _decode(mock_org.client.request.call_args) assert action == "query_cves" @@ -66,8 +68,10 @@ def test_passes_all_options(self, vuln, mock_org): "sort_by": "severity", "sort_asc": True, "filters": {"severity": ["HIGH", "CRITICAL"]}, - "search": {"search": "cve", "op": "contains", "value": "CVE-2025"}, + "search": {"field": "cve", "op": "contains", "value": "CVE-2025"}, "include_tags": True, + "include_enrichment": False, + "filter_via_state": False, } @@ -79,6 +83,13 @@ def test_sends_cve_id(self, vuln, mock_org): assert action == "query_cve" assert data == {"cve_id": "CVE-2021-44228"} + def test_sends_include_enrichment(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.get_cve("CVE-2021-44228", include_enrichment=False) + action, data = _decode(mock_org.client.request.call_args) + assert action == "query_cve" + assert data == {"cve_id": "CVE-2021-44228", "include_enrichment": False} + class TestQueryCveHosts: def test_required_cve_and_options(self, vuln, mock_org): @@ -88,6 +99,8 @@ def test_required_cve_and_options(self, vuln, mock_org): limit=10, sort_by="hostname", include_tags=True, + normalized_package_name="openssl", + filter_via_state=False, ) action, data = _decode(mock_org.client.request.call_args) assert action == "query_cve_vuln_hosts" @@ -95,6 +108,8 @@ def test_required_cve_and_options(self, vuln, mock_org): assert data["limit"] == 10 assert data["sort_by"] == "hostname" assert data["include_tags"] is True + assert data["normalized_package_name"] == "openssl" + assert data["filter_via_state"] is False class TestQueryCvePackages: @@ -106,6 +121,7 @@ def test_required_cve_and_pagination(self, vuln, mock_org): limit=25, sort_by="count", sort_asc=False, + include_enrichment=False, ) action, data = _decode(mock_org.client.request.call_args) assert action == "query_cve_vuln_packages" @@ -115,6 +131,7 @@ def test_required_cve_and_pagination(self, vuln, mock_org): "limit": 25, "sort_by": "count", "sort_asc": False, + "include_enrichment": False, } @@ -136,6 +153,20 @@ def test_required_sid(self, vuln, mock_org): assert data["sid"] == "sid-1" assert data["sort_by"] == "score" + def test_passes_rollup_and_enrichment(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.query_host_packages( + "sid-1", + rollup_subpackages=True, + include_enrichment=False, + filter_via_state=False, + ) + action, data = _decode(mock_org.client.request.call_args) + assert action == "query_host_vuln_packages" + assert data["rollup_subpackages"] is True + assert data["include_enrichment"] is False + assert data["filter_via_state"] is False + class TestQueryDashboard: def test_default_empty(self, vuln, mock_org): @@ -159,3 +190,104 @@ def test_sends_sid(self, vuln, mock_org): action, data = _decode(mock_org.client.request.call_args) assert action == "scan_packages" assert data == {"sid": "sid-42"} + + +class TestFindingResolution: + def test_set_by_cve_and_package(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.set_finding_resolution( + scope="org", + cve="CVE-2024-1", + normalized_package_name="openssl", + resolution="accepted", + expires_at="2026-12-31T23:59:59Z", + case_number=42, + ) + action, data = _decode(mock_org.client.request.call_args) + assert action == "set_finding_resolution" + assert data == { + "scope": "org", + "cve": "CVE-2024-1", + "normalized_package_name": "openssl", + "resolution": "accepted", + "expires_at": "2026-12-31T23:59:59Z", + "case_number": 42, + } + + def test_set_reopen_omits_resolution(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.set_finding_resolution(scope="org", fingerprint="deadbeef") + action, data = _decode(mock_org.client.request.call_args) + assert action == "set_finding_resolution" + assert data == {"scope": "org", "fingerprint": "deadbeef"} + assert "resolution" not in data + + def test_bulk_set(self, vuln, mock_org): + mock_org.client.request.return_value = {} + targets = [ + {"scope": "org", "fingerprint": "aaaa"}, + {"scope": "host", "cve": "CVE-2024-1", + "normalized_package_name": "openssl", "sid": "sid-2"}, + ] + vuln.bulk_set_finding_resolution(targets, resolution="mitigated") + action, data = _decode(mock_org.client.request.call_args) + assert action == "bulk_set_finding_resolution" + assert data == {"targets": targets, "resolution": "mitigated"} + + def test_list(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.list_finding_resolutions( + scope="org", resolutions=["accepted"], cursor="c", limit=10, + ) + action, data = _decode(mock_org.client.request.call_args) + assert action == "list_finding_resolutions" + assert data == { + "scope": "org", + "resolutions": ["accepted"], + "cursor": "c", + "limit": 10, + } + + def test_list_defaults(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.list_finding_resolutions() + action, data = _decode(mock_org.client.request.call_args) + assert action == "list_finding_resolutions" + assert data == {} + + def test_reset_asset_findings(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.reset_asset_findings("sid-9") + action, data = _decode(mock_org.client.request.call_args) + assert action == "reset_asset_findings" + assert data == {"sid": "sid-9"} + + +class TestDailySnapshots: + def test_defaults(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.query_daily_snapshots() + action, data = _decode(mock_org.client.request.call_args) + assert action == "query_daily_snapshots" + assert data == {} + + def test_with_filters(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.query_daily_snapshots(days=90, severities=["critical", "high"]) + action, data = _decode(mock_org.client.request.call_args) + assert data == {"days": 90, "severities": ["critical", "high"]} + + +class TestEpssHistory: + def test_defaults(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.query_epss_history("CVE-2024-1") + action, data = _decode(mock_org.client.request.call_args) + assert action == "query_epss_history" + assert data == {"cve": "CVE-2024-1"} + + def test_with_days(self, vuln, mock_org): + mock_org.client.request.return_value = {} + vuln.query_epss_history("CVE-2024-1", days=30) + action, data = _decode(mock_org.client.request.call_args) + assert data == {"cve": "CVE-2024-1", "days": 30} From 781c79ddb009aab6ec662445087065dc5b31cb71 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sun, 17 May 2026 12:06:03 -0700 Subject: [PATCH 04/14] ai: forward X-LC-UID on org-scoped AI session calls (#294) User API Keys are scoped to a user, not an org, so jwt.limacharlie.io can only resolve them when the UID accompanies the secret. The AI SDK sent X-LC-OID + Authorization but never X-LC-UID, so a User API Key could not authenticate ai start-session (or any ai session command) and the server rejected it as an invalid org key. ai-sessions' OrgDualAuthMiddleware already reads X-LC-UID for the raw-API-key path (and ignores it for JWT auth), so no server change is needed -- the SDK just has to send it. Add AI._org_auth_headers() which always sets X-LC-OID and adds X-LC-UID when the client has a uid, and route both start_session() and _org_request() through it so the entire org-scoped AI surface (start-session, session list/get/terminate/history, usage) works under a User API Key. Tests: fix the mock_org fixture to set _uid = None (otherwise the MagicMock auto-vivifies a truthy _uid and leaks X-LC-UID into every request) and add TestOrgRequestUidHeader covering both the org-scoped (no header) and user-scoped (header forwarded) cases for start_session and the _org_request path. Co-authored-by: Claude Opus 4.7 (1M context) --- limacharlie/sdk/ai.py | 19 ++++++++++-- tests/unit/test_sdk_ai_sessions.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/limacharlie/sdk/ai.py b/limacharlie/sdk/ai.py index ef77d154..bb795561 100644 --- a/limacharlie/sdk/ai.py +++ b/limacharlie/sdk/ai.py @@ -40,6 +40,21 @@ def _resolve_map_secrets(self, m: dict[str, str] | None) -> dict[str, str] | Non return m return {k: self._resolve_secret(v) for k, v in m.items()} + def _org_auth_headers(self) -> dict[str, str]: + """Identity headers for the org-scoped ai-sessions endpoints. + + Always carries ``X-LC-OID``. A User API Key is scoped to a + user rather than an org, so jwt.limacharlie.io can only resolve + it when the UID accompanies the secret -- forward it via + ``X-LC-UID``. ai-sessions' OrgDualAuthMiddleware reads + ``X-LC-UID`` only for the raw-API-key path and ignores it for + JWT auth, so sending it whenever a uid is known is always safe. + """ + headers = {"X-LC-OID": self.client._oid} + if self.client._uid: + headers["X-LC-UID"] = self.client._uid + return headers + # Fields copied verbatim from an ai_agent hive record into the # request's ``profile`` section. Every entry in this tuple maps # one-to-one to a field on the server's ``ProfileContent`` type @@ -210,7 +225,7 @@ def start_session(self, definition_name: str, prompt: str | None = None, if profile: request_body["profile"] = profile - extra = {"X-LC-OID": self.client._oid} + extra = self._org_auth_headers() # Use the raw API key when available (works with current and future # ai-sessions deployments). Fall back to JWT auth for OAuth users @@ -331,7 +346,7 @@ def _org_request(self, verb: str, path: str, OrgDualAuthMiddleware. We send the raw API key when available (same pattern as start_session) for maximum compatibility. """ - extra: dict[str, str] = {"X-LC-OID": self.client._oid} + extra: dict[str, str] = self._org_auth_headers() if self.client._api_key is not None: extra["Authorization"] = f"Bearer {self.client._api_key}" return self.client.request( diff --git a/tests/unit/test_sdk_ai_sessions.py b/tests/unit/test_sdk_ai_sessions.py index 2acc4e68..f4cbee9e 100644 --- a/tests/unit/test_sdk_ai_sessions.py +++ b/tests/unit/test_sdk_ai_sessions.py @@ -19,6 +19,10 @@ def mock_org(): org.client = MagicMock() org.client._api_key = "test-api-key" org.client._oid = "test-oid" + # Org-scoped credentials by default: no user id. (Without this the + # MagicMock would auto-vivify a truthy _uid and leak an X-LC-UID + # header into every request.) + org.client._uid = None return org @@ -85,6 +89,52 @@ def test_sends_correct_request(self, ai, mock_org): assert result == {"session_id": "sess-123", "status": "pending"} +class TestOrgRequestUidHeader: + """X-LC-UID is forwarded for User API Keys so jwt.limacharlie.io + can resolve a user-scoped key against the requested org.""" + + def _start(self, ai, mock_org): + with patch("limacharlie.sdk.hive.Hive") as MockHive: + hive_instance = MagicMock() + MockHive.return_value = hive_instance + hive_instance.get.return_value = _make_hive_record( + {"prompt": "p", "anthropic_secret": "sk", "lc_api_key_secret": "k"} + ) + mock_org.client.request.return_value = {"session_id": "s1"} + ai.start_session("agent") + return mock_org.client.request.call_args[1]["extra_headers"] + + def test_org_scoped_key_omits_uid_header(self, ai, mock_org): + # mock_org defaults to _uid = None (org-scoped). + headers = self._start(ai, mock_org) + assert headers["X-LC-OID"] == "test-oid" + assert "X-LC-UID" not in headers + + def test_user_scoped_key_sends_uid_header(self, ai, mock_org): + mock_org.client._uid = "user-123" + headers = self._start(ai, mock_org) + assert headers["X-LC-OID"] == "test-oid" + assert headers["X-LC-UID"] == "user-123" + assert headers["Authorization"] == "Bearer test-api-key" + + def test_org_request_path_forwards_uid(self, ai, mock_org): + # The same identity headers must reach the org session + # management endpoints (list/get/terminate), not just + # start-session. + mock_org.client._uid = "user-123" + mock_org.client.request.return_value = {"session": {}} + ai.get_session("sess-1") + headers = mock_org.client.request.call_args[1]["extra_headers"] + assert headers["X-LC-OID"] == "test-oid" + assert headers["X-LC-UID"] == "user-123" + + def test_org_request_omits_uid_when_org_scoped(self, ai, mock_org): + mock_org.client.request.return_value = {"session": {}} + ai.get_session("sess-1") + headers = mock_org.client.request.call_args[1]["extra_headers"] + assert "X-LC-UID" not in headers + + class TestStartSessionSecretResolution: """Credentials that use hive://secret/ references get resolved.""" From 88db21bd9a41d585870cba3f19a31ac4029ebbcf Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sun, 17 May 2026 20:33:08 -0700 Subject: [PATCH 05/14] schema: add 'schema reset' command to rebuild org schemas (#295) Exposes the existing backend endpoint DELETE /orgs/{oid}/schema (resetOrgSchemas) through the Python CLI/SDK, mirroring the Go SDK's Organization.ResetSchemas(). - SDK: Organization.reset_schemas() -> DELETE orgs/{oid}/schema - CLI: 'limacharlie schema reset', guarded by --confirm (exit 4 without it), with register_explain text for --ai-help - Tests: SDK request assertion, CLI tests for the --confirm guard and the happy path, plus a 'schema list' regression guard; updated the lazy-loading regression's expected subcommand set Co-authored-by: Claude Opus 4.7 (1M context) --- limacharlie/commands/schema.py | 43 +++++++++++++++++++ limacharlie/sdk/organization.py | 12 ++++++ tests/unit/test_cli_commands.py | 38 ++++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 2 +- tests/unit/test_sdk_organization.py | 4 ++ 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/limacharlie/commands/schema.py b/limacharlie/commands/schema.py index 7c8f9a0a..ddce018c 100644 --- a/limacharlie/commands/schema.py +++ b/limacharlie/commands/schema.py @@ -103,3 +103,46 @@ def get(ctx, name) -> None: org = _get_org(ctx) data = org.get_schema(name) _output(ctx, data) + + +# --------------------------------------------------------------------------- +# reset +# --------------------------------------------------------------------------- + +_EXPLAIN_RESET = """\ +Reset (rebuild) all event schemas for the organization. This clears +the cached schema/ontology so it is rebuilt from newly observed +events. + +This is useful when the recorded schema has gone stale -- for example +after telemetry shape changes -- and event fields are missing from +'schema list' or 'schema get'. The schema repopulates as new events +flow in; there may be a short window where schemas are incomplete. + +This is a destructive, organization-wide operation and requires the +--confirm flag. + +Examples: + limacharlie schema reset --confirm +""" +register_explain("schema.reset", _EXPLAIN_RESET) + + +@group.command() +@click.option("--confirm", is_flag=True, default=False, help="Confirm the reset (required).") +@pass_context +def reset(ctx, confirm) -> None: + """Reset (rebuild) all event schemas for the organization.""" + if not confirm: + click.echo( + "Error: Destructive operation requires --confirm flag.\n" + "Suggestion: Re-run with --confirm to reset all org schemas.", + err=True, + ) + ctx.exit(4) + return + org = _get_org(ctx) + data = org.reset_schemas() + if not ctx.obj.quiet: + click.echo("Org schemas reset; they will rebuild as new events are observed.") + _output(ctx, data) diff --git a/limacharlie/sdk/organization.py b/limacharlie/sdk/organization.py index fa88270c..bda7ff92 100644 --- a/limacharlie/sdk/organization.py +++ b/limacharlie/sdk/organization.py @@ -146,6 +146,18 @@ def get_schema(self, name: str) -> dict[str, Any]: """ return self._client.request("GET", f"orgs/{self.oid}/schema/{urlescape(name, safe='')}") + def reset_schemas(self) -> dict[str, Any]: + """Reset (rebuild) all event schemas for the organization. + + Clears the cached schema/ontology so it is rebuilt from newly + observed events. This is useful after telemetry shape changes + when the recorded schema has gone stale. + + Returns: + dict: API response (typically ``{"success": true}``). + """ + return self._client.request("DELETE", f"orgs/{self.oid}/schema") + def get_runtime_metadata(self, entity_type: str | None = None, entity_name: str | None = None) -> dict[str, Any]: """Get runtime metadata. diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index 96adea2b..cdb34b2a 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -509,3 +509,41 @@ def test_shortcut_disable(self, mock_hive_cls, mock_org_cls, mock_client_cls): assert record.tags == ["keep-me"] +class TestSchemaCommands: + @patch("limacharlie.commands.schema.Client") + @patch("limacharlie.commands.schema.Organization") + def test_schema_list(self, mock_org_cls, mock_client_cls): + mock_org = MagicMock() + mock_org.get_schemas.return_value = {"event_types": ["NEW_PROCESS"]} + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "schema", "list"]) + assert result.exit_code == 0 + mock_org.get_schemas.assert_called_once_with() + + @patch("limacharlie.commands.schema.Client") + @patch("limacharlie.commands.schema.Organization") + def test_schema_reset_without_confirm(self, mock_org_cls, mock_client_cls): + mock_org = MagicMock() + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["schema", "reset"]) + assert result.exit_code == 4 + assert "--confirm" in result.output + mock_org.reset_schemas.assert_not_called() + + @patch("limacharlie.commands.schema.Client") + @patch("limacharlie.commands.schema.Organization") + def test_schema_reset_with_confirm(self, mock_org_cls, mock_client_cls): + mock_org = MagicMock() + mock_org.reset_schemas.return_value = {"success": True} + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "schema", "reset", "--confirm"]) + assert result.exit_code == 0 + mock_org.reset_schemas.assert_called_once_with() + + diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 2154225e..62f2b696 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -189,7 +189,7 @@ "payload": frozenset({"delete", "download", "list", "upload"}), "playbook": frozenset({"delete", "disable", "enable", "get", "list", "set"}), "replay": frozenset({"run"}), - "schema": frozenset({"get", "list"}), + "schema": frozenset({"get", "list", "reset"}), "search": frozenset({ "checkpoint-show", "checkpoints", "estimate", "run", "saved-create", "saved-delete", "saved-get", "saved-list", diff --git a/tests/unit/test_sdk_organization.py b/tests/unit/test_sdk_organization.py index 7dacf77e..b279df9e 100644 --- a/tests/unit/test_sdk_organization.py +++ b/tests/unit/test_sdk_organization.py @@ -91,6 +91,10 @@ def test_get_schema(self, org, mock_client): org.get_schema("NEW_PROCESS") mock_client.request.assert_called_once_with("GET", "orgs/test-oid-123/schema/NEW_PROCESS") + def test_reset_schemas(self, org, mock_client): + org.reset_schemas() + mock_client.request.assert_called_once_with("DELETE", "orgs/test-oid-123/schema") + class TestOrganizationOutputs: def test_get_outputs(self, org, mock_client): From 2d83f35600f5ba0a7411e616a90d06e3279e6d7a Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 21 May 2026 08:39:08 -0700 Subject: [PATCH 06/14] cli: add --enabled/--disabled flag to hive set commands (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hive records are created disabled by default, which surprises operators (and LLMs) who run e.g. `secret set` and expect the record to be live. Add an `--enabled/--disabled` flag to the create/update commands so a record can be created and enabled in one shot: limacharlie secret set --key foo --input-file foo.yaml --enabled limacharlie lookup set --key bar --input-file bar.yaml --enabled limacharlie hive set --hive-name lookup --key baz --input-file f --enabled limacharlie dr set --key my-rule --input-file rule.yaml --enabled When passed, the flag overrides any usr_mtd.enabled value in the input file. When omitted, behavior is unchanged: the input file's value (if any) is preserved, otherwise the server-side default applies. The change is scoped to the three entry points that create hive records: - `_hive_shortcut.py` — covers secret, lookup, playbook, ai-skill, cloud-adapter, external-adapter, fp, note, sop - `hive.py` — generic `hive set` - `dr.py` — `dr set` Help text on each updated to call out the disabled-by-default behavior and the new flag. Co-authored-by: Claude Opus 4.7 (1M context) --- limacharlie/commands/_hive_shortcut.py | 15 +++- limacharlie/commands/dr.py | 17 ++++- limacharlie/commands/hive.py | 20 ++++-- tests/unit/test_cli_ai_skill_memory.py | 68 ++++++++++++++++++ tests/unit/test_cli_commands.py | 97 ++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 9 deletions(-) diff --git a/limacharlie/commands/_hive_shortcut.py b/limacharlie/commands/_hive_shortcut.py index 4659a991..55a226f6 100644 --- a/limacharlie/commands/_hive_shortcut.py +++ b/limacharlie/commands/_hive_shortcut.py @@ -47,7 +47,12 @@ def make_hive_group(group_name: str, hive_name: str, noun_singular: str, noun_pl explain_list = f"List all {noun_plural} stored in the '{hive_name}' hive." explain_get = f"Get a specific {noun_singular} by its key name from the '{hive_name}' hive." - explain_set = f"Create or update {article} {noun_singular} in the '{hive_name}' hive. Provide data via --input-file (JSON/YAML) or stdin." + explain_set = ( + f"Create or update {article} {noun_singular} in the '{hive_name}' hive. " + f"Provide data via --input-file (JSON/YAML) or stdin. " + f"New hive records default to disabled — pass --enabled to create-and-enable in one shot, " + f"or include usr_mtd.enabled: true in the input file." + ) explain_delete = f"Delete {article} {noun_singular} from the '{hive_name}' hive. Requires --confirm for safety." @click.group(group_name) @@ -77,8 +82,12 @@ def get_cmd(ctx, key) -> None: @grp.command("set", help=f"Create or update {article} {noun_singular}.") @click.option("--key", required=True, help="Record key name.") @click.option("--input-file", type=click.Path(exists=True), default=None, help="JSON or YAML file with record data.") + @click.option( + "--enabled/--disabled", "enabled", default=None, + help=f"Set usr_mtd.enabled on the {noun_singular}. Overrides any value in the input file. Records default to disabled if neither this flag nor usr_mtd.enabled is provided.", + ) @pass_context - def set_cmd(ctx, key, input_file) -> None: + def set_cmd(ctx, key, input_file, enabled) -> None: if input_file: with open(input_file, "r") as f: content = f.read() @@ -108,6 +117,8 @@ def set_cmd(ctx, key, input_file) -> None: record = HiveRecord.from_raw(key, raw) else: record = HiveRecord(key, data=data) + if enabled is not None: + record.enabled = enabled org = _get_org(ctx) hive = Hive(org, hive_name) result = hive.set(record) diff --git a/limacharlie/commands/dr.py b/limacharlie/commands/dr.py index 906946e2..a6bb2659 100644 --- a/limacharlie/commands/dr.py +++ b/limacharlie/commands/dr.py @@ -254,9 +254,13 @@ def get(ctx, key, namespace) -> None: tags: [] comment: "rule description" +New D&R rules are created DISABLED by default for safety. Pass +--enabled to create-and-enable in one shot, or set usr_mtd.enabled +in the input. + Examples: - limacharlie dr set --key my-rule --input-file rule.yaml - cat rule.json | limacharlie dr set --key my-rule + limacharlie dr set --key my-rule --input-file rule.yaml --enabled + cat rule.json | limacharlie dr set --key my-rule --enabled limacharlie dr set --key my-rule --namespace managed --input-file rule.yaml IMPORTANT: Do not write D&R rules from scratch. Use @@ -278,8 +282,12 @@ def get(ctx, key, namespace) -> None: "--namespace", default=None, type=_NS_CHOICES, help="Namespace (default: general).", ) +@click.option( + "--enabled/--disabled", "enabled", default=None, + help="Set usr_mtd.enabled on the rule. Overrides any value in the input file. New rules default to disabled if neither this flag nor usr_mtd.enabled is provided.", +) @pass_context -def set_cmd(ctx, key, input_file, namespace) -> None: +def set_cmd(ctx, key, input_file, namespace, enabled) -> None: if input_file: with open(input_file, "r") as f: content = f.read() @@ -307,6 +315,9 @@ def set_cmd(ctx, key, input_file, namespace) -> None: else: record = HiveRecord(key, data=data) + if enabled is not None: + record.enabled = enabled + org = _get_org(ctx) hive = Hive(org, _hive_name(namespace)) result = hive.set(record) diff --git a/limacharlie/commands/hive.py b/limacharlie/commands/hive.py index 837e8651..7a7cf1f6 100644 --- a/limacharlie/commands/hive.py +++ b/limacharlie/commands/hive.py @@ -210,12 +210,15 @@ def get(ctx, hive_name, key) -> None: --input-file or from stdin if no file is specified. The input should be a JSON or YAML document. +New hive records are created DISABLED by default. Pass --enabled to +create-and-enable in one shot, or set usr_mtd.enabled in the input. + Full record format (YAML): data: key: value # payload varies by hive type usr_mtd: - enabled: true # optional, default true + enabled: true # optional, default false on new records expiry: 0 # optional, unix epoch (0 = never) tags: # optional - my-tag @@ -225,6 +228,9 @@ def get(ctx, hive_name, key) -> None: If the input has no "data" key, the entire input is treated as the record data payload. +The --enabled/--disabled flag, when given, overrides any value in +the input file's usr_mtd.enabled. + Data payload examples per hive type: secret: {secret: "my-api-key"} yara: {rule: "rule MyRule { ... }"} @@ -233,10 +239,10 @@ def get(ctx, hive_name, key) -> None: Examples: echo '{"data": {"key": "value"}}' | limacharlie hive set \\ - --hive-name lookup --key my-lookup + --hive-name lookup --key my-lookup --enabled limacharlie hive set --hive-name lookup --key my-lookup \\ - --input-file record.yaml + --input-file record.yaml --enabled """ register_explain("hive.set", _EXPLAIN_SET) @@ -245,8 +251,12 @@ def get(ctx, hive_name, key) -> None: @click.option("--hive-name", required=True, help="Hive name.") @click.option("--key", required=True, help="Record key.") @click.option("--input-file", default=None, type=click.Path(exists=True), help="Path to record data (JSON or YAML). Reads stdin if omitted.") +@click.option( + "--enabled/--disabled", "enabled", default=None, + help="Set usr_mtd.enabled on the record. Overrides any value in the input file. New records default to disabled if neither this flag nor usr_mtd.enabled is provided.", +) @pass_context -def set_record(ctx, hive_name, key, input_file) -> None: +def set_record(ctx, hive_name, key, input_file, enabled) -> None: data = _load_input(input_file) if data is None: click.echo( @@ -260,6 +270,8 @@ def set_record(ctx, hive_name, key, input_file) -> None: org = _get_org(ctx) hive = Hive(org, hive_name) record = _record_from_input(key, data) + if enabled is not None: + record.enabled = enabled result = hive.set(record) if not ctx.obj.quiet: click.echo(f"Record '{key}' set in hive '{hive_name}'.") diff --git a/tests/unit/test_cli_ai_skill_memory.py b/tests/unit/test_cli_ai_skill_memory.py index 9982b674..2f3bca6b 100644 --- a/tests/unit/test_cli_ai_skill_memory.py +++ b/tests/unit/test_cli_ai_skill_memory.py @@ -62,6 +62,74 @@ def test_delete_requires_confirm(self): result = CliRunner().invoke(cli, ["ai-skill", "delete", "--key", "x"]) assert result.exit_code != 0 + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_set_enabled_flag_creates_enabled(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + result = CliRunner().invoke( + cli, ["ai-skill", "set", "--key", "triage", "--enabled"], + input=json.dumps({"content": "..."}), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is True + + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_set_no_flag_leaves_enabled_unset(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + result = CliRunner().invoke( + cli, ["ai-skill", "set", "--key", "triage"], + input=json.dumps({"content": "..."}), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + # Without the flag, enabled stays None so the API default applies. + assert record.enabled is None + + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_set_enabled_flag_overrides_input_file_value(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + payload = {"data": {"content": "..."}, "usr_mtd": {"enabled": False}} + result = CliRunner().invoke( + cli, ["ai-skill", "set", "--key", "triage", "--enabled"], + input=json.dumps(payload), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is True + + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_set_disabled_flag_works(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + # Input file says enabled=true, --disabled forces it off. + payload = {"data": {"content": "..."}, "usr_mtd": {"enabled": True}} + result = CliRunner().invoke( + cli, ["ai-skill", "set", "--key", "triage", "--disabled"], + input=json.dumps(payload), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is False + # --------------------------------------------------------------------------- # ai-memory (custom commands with partial-merge payloads) diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index cdb34b2a..b4f38fcd 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -509,6 +509,103 @@ def test_shortcut_disable(self, mock_hive_cls, mock_org_cls, mock_client_cls): assert record.tags == ["keep-me"] +class TestHiveSetEnabledFlag: + """The `--enabled/--disabled` flag on the create/update commands lets + AIs and operators create-and-enable a record in one shot, so they don't + forget the separate `enable` step and end up with silently-disabled + records. + """ + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_hive_set_with_enabled_flag(self, mock_hive_cls, _org, _client): + import json as _json + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + result = CliRunner().invoke( + cli, ["hive", "set", "--hive-name", "lookup", "--key", "k", "--enabled"], + input=_json.dumps({"data": {"v": 1}}), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is True + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_hive_set_flag_overrides_input_file(self, mock_hive_cls, _org, _client): + import json as _json + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + payload = {"data": {"v": 1}, "usr_mtd": {"enabled": False}} + result = CliRunner().invoke( + cli, ["hive", "set", "--hive-name", "lookup", "--key", "k", "--enabled"], + input=_json.dumps(payload), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is True + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_hive_set_no_flag_preserves_input_file_value(self, mock_hive_cls, _org, _client): + import json as _json + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + payload = {"data": {"v": 1}, "usr_mtd": {"enabled": True}} + result = CliRunner().invoke( + cli, ["hive", "set", "--hive-name", "lookup", "--key", "k"], + input=_json.dumps(payload), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is True + + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_dr_set_with_enabled_flag(self, mock_hive_cls, _org, _client): + import json as _json + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + rule = {"detect": {"event": "NEW_PROCESS", "op": "is", "path": "event/FILE_PATH", "value": "x"}, "respond": [{"action": "report", "name": "x"}]} + result = CliRunner().invoke( + cli, ["dr", "set", "--key", "my-rule", "--enabled"], + input=_json.dumps(rule), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is True + + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_dr_set_no_flag_leaves_enabled_unset(self, mock_hive_cls, _org, _client): + import json as _json + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "e2"} + mock_hive_cls.return_value = mock_hive + + rule = {"detect": {"event": "NEW_PROCESS", "op": "is", "path": "event/FILE_PATH", "value": "x"}, "respond": [{"action": "report", "name": "x"}]} + result = CliRunner().invoke( + cli, ["dr", "set", "--key", "my-rule"], + input=_json.dumps(rule), + ) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.enabled is None + + class TestSchemaCommands: @patch("limacharlie.commands.schema.Client") @patch("limacharlie.commands.schema.Organization") From 5cc16b2513bce63c7297d0830a47eccb602ec4ba Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Mon, 25 May 2026 08:06:54 -0700 Subject: [PATCH 07/14] cli: clarify event overview/timeline/list --ai-help to prevent unreachable polling predicates (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real AI session burned its full 5-minute foreground-Bash timeout polling "event overview" with `grep -q event_type` — a predicate unreachable by construction because overview returns only millisecond-epoch timestamp buckets, no event content. The previous --ai-help said overview gave a "high-level summary of event activity" without showing the output shape, which an AI can reasonably read as "richer than just timestamps". This commit extends the three relevant explain blocks so any AI reading --ai-help cold can pick the right tool the first time: - event overview: shows the actual output shape (flat list of ms epochs), what empty looks like (`[]`), what overview is good for, and an explicit "do not use this for sampling content or for predicates keyed on in-payload fields — those are unreachable, you will spin until timeout." - event timeline: alias, points at event overview's expanded text. - event list: notes the empty result is the literal `[]` and that structural empty-vs-non-empty against `[]` is the reliable presence predicate — not greping for in-payload strings. No behavior change; --ai-help text only. --- limacharlie/commands/event.py | 38 ++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/limacharlie/commands/event.py b/limacharlie/commands/event.py index 4fd88dcb..dfdad90a 100644 --- a/limacharlie/commands/event.py +++ b/limacharlie/commands/event.py @@ -79,6 +79,12 @@ def group() -> None: routing: oid, sid, event_type, event_time (ms), hostname, ext_ip, int_ip, tags event: fields vary by event_type (e.g. FILE_PATH, COMMAND_LINE, DOMAIN_NAME) +Empty result: the command returns "[]" (literal empty list). Use +that as the empty-check when polling for "did anything arrive yet?". +Do NOT poll by greping for in-payload strings like "event_type" — +the structural empty-vs-non-empty test against "[]" is the reliable +signal. + Events are linked by atom hashes: routing/this identifies the event, routing/parent links to the parent process. Use 'event get' and 'event children' to navigate the event tree. @@ -178,9 +184,29 @@ def children(ctx: click.Context, sid: str, atom: str) -> None: # --------------------------------------------------------------------------- _EXPLAIN_OVERVIEW = """\ -Get an event overview (timeline) for a sensor. The overview provides -a high-level summary of event activity within a time range, showing -when events occurred without returning full event data. +Get an event overview (timeline) for a sensor. The overview shows +WHEN events occurred — NOT what they were. No event_type, no +payload, no fields. + +Output shape (--output yaml / json): + populated: a flat list of millisecond-epoch timestamps, one per + bucketed event, e.g. + - 1779720718401 + - 1779720598401 + - 1779720478399 + empty: [] (literal empty list) + +Use this for: + - "did any events arrive in this window?" (non-empty test). + - rough activity heatmap / silence detection on a sensor. + +Do NOT use this for: + - sampling an event to inspect its content — overview NEVER returns + fields. Use 'event list --limit N' instead. + - polling whose predicate keys on in-payload content (event_type, + methodName, serviceName, etc.) — overview's output is just + timestamps; such a predicate is unreachable against this command + and will spin until your timeout. You must provide --sid (sensor ID) and a time range via --start and --end (unix epoch seconds). @@ -210,8 +236,10 @@ def overview(ctx: click.Context, sid: str, start: int, end: int) -> None: # --------------------------------------------------------------------------- _EXPLAIN_TIMELINE = """\ -Alias for 'event overview'. Get an event timeline for a sensor showing -when events occurred within a time range. +Alias for 'event overview'. Returns a flat list of millisecond-epoch +timestamps showing WHEN events occurred — no event content. See +'event overview --ai-help' for output shape and the "use 'event list' +to sample content" caveat. Example: limacharlie event timeline --sid --start 1700000000 --end 1700086400 From c6a252b326ebe2c82d29dbade38a5f37310abbf6 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Thu, 28 May 2026 15:48:57 -0700 Subject: [PATCH 08/14] cli: fix org quota help text + add dr enable/disable aliases (#298) * cli: fix misleading org quota --quota help text The --quota option claimed "0 to remove limit", implying 0 makes the quota unlimited. The backend (doQuotaChange) sets the value as the org's licensed sensor count (Stripe subscription quantity); 0 puts the org on the free tier (no paid quota), the opposite of "remove limit". Updated the option help and the AI explain text to match. Co-Authored-By: Claude Opus 4.8 (1M context) * cli: add 'dr enable' and 'dr disable' aliases D&R rules are hive-backed, but the dr command group lacked the enable/disable convenience aliases that hive (and the per-hive shortcuts) provide. Add them mirroring the hive pattern: read the record metadata first (preserving tags/expiry/comment) and toggle only usr_mtd.enabled, routing to the /mtd endpoint. Honors --namespace (general/managed/service). Includes --ai-help explain text and unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- limacharlie/commands/dr.py | 73 +++++++++++++++++++ limacharlie/commands/org.py | 8 +- tests/unit/test_cli_commands.py | 64 ++++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 4 +- 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/limacharlie/commands/dr.py b/limacharlie/commands/dr.py index a6bb2659..cf30e612 100644 --- a/limacharlie/commands/dr.py +++ b/limacharlie/commands/dr.py @@ -361,6 +361,79 @@ def delete(ctx, key, namespace, confirm) -> None: _output(ctx, result) +# --------------------------------------------------------------------------- +# enable / disable +# --------------------------------------------------------------------------- + +_EXPLAIN_ENABLE = """\ +Enable a D&R rule by setting its usr_mtd.enabled flag to true. Only the +enabled flag is changed; all other metadata (tags, expiry, comment) and +the rule data (detect/respond) are preserved. + +If the rule is in the 'managed' or 'service' namespace, pass --namespace +accordingly. + +Examples: + limacharlie dr enable --key my-rule + limacharlie dr enable --key some-managed-rule --namespace managed +""" +register_explain("dr.enable", _EXPLAIN_ENABLE) + +_EXPLAIN_DISABLE = """\ +Disable a D&R rule by setting its usr_mtd.enabled flag to false. Only +the enabled flag is changed; all other metadata (tags, expiry, comment) +and the rule data (detect/respond) are preserved. + +If the rule is in the 'managed' or 'service' namespace, pass --namespace +accordingly. + +Examples: + limacharlie dr disable --key my-rule + limacharlie dr disable --key some-managed-rule --namespace managed +""" +register_explain("dr.disable", _EXPLAIN_DISABLE) + + +def _set_enabled(ctx: click.Context, namespace: str | None, key: str, enabled: bool) -> None: + """Toggle the enabled flag on a D&R rule. + + Reads the current metadata first so that tags, expiry, and comment + are preserved (the API replaces usr_mtd wholesale). + """ + hive_name = _hive_name(namespace) + org = _get_org(ctx) + hive = Hive(org, hive_name) + record = hive.get_metadata(key) + record.enabled = enabled + result = hive.set(record) + state = "enabled" if enabled else "disabled" + if not ctx.obj.quiet: + click.echo(f"Rule '{key}' {state} in namespace '{namespace or 'general'}'.") + _output(ctx, result) + + +@group.command() +@click.option("--key", required=True, help="Rule key name.") +@click.option( + "--namespace", default=None, type=_NS_CHOICES, + help="Namespace (default: general).", +) +@pass_context +def enable(ctx, key, namespace) -> None: + _set_enabled(ctx, namespace, key, True) + + +@group.command() +@click.option("--key", required=True, help="Rule key name.") +@click.option( + "--namespace", default=None, type=_NS_CHOICES, + help="Namespace (default: general).", +) +@pass_context +def disable(ctx, key, namespace) -> None: + _set_enabled(ctx, namespace, key, False) + + # --------------------------------------------------------------------------- # Helpers for loading events # --------------------------------------------------------------------------- diff --git a/limacharlie/commands/org.py b/limacharlie/commands/org.py index 1356632e..98614c8d 100644 --- a/limacharlie/commands/org.py +++ b/limacharlie/commands/org.py @@ -374,15 +374,15 @@ def rename(ctx: click.Context, name: str) -> None: # --------------------------------------------------------------------------- _EXPLAIN_QUOTA = """\ -Set the sensor quota for the organization. The quota limits how many -sensors can be enrolled simultaneously. Set to 0 to remove the quota -limit (billing still applies). +Set the sensor quota for the organization. The quota is the number of +sensors the organization is licensed (billed) for. Set to 0 to put the +organization on the free tier (no paid sensor quota). """ register_explain("org.quota", _EXPLAIN_QUOTA) @group.command() -@click.option("--quota", required=True, type=int, help="Sensor quota (0 to remove limit).") +@click.option("--quota", required=True, type=int, help="Sensor quota: number of licensed sensors (0 = free tier).") @pass_context def quota(ctx: click.Context, quota: int) -> None: org = _get_org(ctx) diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index b4f38fcd..934bbb29 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -428,6 +428,70 @@ def test_dr_list(self, mock_hive_cls, mock_org_cls, mock_client_cls): assert "my-rule" in parsed mock_hive.list.assert_called_once() + @staticmethod + def _existing_record(name="my-rule", enabled=True): + from limacharlie.sdk.hive import HiveRecord + return HiveRecord( + name=name, enabled=enabled, + tags=["keep-me"], expiry=1234, comment="preserve this", + etag="old-etag", + ) + + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_dr_enable(self, mock_hive_cls, mock_org_cls, mock_client_cls): + mock_hive = MagicMock() + mock_hive.get_metadata.return_value = self._existing_record(enabled=False) + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, ["dr", "enable", "--key", "my-rule"]) + assert result.exit_code == 0 + # Defaults to the dr-general hive. + assert mock_hive_cls.call_args[0][1] == "dr-general" + record = mock_hive.set.call_args[0][0] + assert record.enabled is True + assert record.data is None # only metadata update + assert record.tags == ["keep-me"] + assert record.expiry == 1234 + assert record.comment == "preserve this" + + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_dr_disable(self, mock_hive_cls, mock_org_cls, mock_client_cls): + mock_hive = MagicMock() + mock_hive.get_metadata.return_value = self._existing_record(enabled=True) + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, ["dr", "disable", "--key", "my-rule"]) + assert result.exit_code == 0 + record = mock_hive.set.call_args[0][0] + assert record.enabled is False + assert record.data is None + assert record.tags == ["keep-me"] + + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_dr_disable_namespace(self, mock_hive_cls, mock_org_cls, mock_client_cls): + mock_hive = MagicMock() + mock_hive.get_metadata.return_value = self._existing_record(enabled=True) + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, ["dr", "disable", "--key", "my-rule", "--namespace", "managed"]) + assert result.exit_code == 0 + # Namespace maps to the dr-managed hive. + assert mock_hive_cls.call_args[0][1] == "dr-managed" + record = mock_hive.set.call_args[0][0] + assert record.enabled is False + class TestHiveEnableDisable: @staticmethod diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 62f2b696..8020d077 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -146,8 +146,8 @@ "detection": frozenset({"get", "list"}), "download": frozenset({"adapter", "list", "sensor"}), "dr": frozenset({ - "convert-rules", "delete", "export", "get", "import", - "list", "replay", "set", "test", "validate", + "convert-rules", "delete", "disable", "enable", "export", "get", + "import", "list", "replay", "set", "test", "validate", }), "endpoint-policy": frozenset({"isolate", "rejoin", "seal", "status", "unseal"}), "event": frozenset({ From 1785582d48a40dade554ab8d1f0299adf63f835b Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 30 May 2026 09:03:36 -0700 Subject: [PATCH 09/14] CLI: discoverability & ergonomics improvements (projection flags, scaffolding, clearer verdicts) (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cli: discoverability & ergonomics improvements Proactive developer-experience improvements found during routine review of the CLI surface. Each change is small and self-contained. - Global projection flags: add --fields, --sort-by, --reverse on the root group, mirroring the existing --filter module-level mechanism in output.py so they flow into every command's render path. - api-key list --name: filter the key-hash-keyed result down to the single matching key while preserving the raw object shape. - start-session: accept the hive://ai_agent/ URI form (the form the D&R 'start ai agent' action uses) in addition to a bare key. - hive validate: emit an explicit positive verdict ("Record is valid." to stderr; {"valid": true} for json/yaml when the API is silent), keeping stdout machine-stable. - hive set: add --tag-add/--tag-rm (additive), --comment, --expiry; metadata-only update when no data is supplied, overrides otherwise. - secret set --value and a 'tag' subcommand (add/rm/set) on hive shortcut groups; --value documents the shell-history exposure. - dr set --detect/--respond/--tag: assemble a rule from component files (mutually exclusive with --input-file). - cloud-adapter/external-adapter list-types: derive supported adapter types from the cloud_sensor schema with a curated fallback; fix stale "...and others" prose that omitted threatlocker. - hive schema: default to a flat field table (resolving $ref/$defs); raw JSON-Schema still available via --output json. - event types: note that an empty result on a fresh org is expected (the schema is observed, not declared). Co-Authored-By: Claude Opus 4.8 (1M context) * cli: generalize hive shortcut --value via per-hive value_key The shared hive shortcut set command exposed --value wrapping the input as {data: {secret: }} for every hive, but only the secret hive uses a single "secret" data field — the wrapper was meaningless (and wrong) for the structured-data hives (lookup, fp, playbook, note, sop, adapters, ai-skill). make_hive_group now takes an optional value_key naming the hive's single scalar data field. --value is offered only when value_key is set (the secret group declares value_key="secret") and wraps as {data: {: }}. Structured-data hives no longer advertise --value at all. Co-Authored-By: Claude Opus 4.8 (1M context) * cli: fix adapter list-types to enumerate real adapter types per hive list-types derived its list from the bare JSON-Schema $defs keys, which are helper structs (ClientOptions, AckBufferOptions, Dict, …), not adapter types — so it printed garbage. The reflected schema is a root that $refs into the record definition (CloudSensorRecord / ExternalAdapterConfig); the real type names are that record's properties (s3, office365, threatlocker, …) minus the sensor_type discriminator. - Resolve the root $ref into the record and use its properties (fall back to inline root properties); never enumerate raw $defs keys. - Parameterize by hive so cloud-adapter reads cloud_sensor and external-adapter reads external_adapter (their type sets genuinely differ). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- limacharlie/ai_help.py | 2 + limacharlie/cli.py | 18 +- limacharlie/commands/_adapter_types.py | 189 +++++++ limacharlie/commands/_hive_shortcut.py | 159 ++++-- limacharlie/commands/adapter.py | 7 +- limacharlie/commands/ai.py | 6 +- limacharlie/commands/api_key.py | 22 +- limacharlie/commands/cloud_sensor.py | 6 +- limacharlie/commands/dr.py | 88 +++- limacharlie/commands/event.py | 11 + limacharlie/commands/hive.py | 229 +++++++- limacharlie/commands/secret.py | 2 +- limacharlie/output.py | 32 +- limacharlie/sdk/ai.py | 13 +- tests/unit/test_cli_ergonomics.py | 497 ++++++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 22 +- tests/unit/test_dataclasses.py | 2 +- tests/unit/test_sdk_ai_sessions.py | 31 ++ 18 files changed, 1246 insertions(+), 90 deletions(-) create mode 100644 limacharlie/commands/_adapter_types.py create mode 100644 tests/unit/test_cli_ergonomics.py diff --git a/limacharlie/ai_help.py b/limacharlie/ai_help.py index 4a75ea5f..c9673772 100644 --- a/limacharlie/ai_help.py +++ b/limacharlie/ai_help.py @@ -140,6 +140,8 @@ def _top_level_help(cli: click.Group) -> str: lines.append("--wide Don't truncate table columns") lines.append("--filter JMESPATH Filter/transform output") lines.append("--fields f1,f2 Select specific output fields") + lines.append("--sort-by FIELD Sort list output by a field") + lines.append("--reverse Reverse sorted order (with --sort-by)") lines.append("--quiet Suppress non-data output") lines.append("--debug Print HTTP request details") lines.append("--env NAME Use a named environment from config") diff --git a/limacharlie/cli.py b/limacharlie/cli.py index 21f123cb..d7135d13 100644 --- a/limacharlie/cli.py +++ b/limacharlie/cli.py @@ -53,6 +53,9 @@ class LimaCharlieContext: wide: bool = False no_warnings: bool = False filter_expr: str | None = None + fields: list[str] | None = None + sort_by: str | None = None + reverse: bool = False profile: str | None = None environment: str | None = None @@ -346,11 +349,14 @@ def _find_shadowed_opts( @click.option("--wide", "-W", is_flag=True, default=False, help="Disable table value truncation (show full values).") @click.option("--no-warnings", is_flag=True, default=False, help="Suppress advisory warnings (cost notices, memory hints, checkpoint suggestions).") @click.option("--filter", "filter_expr", default=None, help="JMESPath expression to filter/transform output (e.g. 'user_perms', 'keys(@)').") +@click.option("--fields", "fields", default=None, help="Comma-separated field names to keep in output (e.g. 'sid,hostname'). Applied to list/record output.") +@click.option("--sort-by", "sort_by", default=None, help="Field name to sort list output by.") +@click.option("--reverse", "reverse", is_flag=True, default=False, help="Reverse the order of sorted list output (use with --sort-by).") @click.option("--profile", default=None, help="Named credential profile to use.") @click.option("--env", "environment", default=None, help="Named environment from config file.") @click.version_option(version=__version__, prog_name="limacharlie") @click.pass_context -def cli(ctx: click.Context, oid: str | None, output_format: str | None, debug: bool, debug_full: bool, debug_curl: bool, quiet: bool, wide: bool, no_warnings: bool, filter_expr: str | None, profile: str | None, environment: str | None) -> None: +def cli(ctx: click.Context, oid: str | None, output_format: str | None, debug: bool, debug_full: bool, debug_curl: bool, quiet: bool, wide: bool, no_warnings: bool, filter_expr: str | None, fields: str | None, sort_by: str | None, reverse: bool, profile: str | None, environment: str | None) -> None: """LimaCharlie CLI - Endpoint Detection & Response platform. Manage sensors, detection rules, hive data, and more from the command line. @@ -368,14 +374,22 @@ def cli(ctx: click.Context, oid: str | None, output_format: str | None, debug: b lc_ctx.wide = wide lc_ctx.no_warnings = no_warnings or _config_no_warnings() lc_ctx.filter_expr = filter_expr + # Parse --fields into a clean list of names (drop blanks/whitespace). + field_list = [f.strip() for f in fields.split(",") if f.strip()] if fields else None + lc_ctx.fields = field_list + lc_ctx.sort_by = sort_by + lc_ctx.reverse = reverse lc_ctx.profile = profile lc_ctx.environment = environment # Lazy import: output pulls in jmespath, tabulate, yaml, csv (~14ms). # Deferring to here avoids that cost for fast paths like --help, --version, # and --ai-help that never render command output. - from .output import set_filter_expr, set_wide_mode + from .output import set_filter_expr, set_wide_mode, set_fields, set_sort_by, set_reverse set_wide_mode(wide) set_filter_expr(filter_expr) + set_fields(field_list) + set_sort_by(sort_by) + set_reverse(reverse) # Inject --ai-help on the root cli group itself (subcommands get it lazily diff --git a/limacharlie/commands/_adapter_types.py b/limacharlie/commands/_adapter_types.py new file mode 100644 index 00000000..a5063343 --- /dev/null +++ b/limacharlie/commands/_adapter_types.py @@ -0,0 +1,189 @@ +"""Shared helpers for listing supported adapter/sensor types. + +Both the cloud-adapter and external-adapter command groups expose a +``list-types`` subcommand. The list of supported types is derived at +runtime from the ``cloud_sensor`` hive's JSON-Schema so it cannot go +stale relative to the backend; a curated fallback is used only if the +schema cannot be fetched or does not advertise the per-type sub-structs. +""" + +from __future__ import annotations + +from typing import Any + +import click + +from ..sdk.hive import Hive + +# Fields that appear at the top level of an adapter config but are NOT +# adapter type names (they are shared across every adapter type). These +# are filtered out when deriving the type list from the schema. +_NON_TYPE_FIELDS = { + "sensor_type", + "client_options", + "mapping", + "mappings", + "indexing", +} + +# Curated fallback list. IMPORTANT: this is only used when the live +# cloud_sensor schema cannot be fetched or parsed. It MUST be kept in +# sync with the backend's supported adapter types; prefer the schema- +# derived list, which cannot go stale. +_FALLBACK_ADAPTER_TYPES: dict[str, str] = { + "1password": "1Password audit events", + "azure_event_hub": "Azure Event Hub stream", + "carbon_black": "VMware Carbon Black events", + "cato": "Cato Networks events", + "crowdstrike": "CrowdStrike Falcon Data Replicator", + "duo": "Cisco Duo authentication logs", + "entraid": "Microsoft Entra ID (Azure AD) logs", + "file": "Tail a local file", + "gcs": "Google Cloud Storage objects", + "github": "GitHub audit log", + "google_workspace": "Google Workspace activity", + "guardduty": "AWS GuardDuty findings", + "imap": "IMAP mailbox ingestion", + "itglue": "IT Glue records", + "k8s_pods": "Kubernetes pod logs", + "mac_unified_logging": "macOS unified logging", + "mimecast": "Mimecast email security logs", + "ms_graph": "Microsoft Graph API", + "office365": "Microsoft Office 365 management activity", + "okta": "Okta system log", + "pubsub": "Google Cloud Pub/Sub", + "s3": "AWS S3 objects", + "sentinelone": "SentinelOne events", + "simulation": "Simulated/test events", + "slack": "Slack audit logs", + "sophos": "Sophos Central events", + "sqs": "AWS SQS queue", + "stdin": "Read events from stdin (external adapter only)", + "syslog": "Syslog over TCP/UDP", + "threatlocker": "ThreatLocker unified audit", + "webhook": "Inbound HTTP webhook", + "wel": "Windows Event Log (external adapter only)", + "wiz": "Wiz cloud security findings", +} + + +def _resolve_ref(root: dict, ref: str) -> dict | None: + """Resolve a local JSON-Schema ``$ref`` (e.g. ``#/$defs/CloudSensorRecord``).""" + if not isinstance(ref, str) or not ref.startswith("#/"): + return None + node: Any = root + for part in ref[2:].split("/"): + if not isinstance(node, dict): + return None + node = node.get(part) + return node if isinstance(node, dict) else None + + +def _describe(name: str) -> str: + """Best-effort human description for a derived type name (blank if unknown).""" + return ( + _FALLBACK_ADAPTER_TYPES.get(name) + or _FALLBACK_ADAPTER_TYPES.get(name.replace("_", "")) + or "" + ) + + +def _types_from_schema(schema: Any) -> list[str] | None: + """Derive adapter type names from an adapter hive JSON-Schema. + + The reflected schema is a root that ``$ref``s into ``$defs`` (e.g. + ``CloudSensorRecord`` / ``ExternalAdapterConfig``); the per-adapter config + lives in that record as a property keyed by the adapter type name + (``s3``, ``office365``, ``threatlocker``, …), alongside the ``sensor_type`` + discriminator. The type names are therefore the record's ``properties`` + minus the shared/non-type fields — NOT the bare ``$defs`` keys, which are + helper structs (``ClientOptions``, ``AckBufferOptions``, …). Returns + ``None`` if the schema does not expose any usable type names. + """ + if isinstance(schema, dict) and isinstance(schema.get("schema"), dict): + schema = schema["schema"] + if not isinstance(schema, dict): + return None + + # Follow a top-level $ref into the record definition. + record = schema + ref = schema.get("$ref") + if isinstance(ref, str): + resolved = _resolve_ref(schema, ref) + if resolved is not None: + record = resolved + + props = record.get("properties") + if not isinstance(props, dict): + return None + + names = {n for n in props if n and not n.startswith("_")} - _NON_TYPE_FIELDS + return sorted(names) or None + + +def adapter_types(org: Any, hive_name: str = "cloud_sensor") -> list[dict[str, str]]: + """Return the supported adapter types for a hive as name/description rows. + + Prefers the live hive schema (so it tracks the backend); falls back to the + curated constant when the schema is unavailable or does not advertise types. + """ + derived: list[str] | None = None + try: + schema = Hive(org, hive_name).get_schema() + derived = _types_from_schema(schema) + except Exception: + derived = None + + if derived: + return [{"type": name, "description": _describe(name)} for name in derived] + return [ + {"type": name, "description": desc} + for name, desc in sorted(_FALLBACK_ADAPTER_TYPES.items()) + ] + + +_EXPLAIN_LIST_TYPES = """\ +List the supported adapter/sensor type names with a short description. + +The list is derived from the live adapter hive JSON-Schema when +available (so it tracks the backend), with a curated fallback otherwise. +Use a type name as the top-level sensor_type when calling 'set'. + +The cloud-adapter and external-adapter type sets differ: cloud adapters +run in LimaCharlie's infrastructure, external (on-prem) adapters add +types like syslog, file, stdin and wel. Consult the set --ai-help for +the per-type config shape. +""" + + +def add_list_types(group: click.Group, command_path: str, hive_name: str = "cloud_sensor") -> None: + """Attach a ``list-types`` subcommand to an adapter command group. + + ``hive_name`` selects which adapter hive's schema to enumerate + (``cloud_sensor`` vs ``external_adapter``) so each group lists its own + supported types. + """ + from ..cli import pass_context + from ..client import Client + from ..sdk.organization import Organization + from ..output import format_output, detect_output_format + from ..discovery import register_explain + + @group.command("list-types", help="List supported adapter/sensor types.") + @pass_context + def list_types_cmd(ctx) -> None: + client = Client( + oid=ctx.obj.oid, + environment=ctx.obj.environment, + print_debug_fn=ctx.obj.debug_fn, + debug_full_response=ctx.obj.debug_full, + debug_curl=ctx.obj.debug_curl, + debug_verbose=ctx.obj.debug_verbose, + ) + org = Organization(client) + data = adapter_types(org, hive_name) + if not ctx.obj.quiet: + fmt = ctx.obj.output_format or detect_output_format() + click.echo(format_output(data, fmt)) + + register_explain(command_path, _EXPLAIN_LIST_TYPES) diff --git a/limacharlie/commands/_hive_shortcut.py b/limacharlie/commands/_hive_shortcut.py index 55a226f6..1696e537 100644 --- a/limacharlie/commands/_hive_shortcut.py +++ b/limacharlie/commands/_hive_shortcut.py @@ -28,7 +28,7 @@ def _output(ctx: click.Context, data: Any) -> None: click.echo(format_output(data, fmt)) -def make_hive_group(group_name: str, hive_name: str, noun_singular: str, noun_plural: str | None = None) -> click.Group: +def make_hive_group(group_name: str, hive_name: str, noun_singular: str, noun_plural: str | None = None, value_key: str | None = None) -> click.Group: """Create a Click group for a specific hive type. Args: @@ -36,6 +36,13 @@ def make_hive_group(group_name: str, hive_name: str, noun_singular: str, noun_pl hive_name: Hive backend name (e.g., "secret"). noun_singular: Human-readable singular (e.g., "secret"). noun_plural: Human-readable plural (defaults to noun_singular + "s"). + value_key: Name of the single scalar field in the record's ``data`` + payload for hives whose value is one scalar (e.g. ``"secret"`` for + the secret hive, whose data is ``{secret: }``). When set, the + 'set' command gains a ``--value`` convenience flag that wraps the + value as ``{data: {: }}``. Leave None for hives + whose data is structured (lookup, fp, playbook, …) — they have no + single value field and do not get ``--value``. Returns: click.Group: The configured group with list, get, set, delete commands. @@ -47,9 +54,18 @@ def make_hive_group(group_name: str, hive_name: str, noun_singular: str, noun_pl explain_list = f"List all {noun_plural} stored in the '{hive_name}' hive." explain_get = f"Get a specific {noun_singular} by its key name from the '{hive_name}' hive." + value_hint = ( + f"Or use --value to set the {noun_singular} value directly " + f"(wrapped as {{data: {{{value_key}: }}}}); --value exposes the value " + f"on the command line and in shell history, so stdin or --input-file is the " + f"recommended path for humans. " + if value_key else "" + ) explain_set = ( f"Create or update {article} {noun_singular} in the '{hive_name}' hive. " f"Provide data via --input-file (JSON/YAML) or stdin. " + f"{value_hint}" + f"--tag (repeatable) and --comment populate usr_mtd. " f"New hive records default to disabled — pass --enabled to create-and-enable in one shot, " f"or include usr_mtd.enabled: true in the input file." ) @@ -79,44 +95,73 @@ def get_cmd(ctx, key) -> None: record = hive.get(key) _output(ctx, record.to_dict()) + def _value_option(fn): + # Only hives with a single scalar value field (value_key set, e.g. + # the secret hive) expose --value; structured-data hives do not, so + # there is no misleading flag and no hardcoded data-key assumption. + if value_key is None: + return fn + return click.option( + "--value", default=None, + help=f"Set the {noun_singular} value directly (wraps into {{data: {{{value_key}: }}}}). " + "WARNING: this exposes the value on the command line and in shell history; " + "stdin or --input-file remains the recommended path for humans.", + )(fn) + @grp.command("set", help=f"Create or update {article} {noun_singular}.") @click.option("--key", required=True, help="Record key name.") @click.option("--input-file", type=click.Path(exists=True), default=None, help="JSON or YAML file with record data.") + @_value_option + @click.option("--tag", "tags", multiple=True, help="Tag to set in usr_mtd (repeatable).") + @click.option("--comment", default=None, help="Set usr_mtd.comment on the record.") @click.option( "--enabled/--disabled", "enabled", default=None, help=f"Set usr_mtd.enabled on the {noun_singular}. Overrides any value in the input file. Records default to disabled if neither this flag nor usr_mtd.enabled is provided.", ) @pass_context - def set_cmd(ctx, key, input_file, enabled) -> None: - if input_file: - with open(input_file, "r") as f: - content = f.read() - elif not sys.stdin.isatty(): - content = sys.stdin.read() + def set_cmd(ctx, key, input_file, tags, comment, enabled, value=None) -> None: + if value is not None: + if input_file: + raise click.UsageError("--value is mutually exclusive with --input-file/stdin.") + # Convenience wrapper so a single-value record (value_key set) can + # be set without a file or stdin. --value is explicit intent, so + # stdin is ignored in this mode rather than consulted. + record = HiveRecord(key, data={value_key: value}) else: - raise click.UsageError("Provide data via --input-file or pipe to stdin.") - - # Parse input as YAML first (YAML is a superset of JSON) - try: - data = yaml.safe_load(content) - except Exception: - data = json.loads(content) - - # Support the same format as 'hive set': if the input has a - # "data" key, use it as the record data and extract usr_mtd. - if isinstance(data, dict) and "data" in data: - # Build a raw dict matching the API format so HiveRecord - # picks up usr_mtd and etag correctly. - raw = { - "data": data["data"], - "usr_mtd": data.get("usr_mtd", {}), - "sys_mtd": {}, - } - if data.get("etag"): - raw["sys_mtd"]["etag"] = data["etag"] - record = HiveRecord.from_raw(key, raw) - else: - record = HiveRecord(key, data=data) + if input_file: + with open(input_file, "r") as f: + content = f.read() + elif not sys.stdin.isatty(): + content = sys.stdin.read() + else: + hint = "--value, --input-file, or pipe to stdin" if value_key else "--input-file or pipe to stdin" + raise click.UsageError(f"Provide data via {hint}.") + + # Parse input as YAML first (YAML is a superset of JSON) + try: + data = yaml.safe_load(content) + except Exception: + data = json.loads(content) + + # Support the same format as 'hive set': if the input has a + # "data" key, use it as the record data and extract usr_mtd. + if isinstance(data, dict) and "data" in data: + # Build a raw dict matching the API format so HiveRecord + # picks up usr_mtd and etag correctly. + raw = { + "data": data["data"], + "usr_mtd": data.get("usr_mtd", {}), + "sys_mtd": {}, + } + if data.get("etag"): + raw["sys_mtd"]["etag"] = data["etag"] + record = HiveRecord.from_raw(key, raw) + else: + record = HiveRecord(key, data=data) + if tags: + record.tags = list(tags) + if comment is not None: + record.comment = comment if enabled is not None: record.enabled = enabled org = _get_org(ctx) @@ -158,6 +203,57 @@ def disable_cmd(ctx, key) -> None: result = hive.set(record) _output(ctx, result) + @grp.group("tag", help=f"Manage tags on {noun_plural}.") + def tag_group() -> None: + pass + + def _merge_tags(existing: list[str] | None, changes: tuple[str, ...], remove: bool) -> list[str]: + if remove: + to_remove = {t.lower() for t in changes} + return [t for t in (existing or []) if t.lower() not in to_remove] + seen: dict[str, str] = {} + for tag in (existing or []) + list(changes): + k = tag.lower() + if k not in seen: + seen[k] = tag + return list(seen.values()) + + @tag_group.command("set", help=f"Replace all tags on {article} {noun_singular}.") + @click.option("--key", required=True, help="Record key name.") + @click.option("--tag", "-t", "tags", multiple=True, required=True, help="Tag value (repeatable).") + @pass_context + def tag_set_cmd(ctx, key, tags) -> None: + org = _get_org(ctx) + hive = Hive(org, hive_name) + record = hive.get_metadata(key) + record.tags = list(tags) + result = hive.set(record) + _output(ctx, result) + + @tag_group.command("add", help=f"Add tags to {article} {noun_singular} (merged with existing).") + @click.option("--key", required=True, help="Record key name.") + @click.option("--tag", "-t", "tags", multiple=True, required=True, help="Tag value to add (repeatable).") + @pass_context + def tag_add_cmd(ctx, key, tags) -> None: + org = _get_org(ctx) + hive = Hive(org, hive_name) + record = hive.get_metadata(key) + record.tags = _merge_tags(record.tags, tags, remove=False) + result = hive.set(record) + _output(ctx, result) + + @tag_group.command("rm", help=f"Remove tags from {article} {noun_singular}.") + @click.option("--key", required=True, help="Record key name.") + @click.option("--tag", "-t", "tags", multiple=True, required=True, help="Tag value to remove (repeatable).") + @pass_context + def tag_rm_cmd(ctx, key, tags) -> None: + org = _get_org(ctx) + hive = Hive(org, hive_name) + record = hive.get_metadata(key) + record.tags = _merge_tags(record.tags, tags, remove=True) + result = hive.set(record) + _output(ctx, result) + # Register explain texts. register_explain(f"{group_name}.list", explain_list) register_explain(f"{group_name}.get", explain_get) @@ -165,5 +261,8 @@ def disable_cmd(ctx, key) -> None: register_explain(f"{group_name}.delete", explain_delete) register_explain(f"{group_name}.enable", f"Enable {article} {noun_singular} by key (sets usr_mtd.enabled to true).") register_explain(f"{group_name}.disable", f"Disable {article} {noun_singular} by key (sets usr_mtd.enabled to false).") + register_explain(f"{group_name}.tag.set", f"Replace all tags on {article} {noun_singular} (fetch metadata, set tags).") + register_explain(f"{group_name}.tag.add", f"Add tags to {article} {noun_singular}, merged additively with existing tags.") + register_explain(f"{group_name}.tag.rm", f"Remove tags from {article} {noun_singular}, keeping the rest.") return grp diff --git a/limacharlie/commands/adapter.py b/limacharlie/commands/adapter.py index a24093dc..2ad636e5 100644 --- a/limacharlie/commands/adapter.py +++ b/limacharlie/commands/adapter.py @@ -3,9 +3,11 @@ from __future__ import annotations from ._hive_shortcut import make_hive_group +from ._adapter_types import add_list_types from ..discovery import register_explain group = make_hive_group("external-adapter", "external_adapter", "external adapter") +add_list_types(group, "external-adapter.list-types", "external_adapter") # Override the generic hive explains with adapter-specific documentation. @@ -16,8 +18,9 @@ Each record contains the adapter type and its connection settings. Common adapter types: syslog, file, s3, gcs, pubsub, webhook, stdin, -office365, 1password, crowdstrike, carbon_black, duo, sophos, and many -others. +office365, 1password, crowdstrike, carbon_black, duo, sophos, +threatlocker, and more. Run 'limacharlie external-adapter list-types' +for the full, up-to-date list. Use --output json for the full config including connection details. """) diff --git a/limacharlie/commands/ai.py b/limacharlie/commands/ai.py index 2817c130..13a28878 100644 --- a/limacharlie/commands/ai.py +++ b/limacharlie/commands/ai.py @@ -271,6 +271,10 @@ def summarize_detection(ctx, detection_id) -> None: so on. Any --option flag below overrides the matching field from the template; the rest of the template is used as-is. +--definition accepts BOTH a bare record key (e.g. "my-agent") and the +"hive://ai_agent/" URI form (the form the D&R "start ai agent" +action uses); both resolve to the same ai_agent record. + This lets you reuse one ai_agent definition as a starting point and vary only the bits you need per-run (swap the prompt, cap the budget, change the model, add an env var, etc.). @@ -354,7 +358,7 @@ def _parse_env_kv(items: tuple[str, ...]) -> dict[str, str] | None: @group.command("start-session") @click.option("--definition", required=True, - help="Name of the ai_agent hive record to use as template.") + help="ai_agent hive record to use as template. Accepts a bare key (my-agent) or the hive://ai_agent/ URI form.") @click.option("--prompt", default=None, help="Replace the prompt from the definition.") @click.option("--name", default=None, help="Replace the session name.") @click.option("--idempotent-key", default=None, help="Deduplication key for the session.") diff --git a/limacharlie/commands/api_key.py b/limacharlie/commands/api_key.py index 7b4db46d..dadaf0cf 100644 --- a/limacharlie/commands/api_key.py +++ b/limacharlie/commands/api_key.py @@ -56,6 +56,13 @@ def group() -> None: List all API keys in the organization. Each key entry shows the key name, hash, creation date, and associated permissions. +With --output json the result is an OBJECT keyed by key-hash, whose +values carry the key's name and permissions (it is not a list). + +Use --name to filter the result down to the single +matching key entry; the output keeps the same key-hash-keyed shape +(an object with one entry, or an empty object if no key matches). + The actual secret key value is only returned at creation time and cannot be retrieved later. Use --output json to get the full key metadata for auditing purposes. @@ -63,11 +70,24 @@ def group() -> None: register_explain("api-key.list", _EXPLAIN_LIST) +def _key_name(entry: Any) -> str | None: + """Extract the human name from an API key entry value.""" + if isinstance(entry, dict): + # The API has used both 'name' and 'key_name' over time; accept either. + return entry.get("name") or entry.get("key_name") + return None + + @group.command("list") +@click.option("--name", "name", default=None, help="Filter to the single API key with this name (output keeps the key-hash-keyed object shape).") @pass_context -def list_keys(ctx) -> None: +def list_keys(ctx, name) -> None: org = _get_org(ctx) data = org.get_api_keys() + if name is not None and isinstance(data, dict): + # Keep the raw shape (object keyed by key-hash); just narrow it down + # to the matching entry/entries for back-compat with json consumers. + data = {h: v for h, v in data.items() if _key_name(v) == name} _output(ctx, data) diff --git a/limacharlie/commands/cloud_sensor.py b/limacharlie/commands/cloud_sensor.py index e954a588..b35adcca 100644 --- a/limacharlie/commands/cloud_sensor.py +++ b/limacharlie/commands/cloud_sensor.py @@ -3,9 +3,11 @@ from __future__ import annotations from ._hive_shortcut import make_hive_group +from ._adapter_types import add_list_types from ..discovery import register_explain group = make_hive_group("cloud-adapter", "cloud_sensor", "cloud adapter") +add_list_types(group, "cloud-adapter.list-types", "cloud_sensor") # Override the generic hive explains with cloud adapter documentation. @@ -59,7 +61,9 @@ across all types. Supported cloud adapter types include: webhook, s3, gcs, pubsub, -office365, 1password, crowdstrike, duo, sophos, and others. +office365, 1password, crowdstrike, duo, sophos, threatlocker, okta, +google_workspace, sentinelone, mimecast, and more. Run +'limacharlie cloud-adapter list-types' for the full, up-to-date list. Secrets can be referenced with hive://secret/name syntax to avoid storing credentials inline. diff --git a/limacharlie/commands/dr.py b/limacharlie/commands/dr.py index cf30e612..c82d6807 100644 --- a/limacharlie/commands/dr.py +++ b/limacharlie/commands/dr.py @@ -258,10 +258,17 @@ def get(ctx, key, namespace) -> None: --enabled to create-and-enable in one shot, or set usr_mtd.enabled in the input. +Instead of a full record, you can assemble a rule from separate +component files: --detect and --respond are loaded +and combined into {data: {detect, respond}, usr_mtd: {...}}. They +must be given together and are mutually exclusive with --input-file +(stdin is ignored in this mode). --tag (repeatable) adds usr_mtd tags. + Examples: limacharlie dr set --key my-rule --input-file rule.yaml --enabled cat rule.json | limacharlie dr set --key my-rule --enabled limacharlie dr set --key my-rule --namespace managed --input-file rule.yaml + limacharlie dr set --key my-rule --detect detect.yaml --respond respond.yaml --tag prod --enabled IMPORTANT: Do not write D&R rules from scratch. Use 'limacharlie ai generate-rule --prompt ""' to generate @@ -278,6 +285,15 @@ def get(ctx, key, namespace) -> None: "--input-file", type=click.Path(exists=True), default=None, help="JSON or YAML file with rule data.", ) +@click.option( + "--detect", "detect_path", type=click.Path(exists=True), default=None, + help="Path to the detection component (JSON/YAML). Use with --respond to assemble a rule. Mutually exclusive with --input-file/stdin.", +) +@click.option( + "--respond", "respond_path", type=click.Path(exists=True), default=None, + help="Path to the response component (JSON/YAML). Use with --detect to assemble a rule. Mutually exclusive with --input-file/stdin.", +) +@click.option("--tag", "tags", multiple=True, help="Tag to set in usr_mtd (repeatable).") @click.option( "--namespace", default=None, type=_NS_CHOICES, help="Namespace (default: general).", @@ -287,33 +303,55 @@ def get(ctx, key, namespace) -> None: help="Set usr_mtd.enabled on the rule. Overrides any value in the input file. New rules default to disabled if neither this flag nor usr_mtd.enabled is provided.", ) @pass_context -def set_cmd(ctx, key, input_file, namespace, enabled) -> None: - if input_file: - with open(input_file, "r") as f: - content = f.read() - elif not sys.stdin.isatty(): - content = sys.stdin.read() +def set_cmd(ctx, key, input_file, detect_path, respond_path, tags, namespace, enabled) -> None: + using_components = detect_path is not None or respond_path is not None + + if using_components: + # --detect/--respond assemble a rule in-command and express explicit + # intent, so they cannot be combined with a full record from + # --input-file (which would be ambiguous). Stdin is ignored in this + # mode rather than consulted. + if input_file: + raise click.UsageError( + "--detect/--respond are mutually exclusive with --input-file/stdin." + ) + if detect_path is None or respond_path is None: + raise click.UsageError("--detect and --respond must be provided together.") + detection = _load_file(detect_path) + response = _load_file(respond_path) + record = HiveRecord(key, data={"detect": detection, "respond": response}) else: - raise click.UsageError("Provide data via --input-file or pipe to stdin.") + if input_file: + with open(input_file, "r") as f: + content = f.read() + elif not sys.stdin.isatty(): + content = sys.stdin.read() + else: + raise click.UsageError( + "Provide data via --input-file, stdin, or --detect/--respond." + ) - try: - data = yaml.safe_load(content) - except Exception: - data = json.loads(content) - - # Support the full hive record format (with "data" wrapper) or - # a bare rule dict with detect/respond at the top level. - if isinstance(data, dict) and "data" in data: - raw = { - "data": data["data"], - "usr_mtd": data.get("usr_mtd", {}), - "sys_mtd": {}, - } - if data.get("etag"): - raw["sys_mtd"]["etag"] = data["etag"] - record = HiveRecord.from_raw(key, raw) - else: - record = HiveRecord(key, data=data) + try: + data = yaml.safe_load(content) + except Exception: + data = json.loads(content) + + # Support the full hive record format (with "data" wrapper) or + # a bare rule dict with detect/respond at the top level. + if isinstance(data, dict) and "data" in data: + raw = { + "data": data["data"], + "usr_mtd": data.get("usr_mtd", {}), + "sys_mtd": {}, + } + if data.get("etag"): + raw["sys_mtd"]["etag"] = data["etag"] + record = HiveRecord.from_raw(key, raw) + else: + record = HiveRecord(key, data=data) + + if tags: + record.tags = list(tags) if enabled is not None: record.enabled = enabled diff --git a/limacharlie/commands/event.py b/limacharlie/commands/event.py index dfdad90a..d6bfe3f5 100644 --- a/limacharlie/commands/event.py +++ b/limacharlie/commands/event.py @@ -279,6 +279,11 @@ def timeline(ctx: click.Context, sid: str, start: int, end: int) -> None: Use 'event schema --event-type ' to see the full field list for any given event type. +The schema is OBSERVED, not declared: it is built from events the org +has actually seen. On a fresh org with no telemetry yet, an empty +result is expected — it means no events have been observed, NOT a +misconfiguration. Types appear as sensors start reporting. + Examples: limacharlie event types limacharlie event types --platform windows @@ -290,6 +295,12 @@ def timeline(ctx: click.Context, sid: str, start: int, end: int) -> None: @click.option("--platform", default=None, help="Filter by platform (e.g., windows, linux, macos).") @pass_context def types(ctx: click.Context, platform: str | None) -> None: + """List observed event types and their schemas. + + The schema is observed (built from events the org has seen), not + declared, so an empty result on a fresh org just means no events + have been observed yet — it is not a misconfiguration. + """ org = _get_org(ctx) data = org.get_schemas(platform=platform) _output(ctx, data) diff --git a/limacharlie/commands/hive.py b/limacharlie/commands/hive.py index 7a7cf1f6..03594d48 100644 --- a/limacharlie/commands/hive.py +++ b/limacharlie/commands/hive.py @@ -20,6 +20,7 @@ from ..sdk.hive import Hive, HiveRecord from ..output import format_output, detect_output_format from ..discovery import register_explain +from ._time_validation import validate_epoch_seconds # --------------------------------------------------------------------------- @@ -231,6 +232,19 @@ def get(ctx, hive_name, key) -> None: The --enabled/--disabled flag, when given, overrides any value in the input file's usr_mtd.enabled. +Metadata flags (--tag-add, --tag-rm, --comment, --expiry) can be used +to manage usr_mtd without re-supplying the record data: + + * When NO data is supplied (no --input-file and no piped stdin), a + metadata-only update is performed: the current metadata is fetched, + --tag-add/--tag-rm are applied additively (existing tags are kept), + and --comment/--expiry/--enabled overwrite their fields. + * When data IS supplied, the same flags are applied as overrides on + top of the input's usr_mtd. + +--tag-add and --tag-rm are repeatable and additive (they never clobber +the existing tag set); applying both, a removal of an added tag wins. + Data payload examples per hive type: secret: {secret: "my-api-key"} yara: {rule: "rule MyRule { ... }"} @@ -243,10 +257,30 @@ def get(ctx, hive_name, key) -> None: limacharlie hive set --hive-name lookup --key my-lookup \\ --input-file record.yaml --enabled + + # Metadata-only: add/remove tags and set a comment without touching data. + limacharlie hive set --hive-name lookup --key my-lookup \\ + --tag-add prod --tag-add reviewed --tag-rm draft --comment "ready" """ register_explain("hive.set", _EXPLAIN_SET) +def _merge_tags(existing: list[str] | None, add: tuple[str, ...], rm: tuple[str, ...]) -> list[str]: + """Additively merge tag changes onto an existing tag list. + + Existing tags are preserved; --tag-add entries are appended (case- + insensitive dedup), then --tag-rm entries are removed. A tag both + added and removed in the same call is removed (removal wins). + """ + seen: dict[str, str] = {} + for tag in (existing or []) + list(add): + key = tag.lower() + if key not in seen: + seen[key] = tag + to_remove = {t.lower() for t in rm} + return [t for k, t in seen.items() if k not in to_remove] + + @group.command("set") @click.option("--hive-name", required=True, help="Hive name.") @click.option("--key", required=True, help="Record key.") @@ -255,23 +289,54 @@ def get(ctx, hive_name, key) -> None: "--enabled/--disabled", "enabled", default=None, help="Set usr_mtd.enabled on the record. Overrides any value in the input file. New records default to disabled if neither this flag nor usr_mtd.enabled is provided.", ) +@click.option("--tag-add", "tag_add", multiple=True, help="Tag to add (repeatable, additive; keeps existing tags).") +@click.option("--tag-rm", "tag_rm", multiple=True, help="Tag to remove (repeatable, additive; keeps other existing tags).") +@click.option("--comment", default=None, help="Set usr_mtd.comment on the record.") +@click.option("--expiry", default=None, type=int, help="Set usr_mtd.expiry (Unix epoch seconds, 0 = never).") @pass_context -def set_record(ctx, hive_name, key, input_file, enabled) -> None: +def set_record(ctx, hive_name, key, input_file, enabled, tag_add, tag_rm, comment, expiry) -> None: + if expiry is not None: + validate_epoch_seconds(expiry, "expiry") + data = _load_input(input_file) - if data is None: - click.echo( - "Error: No input data provided.\n" - "Suggestion: Use --input-file or pipe data to stdin.", - err=True, - ) - ctx.exit(4) - return + has_metadata_flags = bool(tag_add or tag_rm or comment is not None or expiry is not None or enabled is not None) org = _get_org(ctx) hive = Hive(org, hive_name) - record = _record_from_input(key, data) - if enabled is not None: - record.enabled = enabled + + if data is None: + if not has_metadata_flags: + click.echo( + "Error: No input data provided.\n" + "Suggestion: Use --input-file or pipe data to stdin, " + "or pass metadata flags (--tag-add/--tag-rm/--comment/--expiry/--enabled).", + err=True, + ) + ctx.exit(4) + return + # Metadata-only update: fetch current metadata so tags/comment/expiry + # that are not being changed are preserved (the API replaces usr_mtd + # wholesale), modeled on the enable/disable commands. + record = hive.get_metadata(key) + if tag_add or tag_rm: + record.tags = _merge_tags(record.tags, tag_add, tag_rm) + if comment is not None: + record.comment = comment + if expiry is not None: + record.expiry = expiry + if enabled is not None: + record.enabled = enabled + else: + record = _record_from_input(key, data) + if tag_add or tag_rm: + record.tags = _merge_tags(record.tags, tag_add, tag_rm) + if comment is not None: + record.comment = comment + if expiry is not None: + record.expiry = expiry + if enabled is not None: + record.enabled = enabled + result = hive.set(record) if not ctx.obj.quiet: click.echo(f"Record '{key}' set in hive '{hive_name}'.") @@ -376,6 +441,11 @@ def disable(ctx, hive_name, key) -> None: Validate a record against a hive's schema without saving it. Useful for checking whether record data is well-formed before pushing changes. The data format is the same as for the 'set' command. + +On success the command exits 0 and prints "Record is valid." to stderr. +When the API returns an empty/no-content response, the structured +formats (json/yaml) emit {"valid": true} so success is machine-readable. +On failure the command exits non-zero with the validation error. """ register_explain("hive.validate", _EXPLAIN_VALIDATE) @@ -399,7 +469,16 @@ def validate(ctx, hive_name, key, input_file) -> None: org = _get_org(ctx) hive = Hive(org, hive_name) record = _record_from_input(key, data) + # A failed validation raises (and exits non-zero) before reaching here, + # so getting this far is an explicit positive verdict. result = hive.validate(record) + # Keep stdout machine-stable: the human-readable confirmation goes to + # stderr, and when the API reports nothing we synthesize {"valid": true} + # so json/yaml consumers still get a definite success signal. + if not ctx.obj.quiet: + click.echo(f"Record '{key}' is valid.", err=True) + if not result: + result = {"valid": True} _output(ctx, result) @@ -417,13 +496,128 @@ def validate(ctx, hive_name, key, input_file) -> None: format (e.g., dr-general, dr-managed, dr-service, fp, extension_config) return an error indicating no schema is available. +By default the schema is rendered as a flat field table (name, type, +required, notes) with $ref/$defs resolved, so the accepted fields are +immediately readable. Use --output json to get the raw JSON Schema +(nothing is lost). + Example: limacharlie hive schema --hive-name secret - limacharlie hive schema --hive-name lookup + limacharlie hive schema --hive-name ai_agent + limacharlie hive schema --hive-name ai_agent --output json """ register_explain("hive.schema", _EXPLAIN_SCHEMA) +def _resolve_ref(ref: str, root: dict[str, Any]) -> dict[str, Any]: + """Resolve a local JSON-Schema $ref (e.g. '#/$defs/Foo') against root.""" + if not ref.startswith("#/"): + return {} + node: Any = root + for part in ref[2:].split("/"): + if isinstance(node, dict) and part in node: + node = node[part] + else: + return {} + return node if isinstance(node, dict) else {} + + +def _schema_type(node: dict[str, Any], root: dict[str, Any]) -> str: + """Best-effort human type string for a JSON-Schema node.""" + if "$ref" in node: + ref = node["$ref"] + name = ref.rsplit("/", 1)[-1] + return name or "object" + t = node.get("type") + if isinstance(t, list): + return "|".join(str(x) for x in t) + if t == "array": + items = node.get("items") + if isinstance(items, dict): + return f"array<{_schema_type(items, root)}>" + return "array" + if t: + return str(t) + if "enum" in node: + return "enum" + if "anyOf" in node or "oneOf" in node: + opts = node.get("anyOf") or node.get("oneOf") or [] + parts = [_schema_type(o, root) for o in opts if isinstance(o, dict)] + return "|".join(p for p in parts if p) or "any" + if "properties" in node: + return "object" + return "any" + + +def _flatten_schema(node: dict[str, Any], root: dict[str, Any], required: set[str] | None = None, + prefix: str = "", seen: set[int] | None = None) -> list[dict[str, str]]: + """Flatten a JSON-Schema object into name/type/required/notes rows.""" + if seen is None: + seen = set() + if required is None: + required = set() + + # Resolve a top-level $ref before descending. + if "$ref" in node: + node = _resolve_ref(node["$ref"], root) + + if id(node) in seen: + return [] + seen = seen | {id(node)} + + rows: list[dict[str, str]] = [] + props = node.get("properties") + if not isinstance(props, dict): + return rows + req = set(node.get("required", [])) + + for name, sub in props.items(): + if not isinstance(sub, dict): + continue + field = f"{prefix}{name}" + resolved = _resolve_ref(sub["$ref"], root) if "$ref" in sub else sub + type_str = _schema_type(sub, root) + + notes_parts: list[str] = [] + if "enum" in resolved: + vals = resolved["enum"] + shown = ", ".join(str(v) for v in vals[:8]) + if len(vals) > 8: + shown += ", ..." + notes_parts.append(f"enum: {shown}") + desc = resolved.get("description") or sub.get("description") + if desc: + notes_parts.append(str(desc).strip().splitlines()[0]) + + rows.append({ + "field": field, + "type": type_str, + "required": "yes" if name in req else "", + "notes": "; ".join(notes_parts), + }) + + # Recurse into nested objects (resolved object with properties). + if isinstance(resolved.get("properties"), dict): + rows.extend(_flatten_schema(resolved, root, prefix=f"{field}.", seen=seen)) + + return rows + + +def _flatten_hive_schema(data: Any) -> list[dict[str, str]] | None: + """Turn a hive get_schema() response into flat field rows. + + Returns None when the response has no resolvable object schema (so the + caller can fall back to printing the raw structure). + """ + if not isinstance(data, dict): + return None + root = data.get("schema", data) + if not isinstance(root, dict): + return None + rows = _flatten_schema(root, root) + return rows or None + + @group.command() @click.option("--hive-name", required=True, help="Hive name (e.g., secret, lookup, yara).") @pass_context @@ -431,6 +625,15 @@ def schema(ctx, hive_name) -> None: org = _get_org(ctx) hive = Hive(org, hive_name) data = hive.get_schema() + + fmt = ctx.obj.output_format or detect_output_format() + # json/jsonl/yaml/toon/csv consumers get the raw JSON Schema untouched. + # The default human view (table) is a flattened field listing. + if fmt == "table": + rows = _flatten_hive_schema(data) + if rows is not None: + _output(ctx, rows) + return _output(ctx, data) diff --git a/limacharlie/commands/secret.py b/limacharlie/commands/secret.py index 19235f8f..baad22f6 100644 --- a/limacharlie/commands/secret.py +++ b/limacharlie/commands/secret.py @@ -5,7 +5,7 @@ from ._hive_shortcut import make_hive_group from ..discovery import register_explain -group = make_hive_group("secret", "secret", "secret") +group = make_hive_group("secret", "secret", "secret", value_key="secret") # Override the generic hive explains with secret-specific documentation. diff --git a/limacharlie/output.py b/limacharlie/output.py index 021a4425..71bf111b 100644 --- a/limacharlie/output.py +++ b/limacharlie/output.py @@ -36,6 +36,9 @@ # Module-level flags set by the CLI before any command runs. _wide_mode: bool = False _filter_expr: str | None = None +_fields: list[str] | None = None +_sort_by: str | None = None +_reverse: bool = False @@ -51,6 +54,24 @@ def set_filter_expr(expr: str | None) -> None: _filter_expr = expr +def set_fields(fields: list[str] | None) -> None: + """Set the list of fields to project, applied to all output.""" + global _fields + _fields = fields + + +def set_sort_by(sort_by: str | None) -> None: + """Set the field name to sort list output by, applied to all output.""" + global _sort_by + _sort_by = sort_by + + +def set_reverse(reverse: bool) -> None: + """Set whether sorted list output is reversed, applied to all output.""" + global _reverse + _reverse = reverse + + def detect_output_format() -> str: """Auto-detect the output format based on whether stdout is a TTY. @@ -87,9 +108,18 @@ def format_output( if fmt is None: fmt = detect_output_format() - # Fall back to module-level filter if none passed explicitly. + # Fall back to module-level projection state if none passed explicitly. + # Mirrors the set_filter_expr/_filter_expr mechanism so the global + # --filter/--fields/--sort-by/--reverse flags flow into every command's + # output without each command needing to thread them through. if filter_expr is None: filter_expr = _filter_expr + if fields is None: + fields = _fields + if sort_by is None: + sort_by = _sort_by + if not reverse: + reverse = _reverse # Apply jmespath filter if filter_expr and data is not None: diff --git a/limacharlie/sdk/ai.py b/limacharlie/sdk/ai.py index bb795561..a5855070 100644 --- a/limacharlie/sdk/ai.py +++ b/limacharlie/sdk/ai.py @@ -97,7 +97,11 @@ def start_session(self, definition_name: str, prompt: str | None = None, resolved automatically before the request is sent. Args: - definition_name: Name of the ai_agent hive record to use as template. + definition_name: Name of the ai_agent hive record to use as + template. Accepts either a bare record key (``my-agent``) + or the ``hive://ai_agent/`` URI form used by the + D&R ``start ai agent`` action; the prefix is stripped and + both resolve to the same record. prompt: Replace the prompt from the definition. name: Replace the session name. idempotent_key: Deduplication key. @@ -131,6 +135,13 @@ def start_session(self, definition_name: str, prompt: str | None = None, """ from .hive import Hive + # Accept both a bare record key and the hive://ai_agent/ URI + # form (the form the D&R 'start ai agent' action references), so the + # same identifier works from a rule and from the CLI/SDK. + _HIVE_PREFIX = "hive://ai_agent/" + if definition_name.startswith(_HIVE_PREFIX): + definition_name = definition_name[len(_HIVE_PREFIX):] + # Fetch the ai_agent definition; treat its fields as the template # that overrides stack on top of. record = Hive(self._org, "ai_agent").get(definition_name) diff --git a/tests/unit/test_cli_ergonomics.py b/tests/unit/test_cli_ergonomics.py new file mode 100644 index 00000000..b19df74e --- /dev/null +++ b/tests/unit/test_cli_ergonomics.py @@ -0,0 +1,497 @@ +"""Tests for CLI discoverability/ergonomics improvements. + +Covers the global projection flags (--fields/--sort-by/--reverse), the +api-key list --name filter, hive validate verdict, hive set metadata +flags, secret set --value / tag subcommand, dr set --detect/--respond, +adapter list-types, and hive schema flat rendering. +""" + +import json +from unittest.mock import patch, MagicMock + +from click.testing import CliRunner + +from limacharlie.cli import cli +from limacharlie import output as output_mod +from limacharlie.sdk.hive import HiveRecord + + +def _extract_json(text): + """Return the JSON document embedded in mixed stdout/stderr output. + + This CliRunner version merges stderr into output, so a stderr status + line may precede the JSON body; slice from the first '{' or '['. + """ + for i, ch in enumerate(text): + if ch in "{[": + return text[i:] + return text + + +# --------------------------------------------------------------------------- +# Global projection state (module-level setters mirror set_filter_expr) +# --------------------------------------------------------------------------- + +class TestProjectionState: + def teardown_method(self): + output_mod.set_fields(None) + output_mod.set_sort_by(None) + output_mod.set_reverse(False) + + def test_fields_module_level_fallback(self): + output_mod.set_fields(["a", "c"]) + out = output_mod.format_output([{"a": 1, "b": 2, "c": 3}], "json") + assert json.loads(out) == [{"a": 1, "c": 3}] + + def test_sort_by_and_reverse(self): + output_mod.set_sort_by("a") + output_mod.set_reverse(True) + data = [{"a": 1}, {"a": 3}, {"a": 2}] + out = output_mod.format_output(data, "json") + assert [r["a"] for r in json.loads(out)] == [3, 2, 1] + + def test_explicit_arg_overrides_module_level(self): + output_mod.set_fields(["a"]) + # Explicitly passing fields should win over module-level state. + out = output_mod.format_output([{"a": 1, "b": 2}], "json", fields=["b"]) + assert json.loads(out) == [{"b": 2}] + + +class TestGlobalProjectionFlags: + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_fields_flag_projects_output(self, mock_hive_cls, _org, _client): + rec = MagicMock() + rec.to_dict.return_value = {"data": {"x": 1}, "usr_mtd": {}, "sys_mtd": {}} + mock_hive = MagicMock() + mock_hive.list.return_value = {"r1": rec} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + # The list output is a dict keyed by record name; --fields on a dict + # narrows the keys. Use --fields to keep only "r1". + result = runner.invoke(cli, ["--output", "json", "--fields", "r1", "dr", "list"]) + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + assert set(parsed.keys()) == {"r1"} + # Reset module-level state so later tests are unaffected. + output_mod.set_fields(None) + + +# --------------------------------------------------------------------------- +# api-key list --name +# --------------------------------------------------------------------------- + +class TestApiKeyListName: + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_name_filter_keeps_keyed_shape(self, mock_org_cls, _client): + mock_org = MagicMock() + mock_org.get_api_keys.return_value = { + "hashA": {"name": "ci-key", "perms": ["dr.list"]}, + "hashB": {"name": "readonly", "perms": ["org.get"]}, + } + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "api-key", "list", "--name", "ci-key"]) + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + # Still keyed by key-hash (object), narrowed to the match. + assert parsed == {"hashA": {"name": "ci-key", "perms": ["dr.list"]}} + + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_no_match_returns_empty_object(self, mock_org_cls, _client): + mock_org = MagicMock() + mock_org.get_api_keys.return_value = {"hashA": {"name": "ci-key"}} + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "api-key", "list", "--name", "nope"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == {} + + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_without_name_unchanged(self, mock_org_cls, _client): + raw = {"hashA": {"name": "ci-key"}, "hashB": {"name": "readonly"}} + mock_org = MagicMock() + mock_org.get_api_keys.return_value = raw + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "api-key", "list"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == raw + + +# --------------------------------------------------------------------------- +# hive validate verdict +# --------------------------------------------------------------------------- + +class TestHiveValidateVerdict: + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_empty_response_emits_valid_true(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.validate.return_value = {} # API said nothing -> success + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke( + cli, + ["--output", "json", "hive", "validate", "--hive-name", "lookup", "--key", "k"], + input='{"data": {"x": 1}}\n', + ) + assert result.exit_code == 0, result.output + # Human confirmation goes to stderr (mixed into output here); the + # machine-stable JSON verdict is the {"valid": true} object. + assert "is valid." in result.output + assert json.loads(_extract_json(result.output)) == {"valid": True} + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_nonempty_response_preserved(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.validate.return_value = {"detail": "ok", "extra": 1} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke( + cli, + ["--output", "json", "hive", "validate", "--hive-name", "lookup", "--key", "k"], + input='{"data": {"x": 1}}\n', + ) + assert result.exit_code == 0, result.output + assert json.loads(_extract_json(result.output)) == {"detail": "ok", "extra": 1} + + +# --------------------------------------------------------------------------- +# hive set metadata flags +# --------------------------------------------------------------------------- + +class TestHiveSetMetadataFlags: + @staticmethod + def _existing(name="k"): + return HiveRecord(name=name, data=None, enabled=True, + tags=["keep", "draft"], expiry=10, comment="old") + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_metadata_only_additive_tags(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.get_metadata.return_value = self._existing() + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, [ + "hive", "set", "--hive-name", "lookup", "--key", "k", + "--tag-add", "prod", "--tag-rm", "draft", "--comment", "new note", + ]) + assert result.exit_code == 0, result.output + # Metadata-only path fetches current metadata, no data load. + mock_hive.get_metadata.assert_called_once_with("k") + record = mock_hive.set.call_args[0][0] + # 'keep' preserved, 'draft' removed, 'prod' added. + assert set(record.tags) == {"keep", "prod"} + assert record.comment == "new note" + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_no_data_no_flags_errors(self, mock_hive_cls, _org, _client): + mock_hive_cls.return_value = MagicMock() + runner = CliRunner() + # No stdin, no input-file, no metadata flags -> usage error exit 4. + result = runner.invoke(cli, ["hive", "set", "--hive-name", "lookup", "--key", "k"]) + assert result.exit_code == 4 + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_data_with_metadata_overrides(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, [ + "hive", "set", "--hive-name", "lookup", "--key", "k", + "--tag-add", "prod", "--comment", "c", + ], input='{"data": {"x": 1}}\n') + assert result.exit_code == 0, result.output + # Data was supplied, so no get_metadata fetch. + mock_hive.get_metadata.assert_not_called() + record = mock_hive.set.call_args[0][0] + assert record.tags == ["prod"] + assert record.comment == "c" + assert record.data == {"x": 1} + + +# --------------------------------------------------------------------------- +# secret set --value and tag subcommand +# --------------------------------------------------------------------------- + +class TestSecretValueAndTag: + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_set_value_wraps_secret(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, [ + "secret", "set", "--key", "my-secret", "--value", "s3cr3t", + "--tag", "prod", "--comment", "note", + ]) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.data == {"secret": "s3cr3t"} + assert record.tags == ["prod"] + assert record.comment == "note" + + def test_value_not_offered_for_structured_hive(self): + # A hive without a single scalar value field (no value_key) must NOT + # expose --value — the secret-style {data: {secret: ...}} wrapper would + # be wrong for its data shape. Click rejects the unknown option. + runner = CliRunner() + result = runner.invoke(cli, ["lookup", "set", "--key", "k", "--value", "x"]) + assert result.exit_code != 0 + assert "no such option" in result.output.lower() or "No such option" in result.output + + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_tag_works_for_structured_hive(self, mock_hive_cls, _org, _client, tmp_path): + # --tag/--comment are generic metadata and remain available on every + # hive shortcut, even those without --value. + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + f = tmp_path / "l.yaml" + f.write_text("data:\n a: 1\n") + runner = CliRunner() + result = runner.invoke(cli, [ + "lookup", "set", "--key", "k", "--input-file", str(f), "--tag", "prod", + ]) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.tags == ["prod"] + + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_tag_add_merges(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.get_metadata.return_value = HiveRecord(name="my-secret", tags=["a"]) + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, ["secret", "tag", "add", "--key", "my-secret", "-t", "b"]) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert set(record.tags) == {"a", "b"} + + @patch("limacharlie.commands._hive_shortcut.Client") + @patch("limacharlie.commands._hive_shortcut.Organization") + @patch("limacharlie.commands._hive_shortcut.Hive") + def test_tag_rm_keeps_others(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.get_metadata.return_value = HiveRecord(name="my-secret", tags=["a", "b"]) + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, ["secret", "tag", "rm", "--key", "my-secret", "-t", "a"]) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.tags == ["b"] + + +# --------------------------------------------------------------------------- +# dr set --detect/--respond/--tag +# --------------------------------------------------------------------------- + +class TestDrSetComponents: + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_detect_respond_assembles_rule(self, mock_hive_cls, _org, _client, tmp_path): + detect = tmp_path / "d.yaml" + respond = tmp_path / "r.yaml" + detect.write_text("op: is\npath: event/X\nvalue: y\n") + respond.write_text("- action: report\n name: n\n") + + mock_hive = MagicMock() + mock_hive.set.return_value = {"etag": "new"} + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, [ + "dr", "set", "--key", "r", "--detect", str(detect), + "--respond", str(respond), "--tag", "prod", "--enabled", + ]) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.data == { + "detect": {"op": "is", "path": "event/X", "value": "y"}, + "respond": [{"action": "report", "name": "n"}], + } + assert record.tags == ["prod"] + assert record.enabled is True + + @patch("limacharlie.commands.dr.Client") + @patch("limacharlie.commands.dr.Organization") + @patch("limacharlie.commands.dr.Hive") + def test_detect_with_input_file_errors(self, mock_hive_cls, _org, _client, tmp_path): + detect = tmp_path / "d.yaml" + respond = tmp_path / "r.yaml" + infile = tmp_path / "in.yaml" + for p in (detect, respond, infile): + p.write_text("op: is\n") + mock_hive_cls.return_value = MagicMock() + + runner = CliRunner() + result = runner.invoke(cli, [ + "dr", "set", "--key", "r", "--detect", str(detect), + "--respond", str(respond), "--input-file", str(infile), + ]) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + +# --------------------------------------------------------------------------- +# adapter list-types +# --------------------------------------------------------------------------- + +class TestAdapterListTypes: + def test_fallback_includes_threatlocker(self): + from limacharlie.commands._adapter_types import adapter_types + # Passing an org whose schema fetch raises forces the fallback path. + rows = adapter_types(None) + types = {r["type"] for r in rows} + assert "threatlocker" in types + assert "webhook" in types + + def test_derived_from_ref_rooted_schema(self): + # Mirrors the real reflected shape: a {"schema": {...}} wrapper whose + # root $refs into $defs/; type names are that record's + # properties (minus the discriminator), NOT the bare $defs keys. + from limacharlie.commands._adapter_types import _types_from_schema + schema = {"schema": { + "$ref": "#/$defs/CloudSensorRecord", + "$defs": { + "CloudSensorRecord": { + "properties": { + "s3": {}, "office365": {}, "threatlocker": {}, "sensor_type": {}, + }, + }, + "ClientOptions": {}, "AckBufferOptions": {}, # helper structs + }, + }} + names = _types_from_schema(schema) + assert set(names) == {"s3", "office365", "threatlocker"} + # The discriminator and helper-struct $defs names must NOT leak in. + assert "sensor_type" not in names + assert "ClientOptions" not in names and "AckBufferOptions" not in names + + def test_derived_from_inline_properties_schema(self): + # Fallback shape: no $ref, properties inline on the root. + from limacharlie.commands._adapter_types import _types_from_schema + schema = {"schema": { + "properties": {"s3": {}, "syslog": {}, "sensor_type": {}, "client_options": {}}, + }} + names = _types_from_schema(schema) + assert "sensor_type" not in names and "client_options" not in names + assert "s3" in names and "syslog" in names + + def test_per_hive_schema_selection(self): + # cloud-adapter and external-adapter must enumerate their OWN hive. + from limacharlie.commands import _adapter_types as at + captured = {} + + class _FakeHive: + def __init__(self, org, hive_name): + captured["hive_name"] = hive_name + def get_schema(self): + return {"schema": {"$ref": "#/$defs/R", "$defs": {"R": {"properties": {"s3": {}}}}}} + + with patch.object(at, "Hive", _FakeHive): + at.adapter_types(None, "external_adapter") + assert captured["hive_name"] == "external_adapter" + + @patch("limacharlie.commands._adapter_types.Hive") + @patch("limacharlie.commands._adapter_types.Client", create=True) + def test_cli_list_types(self, _client, mock_hive_cls): + # When the schema fetch fails, the command still returns the curated list. + mock_hive = MagicMock() + mock_hive.get_schema.side_effect = Exception("no schema") + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + with patch("limacharlie.client.Client"): + result = runner.invoke(cli, ["--output", "json", "cloud-adapter", "list-types"]) + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + assert any(r["type"] == "threatlocker" for r in parsed) + + +# --------------------------------------------------------------------------- +# hive schema flat rendering +# --------------------------------------------------------------------------- + +class TestHiveSchemaFlat: + _SCHEMA = {"schema": { + "$ref": "#/$defs/Rec", + "$defs": { + "Rec": { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": {"type": "string"}, + "model": {"type": "string", "enum": ["a", "b"]}, + "nested": {"$ref": "#/$defs/Sub"}, + }, + }, + "Sub": {"type": "object", "properties": {"x": {"type": "integer"}}}, + }, + }} + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_default_table_is_flat(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + # Force table (the default human view) explicitly. + result = runner.invoke(cli, ["--output", "table", "hive", "schema", "--hive-name", "ai_agent"]) + assert result.exit_code == 0, result.output + assert "prompt" in result.output + assert "nested.x" in result.output # $ref resolved + flattened + + @patch("limacharlie.commands.hive.Client") + @patch("limacharlie.commands.hive.Organization") + @patch("limacharlie.commands.hive.Hive") + def test_json_keeps_raw(self, mock_hive_cls, _org, _client): + mock_hive = MagicMock() + mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "hive", "schema", "--hive-name", "ai_agent"]) + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + # Raw JSON Schema preserved, nothing lost. + assert parsed == self._SCHEMA diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 8020d077..3315d909 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -125,7 +125,7 @@ "ai-memory": frozenset({ "delete", "delete-record", "get", "list", "list-records", "set", }), - "ai-skill": frozenset({"delete", "disable", "enable", "get", "list", "set"}), + "ai-skill": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "api-key": frozenset({"create", "delete", "list"}), "arl": frozenset({"get"}), "artifact": frozenset({"download", "list", "upload"}), @@ -142,7 +142,7 @@ "entity", "export", "get", "list", "merge", "orgs", "report", "tag", "telemetry", "update", "update-note", }), - "cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "set"}), + "cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "set", "tag"}), "detection": frozenset({"get", "list"}), "download": frozenset({"adapter", "list", "sensor"}), "dr": frozenset({ @@ -160,8 +160,8 @@ "list", "list-available", "rekey", "request", "schema", "subscribe", "unsubscribe", }), - "external-adapter": frozenset({"delete", "disable", "enable", "get", "list", "set"}), - "fp": frozenset({"delete", "disable", "enable", "get", "list", "set"}), + "external-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "set", "tag"}), + "fp": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "group": frozenset({ "create", "delete", "get", "list", "logs", "member-add", "member-remove", "org-add", "org-remove", @@ -178,8 +178,8 @@ "ioc": frozenset({"batch-enrich", "batch-search", "enrich", "hosts", "search"}), "job": frozenset({"delete", "get", "list", "wait"}), "logging": frozenset({"create", "delete", "get", "list"}), - "lookup": frozenset({"delete", "disable", "enable", "get", "list", "set"}), - "note": frozenset({"delete", "disable", "enable", "get", "list", "set"}), + "lookup": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), + "note": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "org": frozenset({ "check-name", "config-get", "config-set", "create", "delete", "dismiss-error", "errors", "info", "list", "mitre", "quota", @@ -187,7 +187,7 @@ }), "output": frozenset({"create", "delete", "list"}), "payload": frozenset({"delete", "download", "list", "upload"}), - "playbook": frozenset({"delete", "disable", "enable", "get", "list", "set"}), + "playbook": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "replay": frozenset({"run"}), "schema": frozenset({"get", "list", "reset"}), "search": frozenset({ @@ -195,12 +195,12 @@ "saved-create", "saved-delete", "saved-get", "saved-list", "saved-run", "validate", }), - "secret": frozenset({"delete", "disable", "enable", "get", "list", "set"}), + "secret": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "sensor": frozenset({ "delete", "dump", "export", "get", "list", "set-version", "sweep", "upgrade", "wait-online", }), - "sop": frozenset({"delete", "disable", "enable", "get", "list", "set"}), + "sop": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "spotcheck": frozenset({"run"}), "stream": frozenset({"audit", "detections", "events", "firehose"}), "sync": frozenset({"pull", "push"}), @@ -223,8 +223,8 @@ # Global options that must be present on the top-level cli group. EXPECTED_GLOBAL_OPTIONS = frozenset({ "oid", "output_format", "debug", "debug_full", "debug_curl", - "quiet", "wide", "no_warnings", "filter_expr", "profile", - "environment", + "quiet", "wide", "no_warnings", "filter_expr", "fields", + "sort_by", "reverse", "profile", "environment", }) diff --git a/tests/unit/test_dataclasses.py b/tests/unit/test_dataclasses.py index 50e6aab1..a846bc67 100644 --- a/tests/unit/test_dataclasses.py +++ b/tests/unit/test_dataclasses.py @@ -22,7 +22,7 @@ def test_default_values(self): def test_field_names(self): names = [f.name for f in fields(LimaCharlieContext)] - assert names == ["oid", "output_format", "debug", "debug_full", "debug_curl", "quiet", "wide", "no_warnings", "filter_expr", "profile", "environment"] + assert names == ["oid", "output_format", "debug", "debug_full", "debug_curl", "quiet", "wide", "no_warnings", "filter_expr", "fields", "sort_by", "reverse", "profile", "environment"] def test_custom_values(self): ctx = LimaCharlieContext(oid="abc", output_format="json", debug=True, quiet=True) diff --git a/tests/unit/test_sdk_ai_sessions.py b/tests/unit/test_sdk_ai_sessions.py index f4cbee9e..cf0aa17d 100644 --- a/tests/unit/test_sdk_ai_sessions.py +++ b/tests/unit/test_sdk_ai_sessions.py @@ -1260,3 +1260,34 @@ def test_history_raw_keeps_everything(self): ]) assert result.exit_code == 0, result.output assert "model_set" in result.output + + +class TestStartSessionHiveUri: + """start_session accepts both a bare key and the hive://ai_agent/ + URI form (the form the D&R 'start ai agent' action references).""" + + def test_strips_hive_ai_agent_prefix(self, ai, mock_org): + defn = {"prompt": "Go", "anthropic_secret": "sk", "lc_api_key_secret": "lc"} + with patch("limacharlie.sdk.hive.Hive") as MockHive: + hive_instance = MagicMock() + MockHive.return_value = hive_instance + hive_instance.get.return_value = _make_hive_record(defn) + mock_org.client.request.return_value = {"session_id": "s", "status": "pending"} + + ai.start_session("hive://ai_agent/my-agent") + + # The URI prefix must be stripped before the hive lookup so it + # resolves to the same record as the bare key. + hive_instance.get.assert_called_with("my-agent") + + def test_bare_key_unchanged(self, ai, mock_org): + defn = {"prompt": "Go", "anthropic_secret": "sk", "lc_api_key_secret": "lc"} + with patch("limacharlie.sdk.hive.Hive") as MockHive: + hive_instance = MagicMock() + MockHive.return_value = hive_instance + hive_instance.get.return_value = _make_hive_record(defn) + mock_org.client.request.return_value = {"session_id": "s", "status": "pending"} + + ai.start_session("my-agent") + + hive_instance.get.assert_called_with("my-agent") From 593772df2eb7d46f81109051e9347aebf4982680 Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 30 May 2026 16:47:38 -0700 Subject: [PATCH 10/14] ai: resolve ai-sessions URL from per-org service URLs instead of hardcoding (#300) The AI session CLI/SDK commands (start session, chat, auth claude, list/ get/terminate, attach, usage) hardcoded https://ai.limacharlie.io as the ai-sessions host. The orgs/{oid}/url endpoint already returns a per-org `ai` entry (lc.SiteURLs.Ai) whose value depends on the deployment -- e.g. staging orgs return ai-staging.limacharlie.io -- so the hardcode pointed staging orgs at the production service. Resolve the host lazily from Organization.get_urls()["ai"] (cached, same pattern as Search), prefixing https:// when needed and falling back to the well-known production host only when the entry is absent. SessionAttachment resolves the same way when no explicit base_url override is given. Co-authored-by: Claude Opus 4.8 (1M context) --- limacharlie/sdk/ai.py | 35 +++++++++++++++--- limacharlie/sdk/ai_session.py | 10 +++-- tests/unit/test_ai_session_attach.py | 55 ++++++++++++++++++++++++++++ tests/unit/test_sdk_ai_sessions.py | 3 ++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/limacharlie/sdk/ai.py b/limacharlie/sdk/ai.py index a5855070..90eea67b 100644 --- a/limacharlie/sdk/ai.py +++ b/limacharlie/sdk/ai.py @@ -9,6 +9,11 @@ from .organization import Organization _HIVE_SECRET_PREFIX = "hive://secret/" + +# Fallback only. The real ai-sessions host is resolved per-org from the +# ``ai`` entry of ``orgs/{oid}/url`` (see :meth:`AI._get_ai_url`) because +# it differs by deployment -- e.g. staging orgs return +# ``ai-staging.limacharlie.io``. _AI_SESSIONS_URL = "https://ai.limacharlie.io" @@ -17,12 +22,32 @@ class AI: def __init__(self, org: Organization) -> None: self._org = org + self._ai_url: str | None = None @property def client(self) -> Any: """The underlying API client.""" return self._org.client + def _get_ai_url(self) -> str: + """Resolve the ai-sessions base URL for this org. + + The per-org service URLs (``orgs/{oid}/url``) carry an ``ai`` + entry whose value depends on the deployment the org lives in + (production orgs resolve to ``ai.limacharlie.io``, staging orgs + to ``ai-staging.limacharlie.io``, etc.), so the host must not be + hardcoded. Mirrors the lazy, cached resolution used by + :class:`~limacharlie.sdk.search.Search`. Falls back to + :data:`_AI_SESSIONS_URL` only when the org URL set has no ``ai`` + entry. + """ + if self._ai_url is None: + ai_url = self._org.get_urls().get("ai", "") + if ai_url and not ai_url.startswith(("http://", "https://")): + ai_url = "https://" + ai_url + self._ai_url = ai_url or _AI_SESSIONS_URL + return self._ai_url + def _resolve_secret(self, value: str) -> str: """Resolve a value that may be a hive://secret/ reference.""" if not value or not value.startswith(_HIVE_SECRET_PREFIX): @@ -248,7 +273,7 @@ def start_session(self, definition_name: str, prompt: str | None = None, raw_body=json.dumps(request_body).encode(), content_type="application/json", is_no_auth=True, - alt_root=_AI_SESSIONS_URL, + alt_root=self._get_ai_url(), extra_headers=extra, ) @@ -256,7 +281,7 @@ def start_session(self, definition_name: str, prompt: str | None = None, "POST", "v1/api/sessions", raw_body=json.dumps(request_body).encode(), content_type="application/json", - alt_root=_AI_SESSIONS_URL, + alt_root=self._get_ai_url(), extra_headers=extra, ) @@ -364,13 +389,13 @@ def _org_request(self, verb: str, path: str, verb, path, query_params=query_params, is_no_auth=True, - alt_root=_AI_SESSIONS_URL, + alt_root=self._get_ai_url(), extra_headers=extra, ) return self.client.request( verb, path, query_params=query_params, - alt_root=_AI_SESSIONS_URL, + alt_root=self._get_ai_url(), extra_headers=extra, ) @@ -523,7 +548,7 @@ def _user_request(self, verb: str, path: str, ``claims.UID()`` alone; sending X-LC-OID is unnecessary here and the routes don't accept raw API keys. """ - kwargs: dict[str, Any] = {"alt_root": _AI_SESSIONS_URL} + kwargs: dict[str, Any] = {"alt_root": self._get_ai_url()} if raw_body is not None: kwargs["raw_body"] = raw_body kwargs["content_type"] = "application/json" diff --git a/limacharlie/sdk/ai_session.py b/limacharlie/sdk/ai_session.py index d8d3413b..0212dc0e 100644 --- a/limacharlie/sdk/ai_session.py +++ b/limacharlie/sdk/ai_session.py @@ -85,12 +85,13 @@ class SessionAttachment: def __init__(self, ai: "AI", session_id: str, *, read_only: bool = False, base_url: str | None = None) -> None: - from .ai import _AI_SESSIONS_URL - self._ai = ai self._session_id = session_id self._read_only = read_only - self._base_url = base_url or _AI_SESSIONS_URL + # When no explicit override is supplied, resolve the per-org + # ai-sessions host lazily (in :meth:`url`) so we honour the org's + # deployment (prod vs staging) rather than a hardcoded host. + self._base_url = base_url self._ws: Any = None self._heartbeat_task: asyncio.Task | None = None @@ -107,7 +108,8 @@ def read_only(self) -> bool: return self._read_only def url(self) -> str: - return _derive_ws_url(self._base_url, self._session_id, self._read_only) + base_url = self._base_url or self._ai._get_ai_url() + return _derive_ws_url(base_url, self._session_id, self._read_only) # ------------------------------------------------------------------ # Connection lifecycle diff --git a/tests/unit/test_ai_session_attach.py b/tests/unit/test_ai_session_attach.py index 1d9d58cf..b8090420 100644 --- a/tests/unit/test_ai_session_attach.py +++ b/tests/unit/test_ai_session_attach.py @@ -238,6 +238,61 @@ def test_url_read_only_uses_org_endpoint(self): ) assert att.url() == "wss://example.test/v1/ws/org/sessions/sid-42" + def test_url_resolves_from_org_ai_url_when_no_override(self): + """With no explicit base_url, the attach URL is derived from the + org's resolved ai-sessions host (e.g. the staging deployment) + rather than a hardcoded production host. + """ + ai_stub = SimpleNamespace( + _get_ai_url=lambda: "https://ai-staging.limacharlie.io", + ) + att = SessionAttachment(ai_stub, "sid-42") + assert att.url() == "wss://ai-staging.limacharlie.io/v1/ws/sessions/sid-42" + + +# --------------------------------------------------------------------------- +# Per-org ai-sessions URL resolution +# --------------------------------------------------------------------------- + +class TestAiUrlResolution: + """``AI._get_ai_url`` must read the per-org ``ai`` service URL from + ``orgs/{oid}/url`` so staging/region deployments are honoured, and + must only fall back to the hardcoded production host when that entry + is absent. + """ + + def _make_ai(self, urls: dict): + from limacharlie.sdk.ai import AI + + calls = {"n": 0} + + def get_urls(): + calls["n"] += 1 + return urls + + org_stub = SimpleNamespace(get_urls=get_urls) + return AI(org_stub), calls + + def test_uses_ai_entry_and_adds_https(self): + ai, _ = self._make_ai({"ai": "ai-staging.limacharlie.io"}) + assert ai._get_ai_url() == "https://ai-staging.limacharlie.io" + + def test_preserves_existing_scheme(self): + ai, _ = self._make_ai({"ai": "http://localhost:8080"}) + assert ai._get_ai_url() == "http://localhost:8080" + + def test_falls_back_when_ai_entry_missing(self): + from limacharlie.sdk.ai import _AI_SESSIONS_URL + + ai, _ = self._make_ai({"search": "x.replay-search.limacharlie.io"}) + assert ai._get_ai_url() == _AI_SESSIONS_URL + + def test_result_is_cached(self): + ai, calls = self._make_ai({"ai": "ai.limacharlie.io"}) + ai._get_ai_url() + ai._get_ai_url() + assert calls["n"] == 1 + # --------------------------------------------------------------------------- # Pretty-printer diff --git a/tests/unit/test_sdk_ai_sessions.py b/tests/unit/test_sdk_ai_sessions.py index cf0aa17d..a8de7f63 100644 --- a/tests/unit/test_sdk_ai_sessions.py +++ b/tests/unit/test_sdk_ai_sessions.py @@ -23,6 +23,9 @@ def mock_org(): # MagicMock would auto-vivify a truthy _uid and leak an X-LC-UID # header into every request.) org.client._uid = None + # ai-sessions host is resolved per-org from orgs/{oid}/url; return the + # production host so the resolved alt_root matches _AI_SESSIONS_URL. + org.get_urls.return_value = {"ai": "ai.limacharlie.io"} return org From 05c81caabfbcef87a47eec02679a57abbf4198ae Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sat, 30 May 2026 20:00:43 -0700 Subject: [PATCH 11/14] cli: adapter schema --type / sensors --key + api-key create --store-secret (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cli: add adapter schema --type / sensors --key and api-key create --store-secret Three FDE-acceleration additions found while profiling AI agent build runs: - cloud-adapter/external-adapter `schema --type `: resolve one adapter type's config sub-schema from the hive JSON-Schema $defs and render the flat field table (reusing hive's _flatten_schema). Shows where each field lives (e.g. hostname under client_options), preventing "unknown field" rejections from hand-built records. Unknown type errors with the valid type list. - cloud-adapter/external-adapter `sensors --key `: return the live sensor(s) an adapter produced by matching the record's installation_key (iid) against the sensor iid — the reliable way to get an adapter's SID without decoding the installation key. Falls back to hostname match; reports a clear "not registered yet" when the adapter hasn't delivered events. - `api-key create --store-secret ` (+ --store-secret-tag): atomically mint the key and write its value into the secret hive, collapsing the mint -> capture -> store -> reference chain into one call (the value never has to transit an intermediate file). Co-Authored-By: Claude Opus 4.8 (1M context) * cli: address review — quiet/json on adapter schema, server-side sensor selector, etag-safe store-secret - adapter `schema --type` now respects --quiet (consistent with sensors/list-types) and its --output json carries the root $defs so nested $refs stay resolvable. - adapter `sensors --key` filters server-side via a sensor selector (iid == "…" / hostname == "…") instead of paging and scanning every sensor. - api-key create --store-secret reads any existing secret's etag and writes a conditional update (no silent lost-update; reports create vs overwrite), and on the no-value edge still surfaces the created key before failing. Tests updated for the selector path and added: schema CLI table/unknown-type, store-secret new-vs-existing(etag). 990 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- limacharlie/commands/_adapter_types.py | 171 ++++++++++++++++ limacharlie/commands/adapter.py | 4 +- limacharlie/commands/api_key.py | 44 +++- limacharlie/commands/cloud_sensor.py | 4 +- tests/unit/test_cli_ergonomics.py | 192 ++++++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 4 +- 6 files changed, 414 insertions(+), 5 deletions(-) diff --git a/limacharlie/commands/_adapter_types.py b/limacharlie/commands/_adapter_types.py index a5063343..3ccb50c0 100644 --- a/limacharlie/commands/_adapter_types.py +++ b/limacharlie/commands/_adapter_types.py @@ -9,6 +9,7 @@ from __future__ import annotations +import re from typing import Any import click @@ -156,6 +157,176 @@ def adapter_types(org: Any, hive_name: str = "cloud_sensor") -> list[dict[str, s """ +_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def _record_root(schema: Any) -> tuple[dict | None, dict | None]: + """Return (root_schema, record_node) for an adapter hive schema. + + Unwraps the {"schema": {...}} envelope and follows the root $ref into the + record definition (CloudSensorRecord / ExternalAdapterConfig). + """ + if isinstance(schema, dict) and isinstance(schema.get("schema"), dict): + schema = schema["schema"] + if not isinstance(schema, dict): + return None, None + record = schema + ref = schema.get("$ref") + if isinstance(ref, str): + resolved = _resolve_ref(schema, ref) + if resolved is not None: + record = resolved + return schema, record + + +def adapter_type_schema(org: Any, hive_name: str, type_name: str) -> tuple[Any, dict | None]: + """Resolve one adapter type's config sub-schema. + + Returns (root_schema, type_node): type_node is the JSON-Schema node for the + per-type config (e.g. the threatlocker sub-struct), or None if the type is + unknown. root_schema is returned so the caller can flatten/render with $ref + resolution against $defs. + """ + schema = Hive(org, hive_name).get_schema() + root, record = _record_root(schema) + if record is None: + return schema, None + props = record.get("properties") + if not isinstance(props, dict) or type_name not in props: + return root, None + return root, props[type_name] + + +def adapter_sensors(org: Any, hive_name: str, key: str) -> dict[str, Any]: + """Find the live sensor(s) belonging to an adapter record. + + Reads the adapter record, extracts its installation key (the sensor iid), + and returns the sensors whose iid matches. Falls back to matching the + adapter's configured hostname when the installation key isn't a bare iid + (UUID). The sensors list is empty when the adapter hasn't registered a + sensor yet (e.g. it has not delivered any events). + """ + record = Hive(org, hive_name).get(key) + data = getattr(record, "data", None) or {} + sensor_type = data.get("sensor_type") + sub = data.get(sensor_type, {}) if sensor_type else {} + client_options = sub.get("client_options", {}) if isinstance(sub, dict) else {} + identity = client_options.get("identity", {}) if isinstance(client_options, dict) else {} + install_key = identity.get("installation_key") + hostname = client_options.get("hostname") + + if isinstance(install_key, str) and _UUID_RE.match(install_key): + match_by, match_val = "iid", install_key + elif hostname: + match_by, match_val = "hostname", hostname + else: + return {"adapter": key, "match_by": None, "match_value": None, "selector": None, "sensors": [], + "note": "Adapter record has no resolvable installation_key (iid) or hostname to match on."} + + # Filter server-side with a sensor selector (both iid and hostname are + # supported selector fields), so this scales to large fleets instead of + # paging every sensor and filtering client-side. + selector = f'{match_by} == "{match_val}"' + sensors = [ + {"sid": s.get("sid"), "hostname": s.get("hostname"), "iid": s.get("iid"), + "is_online": s.get("is_online"), "last_seen": s.get("alive")} + for s in org.list_sensors(selector=selector) + ] + out = {"adapter": key, "match_by": match_by, "match_value": match_val, "selector": selector, "sensors": sensors} + if not sensors: + out["note"] = ("No sensor registered for this adapter yet — it has not delivered any " + "events (a cloud adapter materializes a sensor on first event). Normal " + "for a freshly-created adapter.") + return out + + +_EXPLAIN_SCHEMA = """\ +Show the configuration schema for ONE adapter/sensor type as a flat field +listing (field | type | required | notes), resolved from the live adapter hive +JSON-Schema. Use this before 'set' to learn the exact field set and where each +field lives (e.g. that hostname goes under client_options, not at the top +level). Pass --output json for the raw JSON-Schema node. + +Run 'list-types' first to see the valid --type values. +""" + +_EXPLAIN_SENSORS = """\ +List the live sensor(s) this adapter has produced. Reads the adapter record's +installation key (iid) and returns sensors whose iid matches — the reliable way +to get a cloud/external adapter's SID without decoding the installation key. + +An empty result means the adapter has not registered a sensor yet (it has not +delivered any events); that is expected for a freshly-created adapter. +""" + + +def add_schema(group: click.Group, command_path: str, hive_name: str = "cloud_sensor") -> None: + """Attach a ``schema --type `` subcommand to an adapter command group.""" + from ..cli import pass_context + from ..client import Client + from ..sdk.organization import Organization + from ..output import format_output, detect_output_format + from ..discovery import register_explain + from .hive import _flatten_schema + + @group.command("schema", help="Show one adapter type's config schema.") + @click.option("--type", "type_name", required=True, help="Adapter type (see list-types).") + @pass_context + def schema_cmd(ctx, type_name) -> None: + client = Client( + oid=ctx.obj.oid, environment=ctx.obj.environment, + print_debug_fn=ctx.obj.debug_fn, debug_full_response=ctx.obj.debug_full, + debug_curl=ctx.obj.debug_curl, debug_verbose=ctx.obj.debug_verbose, + ) + org = Organization(client) + root, node = adapter_type_schema(org, hive_name, type_name) + if node is None: + types = ", ".join(t["type"] for t in adapter_types(org, hive_name)) + raise click.UsageError(f"Unknown adapter type '{type_name}'. Valid types: {types}") + if ctx.obj.quiet: + return + fmt = ctx.obj.output_format or detect_output_format() + if fmt == "table" and isinstance(root, dict): + rows = _flatten_schema(node, root) + if rows: + click.echo(format_output(rows, fmt)) + return + # Raw JSON-Schema node for machine formats: resolve a top-level $ref and + # carry the root $defs so nested $refs in the node stay resolvable. + resolved = _resolve_ref(root, node["$ref"]) if isinstance(node, dict) and "$ref" in node and isinstance(root, dict) else node + if isinstance(resolved, dict) and isinstance(root, dict) and isinstance(root.get("$defs"), dict): + resolved = {**resolved, "$defs": root["$defs"]} + click.echo(format_output(resolved, fmt)) + + register_explain(command_path, _EXPLAIN_SCHEMA) + + +def add_sensors(group: click.Group, command_path: str, hive_name: str = "cloud_sensor") -> None: + """Attach a ``sensors --key `` subcommand to an adapter command group.""" + from ..cli import pass_context + from ..client import Client + from ..sdk.organization import Organization + from ..output import format_output, detect_output_format + from ..discovery import register_explain + + @group.command("sensors", help="List the live sensor(s) this adapter produced.") + @click.option("--key", required=True, help="Adapter record key.") + @pass_context + def sensors_cmd(ctx, key) -> None: + client = Client( + oid=ctx.obj.oid, environment=ctx.obj.environment, + print_debug_fn=ctx.obj.debug_fn, debug_full_response=ctx.obj.debug_full, + debug_curl=ctx.obj.debug_curl, debug_verbose=ctx.obj.debug_verbose, + ) + org = Organization(client) + data = adapter_sensors(org, hive_name, key) + if not ctx.obj.quiet: + fmt = ctx.obj.output_format or detect_output_format() + click.echo(format_output(data, fmt)) + + register_explain(command_path, _EXPLAIN_SENSORS) + + def add_list_types(group: click.Group, command_path: str, hive_name: str = "cloud_sensor") -> None: """Attach a ``list-types`` subcommand to an adapter command group. diff --git a/limacharlie/commands/adapter.py b/limacharlie/commands/adapter.py index 2ad636e5..ca901117 100644 --- a/limacharlie/commands/adapter.py +++ b/limacharlie/commands/adapter.py @@ -3,11 +3,13 @@ from __future__ import annotations from ._hive_shortcut import make_hive_group -from ._adapter_types import add_list_types +from ._adapter_types import add_list_types, add_schema, add_sensors from ..discovery import register_explain group = make_hive_group("external-adapter", "external_adapter", "external adapter") add_list_types(group, "external-adapter.list-types", "external_adapter") +add_schema(group, "external-adapter.schema", "external_adapter") +add_sensors(group, "external-adapter.sensors", "external_adapter") # Override the generic hive explains with adapter-specific documentation. diff --git a/limacharlie/commands/api_key.py b/limacharlie/commands/api_key.py index dadaf0cf..4472b554 100644 --- a/limacharlie/commands/api_key.py +++ b/limacharlie/commands/api_key.py @@ -128,8 +128,19 @@ def list_keys(ctx, name) -> None: "--permissions", required=True, help="Comma-separated list of permissions (e.g., 'dr.list,dr.set').", ) +@click.option( + "--store-secret", "store_secret", default=None, + help="Also store the freshly-minted key value into the secret hive under this " + "key name ({data: {secret: }}), so it can be referenced as " + "hive://secret/. The value is written directly without transiting " + "intermediate files — collapses the mint -> store -> reference chain.", +) +@click.option( + "--store-secret-tag", "store_secret_tags", multiple=True, + help="Tag to apply to the stored secret record (repeatable). Only used with --store-secret.", +) @pass_context -def create(ctx, name, permissions) -> None: +def create(ctx, name, permissions, store_secret, store_secret_tags) -> None: perm_list = [p.strip() for p in permissions.split(",") if p.strip()] if not perm_list: click.echo("Error: At least one permission is required.", err=True) @@ -140,6 +151,37 @@ def create(ctx, name, permissions) -> None: data = org.add_api_key(name, perm_list) if not ctx.obj.quiet: click.echo(f"API key '{name}' created.") + + if store_secret: + # The key value is only shown once; persist it into the secret hive in + # the same step so callers never have to capture and re-pipe it. + value = data.get("api_key") or data.get("secret") or data.get("key") + if not value: + # The key was already created; surface it so it isn't lost, then fail. + click.echo( + "Error: API key created but no key value was returned to store as a secret.", + err=True, + ) + _output(ctx, data) + ctx.exit(4) + return + from ..sdk.hive import Hive, HiveRecord + secret_hive = Hive(org, "secret") + # If a secret of this name already exists, carry its etag so the write is + # a conditional update (no lost update on a concurrent change) and we can + # report create-vs-overwrite instead of silently clobbering it. + existing_etag = None + try: + existing_etag = secret_hive.get_metadata(store_secret).etag + except Exception: + existing_etag = None # not found -> creating a new secret + record = HiveRecord(store_secret, data={"secret": value}, + tags=list(store_secret_tags), enabled=True, etag=existing_etag) + secret_hive.set(record) + if not ctx.obj.quiet: + verb = "Updated existing" if existing_etag else "Stored key value in new" + click.echo(f"{verb} secret '{store_secret}' (reference it as hive://secret/{store_secret}).") + _output(ctx, data) diff --git a/limacharlie/commands/cloud_sensor.py b/limacharlie/commands/cloud_sensor.py index b35adcca..5201b249 100644 --- a/limacharlie/commands/cloud_sensor.py +++ b/limacharlie/commands/cloud_sensor.py @@ -3,11 +3,13 @@ from __future__ import annotations from ._hive_shortcut import make_hive_group -from ._adapter_types import add_list_types +from ._adapter_types import add_list_types, add_schema, add_sensors from ..discovery import register_explain group = make_hive_group("cloud-adapter", "cloud_sensor", "cloud adapter") add_list_types(group, "cloud-adapter.list-types", "cloud_sensor") +add_schema(group, "cloud-adapter.schema", "cloud_sensor") +add_sensors(group, "cloud-adapter.sensors", "cloud_sensor") # Override the generic hive explains with cloud adapter documentation. diff --git a/tests/unit/test_cli_ergonomics.py b/tests/unit/test_cli_ergonomics.py index b19df74e..ce3ca301 100644 --- a/tests/unit/test_cli_ergonomics.py +++ b/tests/unit/test_cli_ergonomics.py @@ -495,3 +495,195 @@ def test_json_keeps_raw(self, mock_hive_cls, _org, _client): parsed = json.loads(result.output) # Raw JSON Schema preserved, nothing lost. assert parsed == self._SCHEMA + + +# --------------------------------------------------------------------------- +# adapter schema --type (per-type config schema) +# --------------------------------------------------------------------------- + +class TestAdapterTypeSchema: + _SCHEMA = {"schema": { + "$ref": "#/$defs/CloudSensorRecord", + "$defs": { + "CloudSensorRecord": {"properties": { + "sensor_type": {"type": "string"}, + "threatlocker": {"$ref": "#/$defs/ThreatLockerConfig"}, + }}, + "ThreatLockerConfig": {"properties": { + "api_key": {"type": "string"}, "instance_letter": {"type": "string"}, + }, "required": ["api_key"]}, + }, + }} + + @patch("limacharlie.commands._adapter_types.Hive") + def test_resolves_known_type(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_type_schema + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + root, node = adapter_type_schema(None, "cloud_sensor", "threatlocker") + assert node is not None + assert node.get("$ref", "").endswith("ThreatLockerConfig") + # root retains $defs so the caller can resolve/flatten. + assert "ThreatLockerConfig" in root["$defs"] + + @patch("limacharlie.commands._adapter_types.Hive") + def test_unknown_type_returns_none(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_type_schema + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + _root, node = adapter_type_schema(None, "cloud_sensor", "bogus") + assert node is None + + @patch("limacharlie.commands._adapter_types.Hive") + def test_cli_table_render(self, mock_hive_cls): + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + runner = CliRunner() + with patch("limacharlie.client.Client"): + result = runner.invoke(cli, [ + "--output", "table", "cloud-adapter", "schema", "--type", "threatlocker"]) + assert result.exit_code == 0, result.output + # Flattened field listing resolves the type config's properties. + assert "api_key" in result.output and "instance_letter" in result.output + + @patch("limacharlie.commands._adapter_types.Hive") + def test_cli_unknown_type_errors_with_valid_list(self, mock_hive_cls): + mock_hive = MagicMock(); mock_hive.get_schema.return_value = self._SCHEMA + mock_hive_cls.return_value = mock_hive + runner = CliRunner() + with patch("limacharlie.client.Client"): + result = runner.invoke(cli, ["cloud-adapter", "schema", "--type", "bogus"]) + assert result.exit_code != 0 + assert "Unknown adapter type 'bogus'" in result.output + assert "threatlocker" in result.output # lists the valid types + + +# --------------------------------------------------------------------------- +# adapter sensors --key (find an adapter's sensor(s) by iid) +# --------------------------------------------------------------------------- + +class TestAdapterSensors: + _IID = "11111111-1111-1111-1111-111111111111" + + @patch("limacharlie.commands._adapter_types.Hive") + def test_matches_by_iid_via_selector(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "s3", "s3": {"client_options": { + "identity": {"installation_key": self._IID}, "hostname": "ignored"}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock() + # The server applies the selector; the mock returns the filtered set. + org.list_sensors.return_value = [ + {"iid": self._IID, "sid": "S1", "hostname": "h1", "is_online": True, "alive": "t"}, + ] + out = adapter_sensors(org, "cloud_sensor", "my-adapter") + org.list_sensors.assert_called_once_with(selector=f'iid == "{self._IID}"') + assert out["match_by"] == "iid" and out["match_value"] == self._IID + assert out["selector"] == f'iid == "{self._IID}"' + assert [s["sid"] for s in out["sensors"]] == ["S1"] + + @patch("limacharlie.commands._adapter_types.Hive") + def test_empty_when_not_registered(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "s3", "s3": {"client_options": { + "identity": {"installation_key": self._IID}}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock(); org.list_sensors.return_value = [] + out = adapter_sensors(org, "cloud_sensor", "a") + assert out["sensors"] == [] and "note" in out + + @patch("limacharlie.commands._adapter_types.Hive") + def test_hostname_fallback_when_key_not_uuid(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "webhook", "webhook": {"client_options": { + "identity": {"installation_key": "AAAAbase64notuuid=="}, "hostname": "wh-1"}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock() + org.list_sensors.return_value = [ + {"iid": "x", "sid": "S1", "hostname": "wh-1", "is_online": True, "alive": "t"}, + ] + out = adapter_sensors(org, "cloud_sensor", "wh") + org.list_sensors.assert_called_once_with(selector='hostname == "wh-1"') + assert out["match_by"] == "hostname" and [s["sid"] for s in out["sensors"]] == ["S1"] + + @patch("limacharlie.commands._adapter_types.Hive") + def test_no_resolvable_identity_does_not_query(self, mock_hive_cls): + from limacharlie.commands._adapter_types import adapter_sensors + rec = MagicMock() + rec.data = {"sensor_type": "s3", "s3": {"client_options": {}}} + mock_hive_cls.return_value.get.return_value = rec + org = MagicMock() + out = adapter_sensors(org, "cloud_sensor", "a") + assert out["match_by"] is None and out["sensors"] == [] + org.list_sensors.assert_not_called() + + +# --------------------------------------------------------------------------- +# api-key create --store-secret (atomic mint -> secret) +# --------------------------------------------------------------------------- + +class TestApiKeyStoreSecret: + @patch("limacharlie.sdk.hive.Hive") + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_store_secret_writes_value_new(self, mock_org_cls, _client, mock_hive_cls): + mock_org = MagicMock() + mock_org.add_api_key.return_value = {"api_key": "minted-value", "key_name": "k", "success": True} + mock_org_cls.return_value = mock_org + mock_hive = MagicMock(); mock_hive_cls.return_value = mock_hive + # No existing secret of this name -> get_metadata raises -> create (no etag). + mock_hive.get_metadata.side_effect = Exception("not found") + + runner = CliRunner() + result = runner.invoke(cli, [ + "api-key", "create", "--name", "k", "--permissions", "org.get", + "--store-secret", "k-secret", "--store-secret-tag", "clitest", + ]) + assert result.exit_code == 0, result.output + # The secret hive received the minted value verbatim. + mock_hive_cls.assert_called_once() + assert mock_hive_cls.call_args[0][1] == "secret" + record = mock_hive.set.call_args[0][0] + assert record.data == {"secret": "minted-value"} + assert record.tags == ["clitest"] + assert record.enabled is True + assert record.etag is None # creating a new secret, no conditional etag + assert "new secret" in result.output + + @patch("limacharlie.sdk.hive.Hive") + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_store_secret_updates_existing_with_etag(self, mock_org_cls, _client, mock_hive_cls): + from limacharlie.sdk.hive import HiveRecord + mock_org = MagicMock() + mock_org.add_api_key.return_value = {"api_key": "minted-value", "key_name": "k"} + mock_org_cls.return_value = mock_org + mock_hive = MagicMock(); mock_hive_cls.return_value = mock_hive + # An existing secret -> carry its etag so the write is a conditional update. + mock_hive.get_metadata.return_value = HiveRecord(name="k-secret", etag="ETAG-1") + + runner = CliRunner() + result = runner.invoke(cli, [ + "api-key", "create", "--name", "k", "--permissions", "org.get", + "--store-secret", "k-secret", + ]) + assert result.exit_code == 0, result.output + record = mock_hive.set.call_args[0][0] + assert record.data == {"secret": "minted-value"} + assert record.etag == "ETAG-1" + assert "Updated existing" in result.output + + @patch("limacharlie.sdk.hive.Hive") + @patch("limacharlie.commands.api_key.Client") + @patch("limacharlie.commands.api_key.Organization") + def test_no_store_secret_does_not_touch_hive(self, mock_org_cls, _client, mock_hive_cls): + mock_org = MagicMock() + mock_org.add_api_key.return_value = {"api_key": "v", "key_name": "k"} + mock_org_cls.return_value = mock_org + runner = CliRunner() + result = runner.invoke(cli, ["api-key", "create", "--name", "k", "--permissions", "org.get"]) + assert result.exit_code == 0, result.output + mock_hive_cls.assert_not_called() diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 3315d909..1141e405 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -142,7 +142,7 @@ "entity", "export", "get", "list", "merge", "orgs", "report", "tag", "telemetry", "update", "update-note", }), - "cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "set", "tag"}), + "cloud-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "schema", "sensors", "set", "tag"}), "detection": frozenset({"get", "list"}), "download": frozenset({"adapter", "list", "sensor"}), "dr": frozenset({ @@ -160,7 +160,7 @@ "list", "list-available", "rekey", "request", "schema", "subscribe", "unsubscribe", }), - "external-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "set", "tag"}), + "external-adapter": frozenset({"delete", "disable", "enable", "get", "list", "list-types", "schema", "sensors", "set", "tag"}), "fp": frozenset({"delete", "disable", "enable", "get", "list", "set", "tag"}), "group": frozenset({ "create", "delete", "get", "list", "logs", From c05a75c8c4ce7c2b33df240d5ae21ebb3264ce0e Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Fri, 12 Jun 2026 06:56:49 -0700 Subject: [PATCH 12/14] cli: add org quota-usage command for getOrgQuotaUsage API (#302) Adds support for the GET /quota_usage/{oid} endpoint (getOrgQuotaUsage), exposing the enforced sensor quota usage the platform uses to gate sensors coming online. Unlike the online sensor count, this weights EPP/response-mode sensors, so it can read higher and is the correct value to size the sensor quota against. - SDK: Organization.get_quota_usage() returns {usage, quota, breakdown} - CLI: `limacharlie org quota-usage` with --ai-help explain text - Docs + unit tests (SDK, CLI, command-map lint, subcommand regression) Co-authored-by: Claude Opus 4.8 (1M context) --- doc/cli/platform-admin.md | 1 + limacharlie/commands/org.py | 26 +++++++++++++++++++ limacharlie/sdk/organization.py | 14 ++++++++++ tests/unit/test_cli_commands.py | 19 ++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 2 +- tests/unit/test_sdk_organization.py | 11 ++++++++ 6 files changed, 72 insertions(+), 1 deletion(-) diff --git a/doc/cli/platform-admin.md b/doc/cli/platform-admin.md index 63e747f9..440a91ac 100644 --- a/doc/cli/platform-admin.md +++ b/doc/cli/platform-admin.md @@ -9,6 +9,7 @@ Commands for organization management, users, groups, API keys, ingestion keys, b ```bash limacharlie org info # Name, sensor count, version, quotas limacharlie org stats # Usage statistics +limacharlie org quota-usage # Enforced sensor quota usage + breakdown limacharlie org urls # Service URLs (for firewall rules) limacharlie org errors # Platform errors limacharlie org dismiss-error --component diff --git a/limacharlie/commands/org.py b/limacharlie/commands/org.py index 98614c8d..d7d3414c 100644 --- a/limacharlie/commands/org.py +++ b/limacharlie/commands/org.py @@ -390,6 +390,32 @@ def quota(ctx: click.Context, quota: int) -> None: _output(ctx, data) +# --------------------------------------------------------------------------- +# quota-usage +# --------------------------------------------------------------------------- + +_EXPLAIN_QUOTA_USAGE = """\ +Display the enforced sensor quota usage for the organization. This is the +weighted virtual-sensor count the platform actually uses to decide whether a +sensor may come online, so it is the value to size the sensor quota against. + +The reported usage can read higher than the online sensor count, which +weights EPP/response-mode sensors at 0. The response includes the configured +quota and a per-category breakdown (raw count and weighted contribution), +making it useful for capacity planning and understanding headroom before +licensing additional sensors. +""" +register_explain("org.quota-usage", _EXPLAIN_QUOTA_USAGE) + + +@group.command("quota-usage") +@pass_context +def quota_usage(ctx: click.Context) -> None: + org = _get_org(ctx) + data = org.get_quota_usage() + _output(ctx, data) + + # --------------------------------------------------------------------------- # schema # --------------------------------------------------------------------------- diff --git a/limacharlie/sdk/organization.py b/limacharlie/sdk/organization.py index bda7ff92..bba3878b 100644 --- a/limacharlie/sdk/organization.py +++ b/limacharlie/sdk/organization.py @@ -186,6 +186,20 @@ def set_quota(self, quota: int) -> dict[str, Any]: """ return self._client.request("POST", f"orgs/{self.oid}/quota", params={"quota": quota}) + def get_quota_usage(self) -> dict[str, Any]: + """Get the enforced sensor quota usage for the organization. + + This is the weighted virtual-sensor count the platform actually uses + to decide whether a sensor may come online, so it is the value to size + the sensor quota against. It can read higher than the online sensor + count, which weights EPP/response-mode sensors at 0. + + Returns: + dict: ``{"usage": int, "quota": int, "breakdown": {category: + {"n": int, "quota": float}}}``. + """ + return self._client.request("GET", f"quota_usage/{self.oid}") + def rename(self, new_name: str) -> dict[str, Any]: """Rename the organization. diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index 934bbb29..c852c3f7 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -313,6 +313,25 @@ def test_org_info(self, mock_org_cls, mock_client_cls): assert parsed["name"] == "TestOrg" mock_org.get_info.assert_called_once() + @patch("limacharlie.commands.org.Client") + @patch("limacharlie.commands.org.Organization") + def test_org_quota_usage(self, mock_org_cls, mock_client_cls): + mock_org = MagicMock() + mock_org.get_quota_usage.return_value = { + "usage": 42, + "quota": 100, + "breakdown": {"edr": {"n": 40, "quota": 40.0}}, + } + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "org", "quota-usage"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["usage"] == 42 + assert parsed["quota"] == 100 + mock_org.get_quota_usage.assert_called_once() + @patch("limacharlie.commands.org.Client") @patch("limacharlie.commands.org.Organization") def test_org_urls(self, mock_org_cls, mock_client_cls): diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 1141e405..25797d5a 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -183,7 +183,7 @@ "org": frozenset({ "check-name", "config-get", "config-set", "create", "delete", "dismiss-error", "errors", "info", "list", "mitre", "quota", - "rename", "runtime-metadata", "schema", "stats", "urls", + "quota-usage", "rename", "runtime-metadata", "schema", "stats", "urls", }), "output": frozenset({"create", "delete", "list"}), "payload": frozenset({"delete", "download", "list", "upload"}), diff --git a/tests/unit/test_sdk_organization.py b/tests/unit/test_sdk_organization.py index b279df9e..0086bdad 100644 --- a/tests/unit/test_sdk_organization.py +++ b/tests/unit/test_sdk_organization.py @@ -47,6 +47,17 @@ def test_get_stats(self, org, mock_client): org.get_stats() mock_client.request.assert_called_once_with("GET", "usage/test-oid-123") + def test_get_quota_usage(self, org, mock_client): + mock_client.request.return_value = { + "usage": 42, + "quota": 100, + "breakdown": {"edr": {"n": 40, "quota": 40.0}}, + } + result = org.get_quota_usage() + mock_client.request.assert_called_once_with("GET", "quota_usage/test-oid-123") + assert result["usage"] == 42 + assert result["quota"] == 100 + def test_get_errors(self, org, mock_client): mock_client.request.return_value = {"errors": []} org.get_errors() From c93f6f09ce6051f4d31ac76d56e2b81cfd99f3cc Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Sun, 14 Jun 2026 16:33:34 -0700 Subject: [PATCH 13/14] cli: add app hive shortcut command (#303) Add an `app` hive-shortcut group mirroring the other hive shortcuts (secret, lookup, playbook, note, sop, ...) for the new `app` hive added in legion_config_hive. The app hive holds user-authored, AI-generated mini web apps (a single self-contained HTML document rendered in a sandboxed iframe by the web UI). - New limacharlie/commands/app.py via make_hive_group("app", "app", "app") with app-specific --ai-help/explain text covering the record shape (display_name, html, required_permissions, allowed_origins, required_services, locations, expected_context). - Register "app" in cli.py _COMMAND_MODULE_MAP. - Update lazy-loading regression test (top-level set, module map, subcommands map, hive-shortcut load test). - Document the shortcut in doc/cli/hive-data.md. Co-authored-by: Claude Opus 4.8 (1M context) --- doc/cli/hive-data.md | 12 ++++ limacharlie/cli.py | 1 + limacharlie/commands/app.py | 62 +++++++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 6 +- 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 limacharlie/commands/app.py diff --git a/doc/cli/hive-data.md b/doc/cli/hive-data.md index c8b3ebc6..f7ad3890 100644 --- a/doc/cli/hive-data.md +++ b/doc/cli/hive-data.md @@ -64,6 +64,18 @@ limacharlie external-adapter list limacharlie cloud-adapter list ``` +### app + +User-authored, AI-generated mini web apps (a self-contained HTML document +rendered in a sandboxed iframe by the web UI). + +```bash +limacharlie app list +limacharlie app get --key my-app +limacharlie app set --key my-app --input-file app.yaml +limacharlie app delete --key my-app --confirm +``` + ## extension ```bash diff --git a/limacharlie/cli.py b/limacharlie/cli.py index d7135d13..56df3d72 100644 --- a/limacharlie/cli.py +++ b/limacharlie/cli.py @@ -102,6 +102,7 @@ def _config_no_warnings() -> bool: "ai-skill": ("ai_skill", "group"), "api": ("api_cmd", "cmd"), "api-key": ("api_key", "group"), + "app": ("app", "group"), "arl": ("arl", "group"), "artifact": ("artifact", "group"), "audit": ("audit", "group"), diff --git a/limacharlie/commands/app.py b/limacharlie/commands/app.py new file mode 100644 index 00000000..18eb214c --- /dev/null +++ b/limacharlie/commands/app.py @@ -0,0 +1,62 @@ +"""App commands for LimaCharlie CLI v2.""" + +from __future__ import annotations + +from ._hive_shortcut import make_hive_group +from ..discovery import register_explain + +group = make_hive_group("app", "app", "app") + +# Override the generic hive explains with app-specific documentation. + +register_explain("app.list", """\ +List all apps stored in the 'app' hive. An app is a user-authored, +AI-generated mini web application: a single self-contained HTML +document that the LimaCharlie web UI renders inside a sandboxed +