apcore-cli 0.10.4__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/CHANGELOG.md +59 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/PKG-INFO +4 -4
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/pyproject.toml +3 -3
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/approval.py +33 -15
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/cli.py +9 -25
- apcore_cli-0.11.0/src/apcore_cli/exit_codes.py +121 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/system_cmd.py +8 -1
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_approval.py +188 -0
- apcore_cli-0.11.0/tests/test_exit_codes.py +119 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_system_cmd.py +55 -0
- apcore_cli-0.10.4/src/apcore_cli/exit_codes.py +0 -74
- apcore_cli-0.10.4/tests/test_exit_codes.py +0 -58
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.github/CODEOWNERS +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.github/copilot-ignore +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.github/workflows/ci.yml +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.gitignore +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.gitmessage +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.pre-commit-config.yaml +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/CLAUDE.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/LICENSE +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/Makefile +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/README.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/README.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/math/add.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/math/multiply.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/sysutil/disk.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/sysutil/env.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/sysutil/info.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/text/reverse.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/text/upper.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/text/wordcount.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/run_examples.sh +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/approval-gate.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/config-resolver.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/core-dispatcher.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/discovery.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/exposure-filtering.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/grouped-commands.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/output-formatter.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/overview.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/schema-parser.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/security-manager.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/shell-integration.md +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/state.json +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/__init__.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/__main__.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/_sandbox_runner.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/builtin_group.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/config.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/discovery.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/display_helpers.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/exposure.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/factory.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/init_cmd.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/output.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/ref_resolver.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/schema_parser.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/__init__.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/audit.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/auth.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/config_encryptor.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/sandbox.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/shell.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/strategy.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/system_usage.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/validate.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/__init__.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conformance/__init__.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conformance/test_apcli_visibility.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conformance/test_snake_case_kwargs.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conftest.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/shell_test_utils.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_apcli_integration.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_bugfixes.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_builtin_group.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_cli.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_config.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_discovery.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_discovery_fe13.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_display_helpers.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_e2e.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_exposure.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_factory_fe13.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_init_cmd.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_integration.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_list_command_filters.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_output.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_output_format_exec.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_output_format_markdown_skill.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_public_api.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_ref_resolver.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_sandbox_runner.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_schema_parser.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/__init__.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_audit.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_auth.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_config_encryptor.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_sandbox.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_shell.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_strategy.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_system_usage.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_toolkit_integration.py +0 -0
- {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_validate.py +0 -0
|
@@ -5,6 +5,65 @@ All notable changes to apcore-cli (Python SDK) will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.11.0] - 2026-09-02
|
|
9
|
+
|
|
10
|
+
Bumps the required `apcore` floor to `0.28.0` and `apcore-toolkit` to `0.10.2`, and fixes **three defects the 0.28.0 upgrade made reachable or visible**. Full suite: 815 passed, 5 xfailed (798 → 815; 17 new regression tests), verified in a throwaway venv built from the declared `dev` extra rather than the working interpreter. Each new test was confirmed to fail against the pre-fix behaviour rather than merely to pass against the new one.
|
|
11
|
+
|
|
12
|
+
**Why a minor rather than a patch.** Every fix below restores behaviour that was already specified, but two of them change what a working consumer observes, and this ecosystem's rule — stated in apcore's own 0.28.0 release note — is that such a change "must ship as a **minor** (or major) version bump, never a patch". A script branching on exit code `1` from an `apcli` system command now sees `45`; a caller doing `handler.request_approval(...)["status"]` now gets a `TypeError`. Neither was correct behaviour, and both were reachable.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **The `apcli health` summary line reported "no data" for a project whose modules it had just listed.** apcore classifies module health in **four** tiers — `healthy` / `degraded` / `error` / `unknown` — and the tally iterated only the first three. `unknown` means "no calls recorded yet", which is the state every module in a fresh project is in, so the common case rendered a populated table above a total that denied it:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
probe.echo unknown 0.0% --
|
|
20
|
+
Summary: no data
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Pre-existing, and not introduced by this upgrade** — all three SDKs have emitted `unknown` since the tier set existed. apcore 0.28.0 is what brought it into focus: `sys-health-summary.schema.json` had declared the enum as `["healthy", "degraded", "unhealthy"]`, a value **no SDK emits**, and the release corrects it to the four tiers actually produced, splitting the summary's `unhealthy` count field into `error` and `unknown`. With the canonical shape finally naming four tiers, rendering three is a plain omission. Fixed in all three SDKs together, with the tally now covering `unknown`; a genuinely empty tally still reads "no data".
|
|
24
|
+
|
|
25
|
+
- **The approval gate crashed on every gate-routed approval — `CliApprovalHandler` returned a mapping where the protocol requires an `ApprovalResult`.** apcore's `BuiltinApprovalGate` reads the handler's answer by attribute (`result.status`, `result.approved_by` in `builtin_steps.py`), so the handler's `{"status": "approved", ...}` dict raised `AttributeError` *inside* the gate and reached the caller as `MODULE_EXECUTE_ERROR`. Every one of the eight return paths was affected, so a module annotated `requires_approval: true` could not execute through the wired handler at all — including the `--yes` and `APCORE_CLI_AUTO_APPROVE=1` bypasses, which return early and therefore failed the same way.
|
|
26
|
+
|
|
27
|
+
It survived because nothing exercised it: no test in this SDK called `request_approval`, and the class was only reachable through `executor.set_approval_handler` in `factory.py`. apcore-cli-rust converts through `cli_to_apcore_result` and apcore-cli-typescript's `ApprovalResult` is a structurally-typed interface, so neither had the defect — this was a Python-only divergence from a shape both other SDKs got right.
|
|
28
|
+
|
|
29
|
+
**Pre-existing, but apcore 0.28.0 widens the blast radius.** Before 0.28.0 the gate fired only for a module whose *annotation* said `requires_approval: true`. Since spec v1.28.0 §6.9 the gate fires on the union of three sources, so an ACL rule carrying `approval: required` (§6.1.6) now routes calls to modules annotated `requires_approval: false` through the same broken path — the exact shape argument-scoped approval was added for (`git push --force` needs a human, `git push` does not).
|
|
30
|
+
|
|
31
|
+
`_approval_result()` now constructs `apcore.approval.ApprovalResult`, falling back to the mapping shape when apcore is not importable so the handler stays usable in isolation.
|
|
32
|
+
|
|
33
|
+
- **Every apcore error from an `apcli` system command exited 1 instead of its canonical code.** `exit_code_for_error` matched only on the CLI's own exception classes, none of which an apcore-raised `ModuleError` subclass is an instance of, so the wire code it carries was never read. `system_cmd._exit_on_system_error` documents the canonical taxonomy (44 module-not-found, 45 schema-validation, 46 approval-denied, 47 config-invalid, 77 ACL-denied) and delivered `1` for all of them. TS `exitCodeForError` falls through to a `codeMap` and Rust `map_module_error_to_exit_code` reads `err.code`; Python had the map — in `cli.py` — and never consulted it from this path.
|
|
34
|
+
|
|
35
|
+
**apcore 0.28.0 makes it newly reachable on a routine command.** `system.usage.summary` / `system.usage.module` now declare `"pattern": "^[1-9][0-9]*[hd]$"` on `period`, and 0.28.0 also stops `_DictSchemaAdapter.model_validate` being a pass-through, so dict-declared schemas are finally enforced. `apcore-cli apcli usage --period 0h` passes the flag through verbatim and now raises `SCHEMA_VALIDATION_ERROR` where it previously returned an empty window with exit 0 — and was reported as exit 1 rather than 45.
|
|
36
|
+
|
|
37
|
+
`APCORE_ERROR_CODE_MAP` moves to `exit_codes.py` as the single source of truth (`cli._ERROR_CODE_MAP` is now an alias, so the two copies cannot drift), gains the `DEPENDENCY_NOT_FOUND` / `DEPENDENCY_VERSION_MISMATCH` entries TS already had, and `exit_code_for_error` falls back to it.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **`apcore>=0.28.0`, `apcore-toolkit>=0.10.2`.** apcore-toolkit 0.10.2 is a dependency-tracking release with no source change; its stable surface consumed by the CLI (`format_*`, `DisplayResolver`, `BindingLoader`, `RegistryWriter`) is unchanged.
|
|
42
|
+
|
|
43
|
+
- **Exit-code map parity pinned across the three SDKs.** A mechanical three-way diff of the maps found `DEPENDENCY_NOT_FOUND` and `DEPENDENCY_VERSION_MISMATCH` mapped to 44 here and in the other non-Rust SDK, but falling through to 1 in apcore-cli-rust (fixed in its 0.11.0). Both codes now carry an explicit assertion here too, so the three maps cannot drift again without a test going red.
|
|
44
|
+
|
|
45
|
+
- **The new handler tests drive their coroutines with `asyncio.run` instead of `@pytest.mark.asyncio`.** This suite declares no async plugin — `pytest-asyncio` is absent from the `dev` extra and there is not one other async test in it — so a marker-based test is collected happily and then fails at run time with *"async def functions are not natively supported"* on any machine that does not happen to have the plugin installed for other reasons. Caught by CI, not locally, which is the point: the local interpreter had it and the declared dependency set does not.
|
|
46
|
+
|
|
47
|
+
### Notes
|
|
48
|
+
|
|
49
|
+
- **What the 0.27.0 → 0.28.0 delta does *not* touch.** The CLI never constructs or loads an `ACL`, never calls `check()` / `check_access()` (so §6.8.1's fail-closed legacy boolean does not reach it), never reads an `AuditEntry`, and never builds an `ACLRule` (so §6.1.5's `effect` value closure and the new `approval` field are inert here). `ExecutionPolicy.resolve()`'s new keyword-only call-site parameters are additive and the CLI configures no policy. `p99_latency_ms` changing value is display-only in `_format_usage_summary_tty`.
|
|
50
|
+
- **`Executor.validate()` now reports the governance-effective requirement (§7.9.5), which is an improvement the CLI gets for free.** `apcli validate` and the `--dry-run` path forward `result.requires_approval` verbatim, so a call gated only by an ACL argument-scoped rule is now correctly reported as needing approval. Pinned by `test_preflight_reports_the_acl_sourced_requirement`.
|
|
51
|
+
- **The CLI's own pre-execution `check_approval(module_def, ...)` still reads the static annotation**, which since spec v1.29.0 no longer means "no consent needed". That is correct here rather than a defect: the pre-check is an ergonomic early prompt, and a call it skips still meets the executor's gate, which now composes all three sources and routes to the same `CliApprovalHandler`. Verified end-to-end by `TestApprovalGateEndToEnd`.
|
|
52
|
+
|
|
53
|
+
## [0.10.5] - 2026-08-17
|
|
54
|
+
|
|
55
|
+
Patch release. Bumps the required `apcore` floor to `0.27.0` to track the aligned apcore 0.27.0 release (2026-08-14). **No source changes** — the full test suite passes unchanged (798 passed, 5 xfailed) against apcore 0.27.0.
|
|
56
|
+
|
|
57
|
+
The apcore 0.26.0 → 0.27.0 delta is BREAKING at the spec level, but touches no surface the CLI consumes — verified against the release notes and the actual call sites:
|
|
58
|
+
|
|
59
|
+
- **Middleware semantics** — `before_step` failure is now terminal/non-recoverable, `after_step` fires after a recovered step body, `state.outputs` excludes the current step in `after_step`. The CLI never constructs or configures middleware or pipelines; it only calls `executor.call` / `executor.validate` / `call_with_trace` with strategy names. No exposure.
|
|
60
|
+
- **ACL-failed `validate()` introspection** — a failed `acl` check now withholds `module_preflight` / `module_preview` checks and `predicted_changes`. The CLI's `executor.validate()` calls (per-module `--dry-run`, `apcli validate`, and the `system.health.summary` probe) consume only `valid` / `checks` / `requires_approval`; the probe discards its result. Behavior stays correct (ACL-denied calls surface exit 77 as before).
|
|
61
|
+
- **`Registry.register` metadata `dependencies` persistence** — the CLI never calls `register()` directly (module registration is via `discover()` / toolkit `RegistryWriter`); it only reads `len(descriptor.dependencies)` for the `--deps` column. No exposure.
|
|
62
|
+
- **Schema conversion (A23)** — object detection, nullable `anyOf` wrapping, sorted `required` are SDK-conversion rules. The CLI runs its **own** schema→Click converter (`schema_parser.py`) on the descriptor's `input_schema`; it already strips `{"type": "null"}` branches from `anyOf` (v0.10.3) and treats `required` order-insensitively. No exposure.
|
|
63
|
+
- **`pipeline.configure` 4-field set / `requires`/`provides` non-configurable** — the CLI never configures pipelines; a host config carrying other keys now fails at load (spec-mandated strictness, upstream concern).
|
|
64
|
+
- **No type coercion at the module boundary** — CLI flag parsing (Click) produces typed values; only `--input -` JSON passthrough with string-typed numeric values now fails `SCHEMA_VALIDATION_ERROR` instead of being silently coerced (spec-mandated strictness).
|
|
65
|
+
- **Removed/renamed API surface** — Python `apcore.middleware.namespace_keys` removed, `SchemaValidator` default flip, `TraceContext.inject()` raises `InvalidParentIdError` — none used by the CLI.
|
|
66
|
+
|
|
8
67
|
## [0.10.4] - 2026-07-14
|
|
9
68
|
|
|
10
69
|
Patch release. Bumps the required `apcore` floor to `0.26.0` to align the ecosystem on the 0.26.0 governance layer (Execution Policy, governance events, no-handler fail-loud — additive, no breaking changes). No code or API changes; all 798 tests pass (5 xfailed) unmodified against apcore 0.26.0.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: apcore-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Terminal adapter for apcore — execute AI-Perceivable modules from the command line
|
|
5
5
|
Project-URL: Homepage, https://aiperceivable.com
|
|
6
6
|
Project-URL: Repository, https://github.com/aiperceivable/apcore-cli-python
|
|
@@ -21,8 +21,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
23
|
Requires-Python: >=3.11
|
|
24
|
-
Requires-Dist: apcore-toolkit>=0.10.
|
|
25
|
-
Requires-Dist: apcore>=0.
|
|
24
|
+
Requires-Dist: apcore-toolkit>=0.10.2
|
|
25
|
+
Requires-Dist: apcore>=0.28.0
|
|
26
26
|
Requires-Dist: click>=8.1
|
|
27
27
|
Requires-Dist: cryptography>=41.0
|
|
28
28
|
Requires-Dist: jsonschema>=4.20
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "apcore-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -26,8 +26,8 @@ classifiers = [
|
|
|
26
26
|
"Environment :: Console",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
"apcore>=0.
|
|
30
|
-
"apcore-toolkit>=0.10.
|
|
29
|
+
"apcore>=0.28.0",
|
|
30
|
+
"apcore-toolkit>=0.10.2",
|
|
31
31
|
"click>=8.1",
|
|
32
32
|
"jsonschema>=4.20",
|
|
33
33
|
"rich>=13.0",
|
|
@@ -71,6 +71,30 @@ def _read_timeout_from_env() -> int | None:
|
|
|
71
71
|
return parsed
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _approval_result(status: str, approved_by: str | None = None, reason: str | None = None) -> Any:
|
|
75
|
+
"""Build the apcore-protocol ``ApprovalResult`` the executor's gate expects.
|
|
76
|
+
|
|
77
|
+
The approval gate reads the handler's return value by attribute
|
|
78
|
+
(``result.status`` in apcore ``builtin_steps``), so a plain mapping raises
|
|
79
|
+
``AttributeError`` inside the gate and surfaces to the caller as
|
|
80
|
+
``MODULE_EXECUTE_ERROR``. Mirrors the Rust adapter's ``cli_to_apcore_result``
|
|
81
|
+
and TypeScript's structurally-typed ``ApprovalResult`` object.
|
|
82
|
+
|
|
83
|
+
Falls back to the mapping shape when apcore is not importable so the handler
|
|
84
|
+
stays usable in isolation (unit tests that stub the runtime out).
|
|
85
|
+
"""
|
|
86
|
+
payload: dict[str, Any] = {"status": status}
|
|
87
|
+
if approved_by is not None:
|
|
88
|
+
payload["approved_by"] = approved_by
|
|
89
|
+
if reason is not None:
|
|
90
|
+
payload["reason"] = reason
|
|
91
|
+
try:
|
|
92
|
+
from apcore.approval import ApprovalResult
|
|
93
|
+
except ImportError:
|
|
94
|
+
return payload
|
|
95
|
+
return ApprovalResult(**payload)
|
|
96
|
+
|
|
97
|
+
|
|
74
98
|
# ---------------------------------------------------------------------------
|
|
75
99
|
# CliApprovalHandler — implements apcore ApprovalHandler protocol (FE-11 §3.5)
|
|
76
100
|
# ---------------------------------------------------------------------------
|
|
@@ -109,25 +133,25 @@ class CliApprovalHandler:
|
|
|
109
133
|
# (`if !get_requires_approval(module_def) { return Approved::not_required }`).
|
|
110
134
|
requires_attr = getattr(request, "requires_approval", None)
|
|
111
135
|
if requires_attr is False:
|
|
112
|
-
return
|
|
136
|
+
return _approval_result("approved", approved_by="not_required")
|
|
113
137
|
module_def = getattr(request, "module_def", None)
|
|
114
138
|
if module_def is not None:
|
|
115
139
|
annotations = getattr(module_def, "annotations", None)
|
|
116
140
|
if annotations is not None:
|
|
117
141
|
requires = _get_annotation(annotations, "requires_approval", None)
|
|
118
142
|
if requires is False:
|
|
119
|
-
return
|
|
143
|
+
return _approval_result("approved", approved_by="not_required")
|
|
120
144
|
|
|
121
145
|
# Bypass: auto_approve flag
|
|
122
146
|
if self.auto_approve:
|
|
123
147
|
logger.info("Approval bypassed via --yes flag for module '%s'.", module_id)
|
|
124
|
-
return
|
|
148
|
+
return _approval_result("approved", approved_by="auto_approve")
|
|
125
149
|
|
|
126
150
|
# Bypass: APCORE_CLI_AUTO_APPROVE env var
|
|
127
151
|
env_val = os.environ.get("APCORE_CLI_AUTO_APPROVE", "")
|
|
128
152
|
if env_val == "1":
|
|
129
153
|
logger.info("Approval bypassed via APCORE_CLI_AUTO_APPROVE for '%s'.", module_id)
|
|
130
|
-
return
|
|
154
|
+
return _approval_result("approved", approved_by="env_auto_approve")
|
|
131
155
|
if env_val != "" and env_val != "1":
|
|
132
156
|
# D10-009 cross-SDK parity: emit to stderr (matching TS) so log
|
|
133
157
|
# capture / handler config does not mask the warning. Spec at
|
|
@@ -140,10 +164,7 @@ class CliApprovalHandler:
|
|
|
140
164
|
|
|
141
165
|
# Non-TTY: reject
|
|
142
166
|
if not sys.stdin.isatty():
|
|
143
|
-
return
|
|
144
|
-
"status": "rejected",
|
|
145
|
-
"reason": "Non-interactive session without --yes",
|
|
146
|
-
}
|
|
167
|
+
return _approval_result("rejected", reason="Non-interactive session without --yes")
|
|
147
168
|
|
|
148
169
|
# TTY prompt
|
|
149
170
|
annotations = getattr(request, "annotations", None) or {}
|
|
@@ -154,21 +175,18 @@ class CliApprovalHandler:
|
|
|
154
175
|
try:
|
|
155
176
|
approved = _tty_prompt(module_id, self.timeout)
|
|
156
177
|
except ApprovalTimeoutError:
|
|
157
|
-
return
|
|
178
|
+
return _approval_result("timeout", reason=f"Timed out after {self.timeout}s")
|
|
158
179
|
|
|
159
180
|
if approved:
|
|
160
|
-
return
|
|
161
|
-
return
|
|
181
|
+
return _approval_result("approved", approved_by="tty_user")
|
|
182
|
+
return _approval_result("rejected", reason="User rejected")
|
|
162
183
|
|
|
163
184
|
async def check_approval(self, approval_id: str) -> Any:
|
|
164
185
|
"""Check status of a previously pending approval (Phase B).
|
|
165
186
|
|
|
166
187
|
CLI does not support async approval polling; always returns rejected.
|
|
167
188
|
"""
|
|
168
|
-
return
|
|
169
|
-
"status": "rejected",
|
|
170
|
-
"reason": "CLI does not support async approval polling",
|
|
171
|
-
}
|
|
189
|
+
return _approval_result("rejected", reason="CLI does not support async approval polling")
|
|
172
190
|
|
|
173
191
|
|
|
174
192
|
# ---------------------------------------------------------------------------
|
|
@@ -16,7 +16,11 @@ import jsonschema
|
|
|
16
16
|
from apcore_cli.approval import check_approval
|
|
17
17
|
from apcore_cli.builtin_group import RESERVED_GROUP_NAMES as RESERVED_GROUP_NAMES # noqa: PLC0414
|
|
18
18
|
from apcore_cli.display_helpers import get_display as _get_display
|
|
19
|
-
from apcore_cli.exit_codes import
|
|
19
|
+
from apcore_cli.exit_codes import (
|
|
20
|
+
APCORE_ERROR_CODE_MAP,
|
|
21
|
+
EXIT_SCHEMA_CIRCULAR_REF,
|
|
22
|
+
EXIT_SCHEMA_VALIDATION_ERROR,
|
|
23
|
+
)
|
|
20
24
|
from apcore_cli.output import format_exec_result
|
|
21
25
|
from apcore_cli.ref_resolver import (
|
|
22
26
|
CircularRefError,
|
|
@@ -428,30 +432,10 @@ class GroupedModuleGroup(LazyModuleGroup):
|
|
|
428
432
|
)
|
|
429
433
|
|
|
430
434
|
|
|
431
|
-
# Error code mapping from apcore error codes to CLI exit codes
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
"MODULE_DISABLED": 44,
|
|
436
|
-
"SCHEMA_VALIDATION_ERROR": 45,
|
|
437
|
-
"SCHEMA_CIRCULAR_REF": 48,
|
|
438
|
-
"APPROVAL_DENIED": 46,
|
|
439
|
-
"APPROVAL_TIMEOUT": 46,
|
|
440
|
-
"APPROVAL_PENDING": 46,
|
|
441
|
-
"CONFIG_NOT_FOUND": 47,
|
|
442
|
-
"CONFIG_INVALID": 47,
|
|
443
|
-
"MODULE_EXECUTE_ERROR": 1,
|
|
444
|
-
"MODULE_TIMEOUT": 1,
|
|
445
|
-
"ACL_DENIED": 77,
|
|
446
|
-
# Config Bus errors (apcore >= 0.15.0)
|
|
447
|
-
"CONFIG_NAMESPACE_RESERVED": 78,
|
|
448
|
-
"CONFIG_NAMESPACE_DUPLICATE": 78,
|
|
449
|
-
"CONFIG_ENV_PREFIX_CONFLICT": 78,
|
|
450
|
-
"CONFIG_ENV_MAP_CONFLICT": 78,
|
|
451
|
-
"CONFIG_MOUNT_ERROR": 66,
|
|
452
|
-
"CONFIG_BIND_ERROR": 65,
|
|
453
|
-
"ERROR_FORMATTER_DUPLICATE": 70,
|
|
454
|
-
}
|
|
435
|
+
# Error code mapping from apcore error codes to CLI exit codes. Single source
|
|
436
|
+
# of truth lives in exit_codes so the dispatch paths that map by wire code and
|
|
437
|
+
# exit_code_for_error cannot drift apart.
|
|
438
|
+
_ERROR_CODE_MAP = APCORE_ERROR_CODE_MAP
|
|
455
439
|
|
|
456
440
|
|
|
457
441
|
# D9-005: format_preflight_result and _first_failed_exit_code moved to
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Exit code constants and helper for mapping error classes to process exit codes.
|
|
2
|
+
|
|
3
|
+
Mirrors apcore-cli-typescript ``src/errors.ts`` (``EXIT_CODES`` map and
|
|
4
|
+
``exitCodeForError``) and apcore-cli-rust ``src/lib.rs`` (``EXIT_*`` consts).
|
|
5
|
+
See ``apcore-cli/docs/features/core-dispatcher.md`` for the canonical exit-code
|
|
6
|
+
table.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from apcore_cli.approval import ApprovalDeniedError, ApprovalTimeoutError
|
|
12
|
+
from apcore_cli.security.auth import AuthenticationError
|
|
13
|
+
from apcore_cli.security.config_encryptor import ConfigDecryptionError
|
|
14
|
+
from apcore_cli.security.sandbox import (
|
|
15
|
+
ModuleExecutionError,
|
|
16
|
+
ModuleNotFoundError,
|
|
17
|
+
SchemaValidationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Exit code constants — match the EXIT_* names in apcore-cli-rust src/lib.rs
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
EXIT_SUCCESS = 0
|
|
25
|
+
EXIT_MODULE_EXECUTE_ERROR = 1
|
|
26
|
+
EXIT_MODULE_TIMEOUT = 1
|
|
27
|
+
EXIT_INVALID_INPUT = 2
|
|
28
|
+
EXIT_MODULE_NOT_FOUND = 44
|
|
29
|
+
EXIT_MODULE_LOAD_ERROR = 44
|
|
30
|
+
EXIT_MODULE_DISABLED = 44
|
|
31
|
+
EXIT_DEPENDENCY_NOT_FOUND = 44
|
|
32
|
+
EXIT_DEPENDENCY_VERSION_MISMATCH = 44
|
|
33
|
+
EXIT_SCHEMA_VALIDATION_ERROR = 45
|
|
34
|
+
EXIT_APPROVAL_DENIED = 46
|
|
35
|
+
EXIT_APPROVAL_TIMEOUT = 46
|
|
36
|
+
EXIT_CONFIG_NOT_FOUND = 47
|
|
37
|
+
EXIT_CONFIG_INVALID = 47
|
|
38
|
+
EXIT_SCHEMA_CIRCULAR_REF = 48
|
|
39
|
+
EXIT_CONFIG_BIND_ERROR = 65
|
|
40
|
+
EXIT_CONFIG_MOUNT_ERROR = 66
|
|
41
|
+
EXIT_ERROR_FORMATTER_DUPLICATE = 70
|
|
42
|
+
EXIT_ACL_DENIED = 77
|
|
43
|
+
# Both namespace errors share exit code 78 per protocol spec.
|
|
44
|
+
EXIT_CONFIG_NAMESPACE_RESERVED = 78
|
|
45
|
+
EXIT_CONFIG_NAMESPACE_DUPLICATE = 78
|
|
46
|
+
EXIT_SIGINT = 130
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Map: error class -> exit code (mirrors TS exitCodeForError instanceof chain)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
EXIT_CODES: dict[type[BaseException], int] = {
|
|
53
|
+
ApprovalTimeoutError: EXIT_APPROVAL_TIMEOUT,
|
|
54
|
+
ApprovalDeniedError: EXIT_APPROVAL_DENIED,
|
|
55
|
+
AuthenticationError: EXIT_ACL_DENIED,
|
|
56
|
+
ConfigDecryptionError: EXIT_CONFIG_INVALID,
|
|
57
|
+
SchemaValidationError: EXIT_SCHEMA_VALIDATION_ERROR,
|
|
58
|
+
ModuleNotFoundError: EXIT_MODULE_NOT_FOUND,
|
|
59
|
+
ModuleExecutionError: EXIT_MODULE_EXECUTE_ERROR,
|
|
60
|
+
KeyboardInterrupt: EXIT_SIGINT,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Map: apcore wire error code -> exit code
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# The CLI's own exception classes (above) never match an error raised by the
|
|
68
|
+
# apcore runtime, which signals failure through ``ModuleError`` subclasses
|
|
69
|
+
# carrying a ``code`` attribute. Matching on that code is what keeps the
|
|
70
|
+
# canonical taxonomy (44 / 45 / 46 / 47 / 77) intact for every dispatch path —
|
|
71
|
+
# ``apcli`` system commands included, which route apcore errors straight to
|
|
72
|
+
# :func:`exit_code_for_error`. Mirrors the ``codeMap`` in TS ``exitCodeForError``
|
|
73
|
+
# and Rust ``cli::map_apcore_error_to_exit_code``.
|
|
74
|
+
APCORE_ERROR_CODE_MAP: dict[str, int] = {
|
|
75
|
+
"MODULE_NOT_FOUND": EXIT_MODULE_NOT_FOUND,
|
|
76
|
+
"MODULE_LOAD_ERROR": EXIT_MODULE_LOAD_ERROR,
|
|
77
|
+
"MODULE_DISABLED": EXIT_MODULE_DISABLED,
|
|
78
|
+
"DEPENDENCY_NOT_FOUND": EXIT_DEPENDENCY_NOT_FOUND,
|
|
79
|
+
"DEPENDENCY_VERSION_MISMATCH": EXIT_DEPENDENCY_VERSION_MISMATCH,
|
|
80
|
+
"SCHEMA_VALIDATION_ERROR": EXIT_SCHEMA_VALIDATION_ERROR,
|
|
81
|
+
"SCHEMA_CIRCULAR_REF": EXIT_SCHEMA_CIRCULAR_REF,
|
|
82
|
+
"APPROVAL_DENIED": EXIT_APPROVAL_DENIED,
|
|
83
|
+
"APPROVAL_TIMEOUT": EXIT_APPROVAL_TIMEOUT,
|
|
84
|
+
"APPROVAL_PENDING": EXIT_APPROVAL_DENIED,
|
|
85
|
+
"CONFIG_NOT_FOUND": EXIT_CONFIG_NOT_FOUND,
|
|
86
|
+
"CONFIG_INVALID": EXIT_CONFIG_INVALID,
|
|
87
|
+
"MODULE_EXECUTE_ERROR": EXIT_MODULE_EXECUTE_ERROR,
|
|
88
|
+
"MODULE_TIMEOUT": EXIT_MODULE_TIMEOUT,
|
|
89
|
+
"ACL_DENIED": EXIT_ACL_DENIED,
|
|
90
|
+
# Config Bus errors (apcore >= 0.15.0)
|
|
91
|
+
"CONFIG_NAMESPACE_RESERVED": EXIT_CONFIG_NAMESPACE_RESERVED,
|
|
92
|
+
"CONFIG_NAMESPACE_DUPLICATE": EXIT_CONFIG_NAMESPACE_DUPLICATE,
|
|
93
|
+
"CONFIG_ENV_PREFIX_CONFLICT": EXIT_CONFIG_NAMESPACE_DUPLICATE,
|
|
94
|
+
"CONFIG_ENV_MAP_CONFLICT": EXIT_CONFIG_NAMESPACE_DUPLICATE,
|
|
95
|
+
"CONFIG_MOUNT_ERROR": EXIT_CONFIG_MOUNT_ERROR,
|
|
96
|
+
"CONFIG_BIND_ERROR": EXIT_CONFIG_BIND_ERROR,
|
|
97
|
+
"ERROR_FORMATTER_DUPLICATE": EXIT_ERROR_FORMATTER_DUPLICATE,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def exit_code_for_error(err: BaseException) -> int:
|
|
102
|
+
"""Return the configured exit code for an error instance.
|
|
103
|
+
|
|
104
|
+
Resolves in two steps, matching TS ``exitCodeForError`` and the Rust
|
|
105
|
+
``ExitCode::from`` mapping: first the CLI's own exception classes
|
|
106
|
+
(:data:`EXIT_CODES`), then the apcore wire code the error carries
|
|
107
|
+
(:data:`APCORE_ERROR_CODE_MAP`). Falls back to ``1`` (generic execute
|
|
108
|
+
error) when neither matches.
|
|
109
|
+
"""
|
|
110
|
+
for cls, code in EXIT_CODES.items():
|
|
111
|
+
if isinstance(err, cls):
|
|
112
|
+
return code
|
|
113
|
+
|
|
114
|
+
# Fall back to the apcore wire code. None of the classes above match an
|
|
115
|
+
# error raised by the apcore runtime, so without this every apcore failure
|
|
116
|
+
# would collapse to 1 — losing the taxonomy that scripted callers read.
|
|
117
|
+
wire_code = getattr(err, "code", None)
|
|
118
|
+
wire_code = getattr(wire_code, "value", wire_code)
|
|
119
|
+
if isinstance(wire_code, str) and wire_code in APCORE_ERROR_CODE_MAP:
|
|
120
|
+
return APCORE_ERROR_CODE_MAP[wire_code]
|
|
121
|
+
return 1
|
|
@@ -101,8 +101,15 @@ def _format_health_summary_tty(result: dict[str, Any]) -> None:
|
|
|
101
101
|
rate = f"{m.get('error_rate', 0) * 100:.1f}%"
|
|
102
102
|
click.echo(f" {m['module_id']:<28} {m['status']:<12} {rate:<12} {top_str}")
|
|
103
103
|
|
|
104
|
+
# The health tiers are healthy / degraded / error / unknown — four, not
|
|
105
|
+
# three. `unknown` means "no calls recorded yet", which is the state every
|
|
106
|
+
# module in a fresh project is in, so omitting it made the summary line
|
|
107
|
+
# contradict the table right above it: the rows listed modules while the
|
|
108
|
+
# total read "no data". apcore >= 0.28.0 declares the four-tier set
|
|
109
|
+
# canonically in sys-health-summary.schema.json (§6.6); the SDKs have
|
|
110
|
+
# emitted `unknown` all along.
|
|
104
111
|
parts = []
|
|
105
|
-
for key in ("healthy", "degraded", "error"):
|
|
112
|
+
for key in ("healthy", "degraded", "error", "unknown"):
|
|
106
113
|
count = summary.get(key, 0)
|
|
107
114
|
if count:
|
|
108
115
|
parts.append(f"{count} {key}")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Tests for Approval Gate (FE-03)."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
from unittest.mock import MagicMock, patch
|
|
5
6
|
|
|
@@ -331,3 +332,190 @@ def test_validate_module_import_path():
|
|
|
331
332
|
# Same callable behind both names.
|
|
332
333
|
assert legacy_fp is new_fp
|
|
333
334
|
assert legacy_efc is new_efc
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class TestApprovalHandlerProtocolResult:
|
|
338
|
+
"""The gate reads the handler's return value by attribute, not by key.
|
|
339
|
+
|
|
340
|
+
apcore's ``BuiltinApprovalGate`` does ``result.status`` / ``result.approved_by``,
|
|
341
|
+
so a plain dict raised ``AttributeError`` inside the gate and reached the
|
|
342
|
+
caller as ``MODULE_EXECUTE_ERROR`` — every gate-routed approval failed.
|
|
343
|
+
Rust converts through ``cli_to_apcore_result`` and TypeScript's
|
|
344
|
+
``ApprovalResult`` is structurally typed; Python needed the same shape.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
# These drive the coroutine with `asyncio.run` rather than `async def` +
|
|
348
|
+
# `@pytest.mark.asyncio`: this suite declares no async plugin (there are no
|
|
349
|
+
# other async tests in it), so a marker-based test is silently collected and
|
|
350
|
+
# then fails at run time with "async def functions are not natively
|
|
351
|
+
# supported" wherever pytest-asyncio is not installed.
|
|
352
|
+
|
|
353
|
+
def test_request_approval_returns_attribute_addressable_result(self):
|
|
354
|
+
from apcore_cli.approval import CliApprovalHandler
|
|
355
|
+
|
|
356
|
+
handler = CliApprovalHandler(auto_approve=True)
|
|
357
|
+
result = asyncio.run(handler.request_approval(MagicMock(module_id="m.x", module_def=None)))
|
|
358
|
+
|
|
359
|
+
assert result.status == "approved"
|
|
360
|
+
assert result.approved_by == "auto_approve"
|
|
361
|
+
|
|
362
|
+
def test_check_approval_returns_attribute_addressable_result(self):
|
|
363
|
+
from apcore_cli.approval import CliApprovalHandler
|
|
364
|
+
|
|
365
|
+
result = asyncio.run(CliApprovalHandler().check_approval("approval-123"))
|
|
366
|
+
|
|
367
|
+
assert result.status == "rejected"
|
|
368
|
+
assert "async approval polling" in result.reason
|
|
369
|
+
|
|
370
|
+
def test_result_is_the_apcore_protocol_type(self):
|
|
371
|
+
"""Not merely duck-typed: the gate's audit path constructs from it."""
|
|
372
|
+
from apcore.approval import ApprovalResult
|
|
373
|
+
|
|
374
|
+
from apcore_cli.approval import CliApprovalHandler
|
|
375
|
+
|
|
376
|
+
result = asyncio.run(
|
|
377
|
+
CliApprovalHandler(auto_approve=True).request_approval(MagicMock(module_id="m.x", module_def=None))
|
|
378
|
+
)
|
|
379
|
+
assert isinstance(result, ApprovalResult)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class TestApprovalGateEndToEnd:
|
|
383
|
+
"""The handler must survive a real trip through apcore's approval gate."""
|
|
384
|
+
|
|
385
|
+
def _app_with_handler(self, auto_approve=True):
|
|
386
|
+
"""Build an app with the CLI handler wired to the executor.
|
|
387
|
+
|
|
388
|
+
``auto_approve`` selects the handler's disposition: True answers
|
|
389
|
+
"approved", False falls through to the TTY prompt, which under pytest
|
|
390
|
+
has no terminal and therefore rejects. The rejecting variant is what
|
|
391
|
+
makes these tests discriminating — a call that merely succeeds proves
|
|
392
|
+
nothing, since a gate that never fired would also let it through.
|
|
393
|
+
"""
|
|
394
|
+
from apcore import APCore
|
|
395
|
+
|
|
396
|
+
from apcore_cli.approval import CliApprovalHandler
|
|
397
|
+
|
|
398
|
+
app = APCore()
|
|
399
|
+
|
|
400
|
+
@app.module(id="danger.wipe", annotations={"requires_approval": True})
|
|
401
|
+
def wipe() -> dict:
|
|
402
|
+
return {"wiped": True}
|
|
403
|
+
|
|
404
|
+
@app.module(id="git.push", annotations={"requires_approval": False})
|
|
405
|
+
def git_push(remote: str = "origin", force: bool = False) -> dict:
|
|
406
|
+
return {"pushed": True, "force": force}
|
|
407
|
+
|
|
408
|
+
app.executor.set_approval_handler(CliApprovalHandler(auto_approve=auto_approve))
|
|
409
|
+
return app
|
|
410
|
+
|
|
411
|
+
def test_annotation_sourced_approval_executes(self):
|
|
412
|
+
app = self._app_with_handler()
|
|
413
|
+
assert app.executor.call("danger.wipe", {}) == {"wiped": True}
|
|
414
|
+
|
|
415
|
+
def test_acl_sourced_approval_executes(self):
|
|
416
|
+
"""apcore >= 0.28.0 (spec v1.28.0 §6.1.6-§6.1.8): an ACL rule may require
|
|
417
|
+
a human for a call whose module annotation says ``requires_approval: false``.
|
|
418
|
+
The gate then fires on a module the CLI's own pre-check skips."""
|
|
419
|
+
from apcore.acl import ACL, ACLRule
|
|
420
|
+
|
|
421
|
+
app = self._app_with_handler()
|
|
422
|
+
app.executor.set_acl(
|
|
423
|
+
ACL(
|
|
424
|
+
rules=[
|
|
425
|
+
ACLRule(
|
|
426
|
+
callers=["*"],
|
|
427
|
+
targets=["git.push"],
|
|
428
|
+
effect="allow",
|
|
429
|
+
approval="required",
|
|
430
|
+
conditions={"arguments": {"has_key": ["force"]}},
|
|
431
|
+
),
|
|
432
|
+
ACLRule(callers=["*"], targets=["*"], effect="allow"),
|
|
433
|
+
],
|
|
434
|
+
default_effect="deny",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Ungated call: the arguments condition is unsatisfied, no human needed.
|
|
439
|
+
assert app.executor.call("git.push", {"remote": "origin"}) == {
|
|
440
|
+
"pushed": True,
|
|
441
|
+
"force": False,
|
|
442
|
+
}
|
|
443
|
+
# Gated call: the ACL requires approval, the CLI handler answers it.
|
|
444
|
+
assert app.executor.call("git.push", {"remote": "origin", "force": True}) == {
|
|
445
|
+
"pushed": True,
|
|
446
|
+
"force": True,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
def test_preflight_reports_the_acl_sourced_requirement(self):
|
|
450
|
+
"""§7.9.5: ``validate()`` reports the governance-effective requirement,
|
|
451
|
+
which is what ``apcli validate`` forwards as ``requires_approval``."""
|
|
452
|
+
from apcore.acl import ACL, ACLRule
|
|
453
|
+
|
|
454
|
+
app = self._app_with_handler()
|
|
455
|
+
app.executor.set_acl(
|
|
456
|
+
ACL(
|
|
457
|
+
rules=[
|
|
458
|
+
ACLRule(
|
|
459
|
+
callers=["*"],
|
|
460
|
+
targets=["git.push"],
|
|
461
|
+
effect="allow",
|
|
462
|
+
approval="required",
|
|
463
|
+
conditions={"arguments": {"has_key": ["force"]}},
|
|
464
|
+
),
|
|
465
|
+
ACLRule(callers=["*"], targets=["*"], effect="allow"),
|
|
466
|
+
],
|
|
467
|
+
default_effect="deny",
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
assert app.executor.validate("git.push", {"remote": "origin"}).requires_approval is False
|
|
472
|
+
assert app.executor.validate("git.push", {"remote": "origin", "force": True}).requires_approval is True
|
|
473
|
+
|
|
474
|
+
def _acl_with_argument_scoped_rule(self):
|
|
475
|
+
from apcore.acl import ACL, ACLRule
|
|
476
|
+
|
|
477
|
+
return ACL(
|
|
478
|
+
rules=[
|
|
479
|
+
ACLRule(
|
|
480
|
+
callers=["*"],
|
|
481
|
+
targets=["git.push"],
|
|
482
|
+
effect="allow",
|
|
483
|
+
approval="required",
|
|
484
|
+
conditions={"arguments": {"has_key": ["force"]}},
|
|
485
|
+
),
|
|
486
|
+
ACLRule(callers=["*"], targets=["*"], effect="allow"),
|
|
487
|
+
],
|
|
488
|
+
default_effect="deny",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def test_a_refusing_handler_blocks_only_the_acl_matched_call(self):
|
|
492
|
+
"""The discriminating pair.
|
|
493
|
+
|
|
494
|
+
With auto-approve off and no TTY under pytest, ``CliApprovalHandler``
|
|
495
|
+
answers "rejected". If the gate did not fire — or fired but never
|
|
496
|
+
reached this handler — both calls would succeed and the assertion
|
|
497
|
+
below could not tell the difference.
|
|
498
|
+
"""
|
|
499
|
+
from apcore.errors import ApprovalDeniedError
|
|
500
|
+
|
|
501
|
+
app = self._app_with_handler(auto_approve=False)
|
|
502
|
+
app.executor.set_acl(self._acl_with_argument_scoped_rule())
|
|
503
|
+
|
|
504
|
+
# No `force` key: the rule does not match, the handler is never asked.
|
|
505
|
+
assert app.executor.call("git.push", {"remote": "origin"}) == {
|
|
506
|
+
"pushed": True,
|
|
507
|
+
"force": False,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
# `force` present: the ACL requires a human and the handler refused.
|
|
511
|
+
with pytest.raises(ApprovalDeniedError):
|
|
512
|
+
app.executor.call("git.push", {"remote": "origin", "force": True})
|
|
513
|
+
|
|
514
|
+
def test_a_refusing_handler_blocks_an_annotation_gated_module(self):
|
|
515
|
+
"""Same discrimination for the pre-0.28.0 source of the requirement."""
|
|
516
|
+
from apcore.errors import ApprovalDeniedError
|
|
517
|
+
|
|
518
|
+
app = self._app_with_handler(auto_approve=False)
|
|
519
|
+
|
|
520
|
+
with pytest.raises(ApprovalDeniedError):
|
|
521
|
+
app.executor.call("danger.wipe", {})
|