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.
Files changed (103) hide show
  1. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/CHANGELOG.md +59 -0
  2. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/PKG-INFO +4 -4
  3. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/pyproject.toml +3 -3
  4. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/approval.py +33 -15
  5. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/cli.py +9 -25
  6. apcore_cli-0.11.0/src/apcore_cli/exit_codes.py +121 -0
  7. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/system_cmd.py +8 -1
  8. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_approval.py +188 -0
  9. apcore_cli-0.11.0/tests/test_exit_codes.py +119 -0
  10. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_system_cmd.py +55 -0
  11. apcore_cli-0.10.4/src/apcore_cli/exit_codes.py +0 -74
  12. apcore_cli-0.10.4/tests/test_exit_codes.py +0 -58
  13. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.github/CODEOWNERS +0 -0
  14. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.github/copilot-ignore +0 -0
  15. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.github/workflows/ci.yml +0 -0
  16. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.gitignore +0 -0
  17. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.gitmessage +0 -0
  18. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/.pre-commit-config.yaml +0 -0
  19. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/CLAUDE.md +0 -0
  20. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/LICENSE +0 -0
  21. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/Makefile +0 -0
  22. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/README.md +0 -0
  23. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/README.md +0 -0
  24. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/math/add.py +0 -0
  25. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/math/multiply.py +0 -0
  26. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/sysutil/disk.py +0 -0
  27. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/sysutil/env.py +0 -0
  28. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/sysutil/info.py +0 -0
  29. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/text/reverse.py +0 -0
  30. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/text/upper.py +0 -0
  31. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/extensions/text/wordcount.py +0 -0
  32. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/examples/run_examples.sh +0 -0
  33. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/approval-gate.md +0 -0
  34. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/config-resolver.md +0 -0
  35. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/core-dispatcher.md +0 -0
  36. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/discovery.md +0 -0
  37. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/exposure-filtering.md +0 -0
  38. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/grouped-commands.md +0 -0
  39. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/output-formatter.md +0 -0
  40. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/overview.md +0 -0
  41. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/schema-parser.md +0 -0
  42. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/security-manager.md +0 -0
  43. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/shell-integration.md +0 -0
  44. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/planning/state.json +0 -0
  45. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/__init__.py +0 -0
  46. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/__main__.py +0 -0
  47. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/_sandbox_runner.py +0 -0
  48. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/builtin_group.py +0 -0
  49. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/config.py +0 -0
  50. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/discovery.py +0 -0
  51. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/display_helpers.py +0 -0
  52. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/exposure.py +0 -0
  53. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/factory.py +0 -0
  54. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/init_cmd.py +0 -0
  55. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/output.py +0 -0
  56. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/ref_resolver.py +0 -0
  57. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/schema_parser.py +0 -0
  58. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/__init__.py +0 -0
  59. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/audit.py +0 -0
  60. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/auth.py +0 -0
  61. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/config_encryptor.py +0 -0
  62. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/security/sandbox.py +0 -0
  63. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/shell.py +0 -0
  64. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/strategy.py +0 -0
  65. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/system_usage.py +0 -0
  66. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/src/apcore_cli/validate.py +0 -0
  67. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/__init__.py +0 -0
  68. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conformance/__init__.py +0 -0
  69. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conformance/test_apcli_visibility.py +0 -0
  70. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conformance/test_snake_case_kwargs.py +0 -0
  71. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/conftest.py +0 -0
  72. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/shell_test_utils.py +0 -0
  73. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_apcli_integration.py +0 -0
  74. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_bugfixes.py +0 -0
  75. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_builtin_group.py +0 -0
  76. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_cli.py +0 -0
  77. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_config.py +0 -0
  78. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_discovery.py +0 -0
  79. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_discovery_fe13.py +0 -0
  80. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_display_helpers.py +0 -0
  81. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_e2e.py +0 -0
  82. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_exposure.py +0 -0
  83. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_factory_fe13.py +0 -0
  84. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_init_cmd.py +0 -0
  85. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_integration.py +0 -0
  86. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_list_command_filters.py +0 -0
  87. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_output.py +0 -0
  88. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_output_format_exec.py +0 -0
  89. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_output_format_markdown_skill.py +0 -0
  90. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_public_api.py +0 -0
  91. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_ref_resolver.py +0 -0
  92. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_sandbox_runner.py +0 -0
  93. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_schema_parser.py +0 -0
  94. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/__init__.py +0 -0
  95. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_audit.py +0 -0
  96. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_auth.py +0 -0
  97. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_config_encryptor.py +0 -0
  98. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_security/test_sandbox.py +0 -0
  99. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_shell.py +0 -0
  100. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_strategy.py +0 -0
  101. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_system_usage.py +0 -0
  102. {apcore_cli-0.10.4 → apcore_cli-0.11.0}/tests/test_toolkit_integration.py +0 -0
  103. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: apcore-cli
3
- Version: 0.10.4
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.0
25
- Requires-Dist: apcore>=0.26.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.10.4"
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.26.0",
30
- "apcore-toolkit>=0.10.0",
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 {"status": "approved", "approved_by": "not_required"}
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 {"status": "approved", "approved_by": "not_required"}
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 {"status": "approved", "approved_by": "auto_approve"}
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 {"status": "approved", "approved_by": "env_auto_approve"}
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 {"status": "timeout", "reason": f"Timed out after {self.timeout}s"}
178
+ return _approval_result("timeout", reason=f"Timed out after {self.timeout}s")
158
179
 
159
180
  if approved:
160
- return {"status": "approved", "approved_by": "tty_user"}
161
- return {"status": "rejected", "reason": "User rejected"}
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 EXIT_SCHEMA_CIRCULAR_REF, EXIT_SCHEMA_VALIDATION_ERROR
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
- _ERROR_CODE_MAP = {
433
- "MODULE_NOT_FOUND": 44,
434
- "MODULE_LOAD_ERROR": 44,
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", {})