diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d7386f8a..bf5100f3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,7 +23,7 @@ own the first two layers or hosted product/account logic. - Warroom modules infer observable site/action/API/state graphs from evidence; they do not own product business logic and do not treat LLM output as a gate. - `docs/reference/` is generated from Python AST and repository assets. It maps - 967 maintained Python files, 5,694 declarations, 487 literal module + 967 maintained Python files, 5,695 declarations, 487 literal module registrations, 28 HTTP operations, 108 environment names, CLI parsers, recipes, bundles, and workflows back to source. diff --git a/CHANGELOG.md b/CHANGELOG.md index f8078098..799d6b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.31.1] +### Security + +- Closed a module-policy bypass in `verify.spec` (GHSA-wmwj-g59x-c8px). The + spec runner picks its child modules out of the caller's own ruleset — + `rules[].source.module` / `rules[].target.module` with free-form params — and + dispatched them with `instance.execute()`. Both locks live in + `BaseModule.run()`, so a caller who had been restricted to `verify.spec` + (`FLYTO_MODULE_ALLOWLIST=verify.spec`, no `FLYTO_GRANTED_PERMISSIONS`) could + name `shell.exec` in a rule and run host commands as the service account, + with neither the module filter nor the `shell.execute` grant consulted. The + dispatcher now calls the policy-gated `run()`, a denied child raises instead + of being reported as an ordinary failed rule, and `POST /v1/execute` gained + the nested-module pre-flight the MCP transport already had, so the ruleset is + refused before `verify.spec` does any work. `_execute_with_resilience` no + longer retries or repackages a `ModulePolicyError` — a blocked module now + reads as blocked rather than broken. A registry-wide test fails on any future + code that resolves a module by a caller-supplied id and calls `execute()` + directly. + ### Changed - `crypto.totp` says when `min_remaining` makes it wait. A code that would diff --git a/SECURITY_STATUS.md b/SECURITY_STATUS.md index 6099951e..fdc608d9 100644 --- a/SECURITY_STATUS.md +++ b/SECURITY_STATUS.md @@ -2,11 +2,11 @@ # Security Status -**Current release: `2.31.1`.** Every advisory published against this project is fixed as of `2.29.0`, and every one has a named regression test that runs in CI. +**Current release: `2.31.1`.** Every advisory published against this project is fixed as of `2.31.1`, and every one has a named regression test that runs in CI. -Installing `>= 2.29.0` clears every known advisory; `2.31.1` is the supported line and the one that receives fixes. See [`SECURITY.md`](SECURITY.md#supported-versions). +Installing `>= 2.31.1` clears every known advisory; `2.31.1` is the supported line and the one that receives fixes. See [`SECURITY.md`](SECURITY.md#supported-versions). -38 advisories have been published and fixed (7 critical, 25 high, 6 medium). They are listed here in full, oldest patch first, because the count is less informative than the pattern: almost all of them are two defects — a caller-supplied path reaching a filesystem sink, and a caller-supplied target reaching the network — found one module at a time. +39 advisories have been published and fixed (8 critical, 25 high, 6 medium). They are listed here in full, oldest patch first, because the count is less informative than the pattern: almost all of them are two defects — a caller-supplied path reaching a filesystem sink, and a caller-supplied target reaching the network — found one module at a time. ## How the recurrence was stopped @@ -65,6 +65,7 @@ Applying those audits closed roughly 30 further modules that no advisory had nam | [GHSA-f9q4-fp8j-r5h7](https://github.com/flytohub/flyto-core/security/advisories/GHSA-f9q4-fp8j-r5h7) | high | `<= 2.28.1` | `2.29.0` | [`test_llm_agent_inline_base_url_is_ssrf_guarded`](tests/core/test_reported_advisories_2026_08_19.py) [`test_llm_agent_reports_ssrf_blocked`](tests/core/test_reported_advisories_2026_08_19.py) | | [GHSA-pp5w-w9c3-qfv2](https://github.com/flytohub/flyto-core/security/advisories/GHSA-pp5w-w9c3-qfv2) | high | `<= 2.28.1` | `2.29.0` | [`test_llm_agent_inline_base_url_is_ssrf_guarded`](tests/core/test_reported_advisories_2026_08_19.py) [`test_llm_agent_sub_node_config_base_url_is_ssrf_guarded`](tests/core/test_reported_advisories_2026_08_19.py) | | [GHSA-9x26-9vhm-2qhw](https://github.com/flytohub/flyto-core/security/advisories/GHSA-9x26-9vhm-2qhw) | high | `<= 2.28.1` | `2.29.0` | [`test_client_dsn_cannot_reach_a_private_target`](tests/core/test_reported_advisories_2026_08_19.py) [`test_mongodb_multi_host_dsn_checks_every_host`](tests/core/test_reported_advisories_2026_08_19.py) | +| [GHSA-wmwj-g59x-c8px](https://github.com/flytohub/flyto-core/security/advisories/GHSA-wmwj-g59x-c8px) | critical | `<= 2.31.0` | `2.31.1` | [`test_verify_spec_ruleset_cannot_run_a_denied_module`](tests/core/test_reported_security_advisories.py) | ## Reporting diff --git a/STATE.md b/STATE.md index e6500503..c13f4e87 100644 --- a/STATE.md +++ b/STATE.md @@ -2,6 +2,17 @@ ## Current State +- `verify.spec` can no longer be used as a launcher for a module the caller was + denied (GHSA-wmwj-g59x-c8px). Its rules name the child module and its params, + and the dispatcher called `execute()` on them — skipping `BaseModule.run()`, + where both the module filter and the dangerous-permission grant live. Under + `FLYTO_MODULE_ALLOWLIST=verify.spec` with no grants, a ruleset naming + `shell.exec` ran host commands. The child now goes through `run()`, a denied + child raises instead of being reported as a failed rule, and `POST /v1/execute` + applies the nested-module pre-flight the MCP transport already had. A + registry-wide test fails on any future dispatcher that resolves a module by a + caller-supplied id and calls `execute()` directly. + - `crypto.totp` closes the one gap that kept an authenticator-protected site outside flyto-core: the six digits exist only inside a browser login flow and no API hands them over. A secret is accepted as the Base32 setup key in any @@ -246,7 +257,7 @@ - The 60% line coverage gate measures the maintained orchestration and security-control kernel. Pluggable module implementations and product overlays remain covered by catalog, contract, and integration suites. -- Source-backed documentation now covers 967 maintained Python files, 5,694 +- Source-backed documentation now covers 967 maintained Python files, 5,695 declarations, 487 literal module registrations, all CLI/HTTP/environment surfaces (28 static HTTP operations, 108 environment names), and all maintained recipe/workflow assets. CI rejects drift, missing ownership, @@ -356,6 +367,36 @@ ## Last Verification +### 2026-08-25 — verify.spec policy-gate bypass (GHSA-wmwj-g59x-c8px): VERIFIED + +The reporter's own proof of concept was run against the built artifact, not the +source tree. Installed from PyPI, `flyto-core[api]==2.31.0` reproduces: +`exploit_reproduced: true`, `marker_outside_sandbox: true`, with +`FLYTO_MODULE_ALLOWLIST=verify.spec` and no `FLYTO_GRANTED_PERMISSIONS`. +Installed from the wheel built here, `2.31.1` refuses the same request in all +three reported configurations (default denylist, strict allowlist, and the +`target` rule branch): `bypass_top_level_ok: false`, +`marker_outside_sandbox: false`, error `Module 'verify.spec' declares nested +module(s) blocked by security policy: shell.exec`. The negative control holds in +every run — a direct `shell.exec` request stays blocked. + +Repository gates on this tree: project-memory lint PASS, documentation contract +PASS (145 Markdown files, 1266 owned source/config files, 192 local links), +brand PASS, release drift PASS (2.31.1 unreleased), Ruff clean on every changed +file (the four remaining findings are pre-existing and untouched), offline suite +**3175 passed, 11 skipped, 275 deselected**, 63.67% coverage against the 60% +floor, `python -m build` plus `twine check` PASS on both artifacts, `npm audit` +0 vulnerabilities, and strict Indexer `flyto-index verify . --strict --full-scan` +20/20 PASS including the `.flyto-rules.yaml` policy check. + +The new regression tests were confirmed to fail on the unpatched code before the +fix landed, so they test the defect rather than the implementation. + +Not verified: `flyto-index task validate` could not run against this repo — it +drives the indexer's own interpreter, which has no `pytest-cov`, and its +repository-wide Ruff scan includes `examples/`, which CI does not lint. The +browser and e2e markers were not run. + ### 2026-08-12 — capability, extension and runtime closure: VERIFIED Codex independently reviewed the full working diff and found one additional diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0f0591c9..ad234bbc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -72,7 +72,7 @@ evidence but does not decide the gate. - [All 480 active module schemas](TOOL_CATALOG.md) - [All 487 literal module implementations](reference/registered-modules.md) -- [All 5,694 maintained Python declarations](reference/python-api.md) +- [All 5,695 maintained Python declarations](reference/python-api.md) - [All CLI parsers](reference/cli.md) - [All HTTP decorators](reference/http-api.md) - [All environment readers and packaged workflow assets](reference/configuration.md) diff --git a/docs/MIGRATION_STATUS.md b/docs/MIGRATION_STATUS.md index 64abdc32..2a7c38fb 100644 --- a/docs/MIGRATION_STATUS.md +++ b/docs/MIGRATION_STATUS.md @@ -7,8 +7,8 @@ | Runtime catalog | 480 modules, 88 categories | | Literal module registrations | 487 | | Packaged recipes | 41 | -| Maintained Python source | 967 files, 202,032 lines | -| Python declarations | 5,694 across 818 files | +| Maintained Python source | 967 files, 202,119 lines | +| Python declarations | 5,695 across 818 files | | Static CLI parsers | Generated in `reference/cli.md` | | Static HTTP operations | 28 | | Environment-variable names | 108 | diff --git a/docs/README.md b/docs/README.md index 4714d140..3396b46e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,7 +65,7 @@ The generated layer makes source coverage auditable without turning narrative guides into hand-maintained symbol dumps: - 480 active runtime modules across 88 catalog categories. -- 967 maintained Python files and 5,694 declarations. +- 967 maintained Python files and 5,695 declarations. - 487 literal module registrations linked to source. - every static CLI parser and HTTP decorator. - 108 environment-variable readers. diff --git a/docs/WHITEPAPER.md b/docs/WHITEPAPER.md index 7d17860b..e562b545 100644 --- a/docs/WHITEPAPER.md +++ b/docs/WHITEPAPER.md @@ -12,7 +12,7 @@ The current generated runtime catalog contains 480 modules across 88 categories and 41 packaged recipes. Catalog search and detail carry each module's registry-declared `provides_capability` and `plugin`, never a value derived from the module ID. Source traceability covers 967 maintained Python files, -202,032 lines, and 5,694 class/function/method declarations. These measurements +202,119 lines, and 5,695 class/function/method declarations. These measurements come from checked generators and are not hand-maintained marketing totals. ## Problem diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 88be2898..dff80b94 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -22,7 +22,7 @@ Static FastAPI decorators define **28 operations**. Mount status is explicit: a | `POST` | `/run` | `run` | internal key | verification service | HTTP operation; linked handler is authoritative. | [`src/core/verification_service.py:447`](https://github.com/flytohub/flyto-core/blob/main/src/core/verification_service.py#L447) | | `GET` | `/v1/capabilities` | `get_capabilities` | none | Execution API | Deterministic capability manifest for this installation. | [`src/core/api/routes/modules.py:98`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L98) | | `POST` | `/v1/capabilities/refresh` | `refresh_capabilities` | bearer token | Execution API | Re-run plugin discovery and rebuild the capability manifest. | [`src/core/api/routes/modules.py:118`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L118) | -| `POST` | `/v1/execute` | `execute_module` | bearer token | Execution API | Execute a single module. | [`src/core/api/routes/modules.py:137`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L137) | +| `POST` | `/v1/execute` | `execute_module` | bearer token | Execution API | Execute a single module. | [`src/core/api/routes/modules.py:186`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L186) | | `GET` | `/v1/extensions` | `list_extensions` | bearer token | Execution API | Installed extensions of every supported kind. | [`src/core/api/routes/extensions.py:183`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/extensions.py#L183) | | `POST` | `/v1/extensions/install` | `install_extension` | bearer token | Execution API | Install or upgrade one extension. | [`src/core/api/routes/extensions.py:227`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/extensions.py#L227) | | `GET` | `/v1/extensions/kinds` | `list_extension_kinds` | bearer token | Execution API | The supported extension kinds, served from the same table the installer enforces — so a client's idea of what is installable cannot drift from Core's. | [`src/core/api/routes/extensions.py:205`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/extensions.py#L205) | diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index acea4708..d434c80b 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -2,7 +2,7 @@ # Python Declaration Reference -Every class, function, nested function, and method in maintained runtime, CLI, script, example, and plugin-template sources: **5,694 declarations across 818 files**. +Every class, function, nested function, and method in maintained runtime, CLI, script, example, and plugin-template sources: **5,695 declarations across 818 files**. ## `demo.py` @@ -812,7 +812,8 @@ Every class, function, nested function, and method in maintained runtime, CLI, s | function | `async def get_module_info(module_id: str)` | Get detailed module information including params schema and examples. | [`src/core/api/routes/modules.py:83`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L83) | | function | `async def get_capabilities()` | Deterministic capability manifest for this installation. | [`src/core/api/routes/modules.py:98`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L98) | | function | `async def refresh_capabilities()` | Re-run plugin discovery and rebuild the capability manifest. | [`src/core/api/routes/modules.py:118`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L118) | -| function | `async def execute_module(body: ExecuteModuleRequest, request: Request)` | Execute a single module. | [`src/core/api/routes/modules.py:137`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L137) | +| function | `def _nested_policy_error(module_id: str, params: Dict[str, Any]) -> Optional[str]` | Reason string when the request's nested module ids are denied, else None. | [`src/core/api/routes/modules.py:136`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L136) | +| function | `async def execute_module(body: ExecuteModuleRequest, request: Request)` | Execute a single module. | [`src/core/api/routes/modules.py:186`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L186) | ## `src/core/api/routes/replay.py` @@ -6535,18 +6536,18 @@ Every class, function, nested function, and method in maintained runtime, CLI, s | Kind | Signature | Responsibility | Source | |---|---|---|---| -| class | `class SpecResult` | 单条规则的验证结果 | [`src/core/modules/atomic/verify/spec_runner.py:45`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L45) | -| method | `def SpecResult.coverage(self) -> float` | 计算覆盖率百分比 | [`src/core/modules/atomic/verify/spec_runner.py:57`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L57) | -| method | `def SpecResult.to_dict(self) -> Dict[str, Any]` | Implements `SpecResult.to_dict`; linked source is authoritative. | [`src/core/modules/atomic/verify/spec_runner.py:68`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L68) | -| function | `async def execute_module_dynamic(module_id: str, params: Dict[str, Any], context: Optional[Dict]=None) -> Dict[str, Any]` | 动态执行任意 flyto-core 模组 | [`src/core/modules/atomic/verify/spec_runner.py:84`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L84) | -| function | `def extract_keys(data: Any, key_field: Optional[str]=None) -> Set[str]` | 从模组输出中提取 keys | [`src/core/modules/atomic/verify/spec_runner.py:114`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L114) | -| function | `def compare_keys(source_keys: Set[str], target_keys: Set[str], direction: str='bidirectional') -> tuple` | 比较两个 key 集合 | [`src/core/modules/atomic/verify/spec_runner.py:179`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L179) | -| function | `async def run_spec_rule(rule: Dict[str, Any]) -> SpecResult` | 执行单条验证规则 | [`src/core/modules/atomic/verify/spec_runner.py:207`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L207) | -| function | `async def run_spec_ruleset(ruleset: Dict[str, Any]) -> Dict[str, Any]` | 执行整个 ruleset | [`src/core/modules/atomic/verify/spec_runner.py:271`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L271) | -| function | `def load_spec_ruleset(path: Union[str, Path]) -> Dict[str, Any]` | 从 YAML 文件加载 ruleset | [`src/core/modules/atomic/verify/spec_runner.py:300`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L300) | -| class | `class VerifySpecModule(BaseModule)` | 动态 Spec 验证模组 | [`src/core/modules/atomic/verify/spec_runner.py:345`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L345) | -| method | `def VerifySpecModule.validate_params(self) -> None` | Implements `VerifySpecModule.validate_params`; linked source is authoritative. | [`src/core/modules/atomic/verify/spec_runner.py:351`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L351) | -| method | `async def VerifySpecModule.execute(self) -> Dict[str, Any]` | Implements `VerifySpecModule.execute`; linked source is authoritative. | [`src/core/modules/atomic/verify/spec_runner.py:367`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L367) | +| class | `class SpecResult` | 单条规则的验证结果 | [`src/core/modules/atomic/verify/spec_runner.py:46`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L46) | +| method | `def SpecResult.coverage(self) -> float` | 计算覆盖率百分比 | [`src/core/modules/atomic/verify/spec_runner.py:58`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L58) | +| method | `def SpecResult.to_dict(self) -> Dict[str, Any]` | Implements `SpecResult.to_dict`; linked source is authoritative. | [`src/core/modules/atomic/verify/spec_runner.py:69`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L69) | +| function | `async def execute_module_dynamic(module_id: str, params: Dict[str, Any], context: Optional[Dict]=None) -> Dict[str, Any]` | 动态执行任意 flyto-core 模组 | [`src/core/modules/atomic/verify/spec_runner.py:85`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L85) | +| function | `def extract_keys(data: Any, key_field: Optional[str]=None) -> Set[str]` | 从模组输出中提取 keys | [`src/core/modules/atomic/verify/spec_runner.py:128`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L128) | +| function | `def compare_keys(source_keys: Set[str], target_keys: Set[str], direction: str='bidirectional') -> tuple` | 比较两个 key 集合 | [`src/core/modules/atomic/verify/spec_runner.py:193`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L193) | +| function | `async def run_spec_rule(rule: Dict[str, Any]) -> SpecResult` | 执行单条验证规则 | [`src/core/modules/atomic/verify/spec_runner.py:221`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L221) | +| function | `async def run_spec_ruleset(ruleset: Dict[str, Any]) -> Dict[str, Any]` | 执行整个 ruleset | [`src/core/modules/atomic/verify/spec_runner.py:290`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L290) | +| function | `def load_spec_ruleset(path: Union[str, Path]) -> Dict[str, Any]` | 从 YAML 文件加载 ruleset | [`src/core/modules/atomic/verify/spec_runner.py:319`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L319) | +| class | `class VerifySpecModule(BaseModule)` | 动态 Spec 验证模组 | [`src/core/modules/atomic/verify/spec_runner.py:364`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L364) | +| method | `def VerifySpecModule.validate_params(self) -> None` | Implements `VerifySpecModule.validate_params`; linked source is authoritative. | [`src/core/modules/atomic/verify/spec_runner.py:370`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L370) | +| method | `async def VerifySpecModule.execute(self) -> Dict[str, Any]` | Implements `VerifySpecModule.execute`; linked source is authoritative. | [`src/core/modules/atomic/verify/spec_runner.py:386`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L386) | ## `src/core/modules/atomic/verify/visual_diff.py` @@ -6743,15 +6744,15 @@ Every class, function, nested function, and method in maintained runtime, CLI, s | method | `async def BaseModule.execute_all(self, items: List['Item'], context: 'ItemContext') -> List['Item']` | Process all items at once (execution_mode="all"). | [`src/core/modules/base.py:184`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L184) | | method | `async def BaseModule.run(self) -> Any` | Execute module with Phase 2 enhancements: - Timeout support - Retry logic - Error handling | [`src/core/modules/base.py:222`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L222) | | method | `async def BaseModule._execute_with_resilience(self, timeout: Optional[int]=None, retryable: bool=False, max_retries: int=DEFAULT_MAX_RETRIES) -> Any` | Execute with timeout and/or retry support. | [`src/core/modules/base.py:277`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L277) | -| method | `def BaseModule.get_metadata(self) -> Dict[str, Any]` | Get module metadata. | [`src/core/modules/base.py:356`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L356) | -| method | `def BaseModule.get_param(self, name: str, default: Any=None) -> Any` | Get a parameter value with optional default. | [`src/core/modules/base.py:370`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L370) | -| method | `def BaseModule.require_param(self, name: str) -> Any` | Get a required parameter value. | [`src/core/modules/base.py:383`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L383) | -| method | `def BaseModule.success(self, data: Any=None, message: Optional[str]=None) -> Dict[str, Any]` | Create a standard success result. | [`src/core/modules/base.py:409`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L409) | -| method | `def BaseModule.failure(self, code: str, message: str, field: Optional[str]=None, hint: Optional[str]=None) -> Dict[str, Any]` | Create a standard failure result. | [`src/core/modules/base.py:431`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L431) | -| method | `def BaseModule.validate_params_v2(self, required: Optional[List[str]]=None, types: Optional[Dict[str, Type]]=None) -> Optional[Dict[str, Any]]` | Validate parameters using the new validation system. | [`src/core/modules/base.py:465`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L465) | -| method | `def BaseModule.raise_validation_error(self, message: str, field: Optional[str]=None, hint: Optional[str]=None) -> None` | Raise a validation error. | [`src/core/modules/base.py:514`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L514) | -| method | `def BaseModule.raise_error(self, error_class: type, message: str, **kwargs) -> None` | Raise a module error. | [`src/core/modules/base.py:540`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L540) | -| method | `def BaseModule.make_result(self, data: Any=None) -> ModuleResult` | Create a ModuleResult from data. | [`src/core/modules/base.py:565`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L565) | +| method | `def BaseModule.get_metadata(self) -> Dict[str, Any]` | Get module metadata. | [`src/core/modules/base.py:364`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L364) | +| method | `def BaseModule.get_param(self, name: str, default: Any=None) -> Any` | Get a parameter value with optional default. | [`src/core/modules/base.py:378`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L378) | +| method | `def BaseModule.require_param(self, name: str) -> Any` | Get a required parameter value. | [`src/core/modules/base.py:391`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L391) | +| method | `def BaseModule.success(self, data: Any=None, message: Optional[str]=None) -> Dict[str, Any]` | Create a standard success result. | [`src/core/modules/base.py:417`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L417) | +| method | `def BaseModule.failure(self, code: str, message: str, field: Optional[str]=None, hint: Optional[str]=None) -> Dict[str, Any]` | Create a standard failure result. | [`src/core/modules/base.py:439`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L439) | +| method | `def BaseModule.validate_params_v2(self, required: Optional[List[str]]=None, types: Optional[Dict[str, Type]]=None) -> Optional[Dict[str, Any]]` | Validate parameters using the new validation system. | [`src/core/modules/base.py:473`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L473) | +| method | `def BaseModule.raise_validation_error(self, message: str, field: Optional[str]=None, hint: Optional[str]=None) -> None` | Raise a validation error. | [`src/core/modules/base.py:522`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L522) | +| method | `def BaseModule.raise_error(self, error_class: type, message: str, **kwargs) -> None` | Raise a module error. | [`src/core/modules/base.py:548`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L548) | +| method | `def BaseModule.make_result(self, data: Any=None) -> ModuleResult` | Create a ModuleResult from data. | [`src/core/modules/base.py:573`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L573) | ## `src/core/modules/builtin/__init__.py` diff --git a/docs/reference/registered-modules.md b/docs/reference/registered-modules.md index 592796a8..490ec171 100644 --- a/docs/reference/registered-modules.md +++ b/docs/reference/registered-modules.md @@ -480,7 +480,7 @@ The AST contains **487 explicit, literal `@register_module` declarations**. This | `verify.report` | `1.0.0` | `verify` | `VerifyReportModule` | no | `['file.write']` | [`src/core/modules/atomic/verify/report.py:67`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/report.py#L67) | | `verify.ruleset` | `1.0.0` | `verify` | `VerifyRulesetModule` | no | `['file.read']` | [`src/core/modules/atomic/verify/ruleset.py:186`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/ruleset.py#L186) | | `verify.run` | `1.0.0` | `verify` | `VerifyRunModule` | no | `['browser.read', 'file.write']` | [`src/core/modules/atomic/verify/runner.py:91`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/runner.py#L91) | -| `verify.spec` | `1.0.0` | `verify` | `VerifySpecModule` | no | `[]` | [`src/core/modules/atomic/verify/spec_runner.py:345`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L345) | +| `verify.spec` | `1.0.0` | `verify` | `VerifySpecModule` | no | `[]` | [`src/core/modules/atomic/verify/spec_runner.py:364`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L364) | | `verify.visual_diff` | `2.0.0` | `verify` | `VerifyVisualDiffModule` | no | `['browser.automation', 'file.write']` | [`src/core/modules/atomic/verify/visual_diff.py:258`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/visual_diff.py#L258) | | `vision.analyze` | `1.0.0` | `atomic` | `vision_analyze` | yes | `['filesystem.read', 'filesystem.write']` | [`src/core/modules/atomic/vision/analyze.py:126`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/vision/analyze.py#L126) | | `vision.compare` | `1.0.0` | `atomic` | `vision_compare` | yes | `[]` | [`src/core/modules/atomic/vision/compare.py:123`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/vision/compare.py#L123) | diff --git a/docs/reference/source-modules.md b/docs/reference/source-modules.md index d25ba96e..ca21768f 100644 --- a/docs/reference/source-modules.md +++ b/docs/reference/source-modules.md @@ -2,7 +2,7 @@ # Source Module Inventory -Inventory: **967 Python files**, **202,032 lines**, and **5,694 class/function/method declarations**. Test files are covered by the test suite rather than treated as public implementation. +Inventory: **967 Python files**, **202,119 lines**, and **5,695 class/function/method declarations**. Test files are covered by the test suite rather than treated as public implementation. | Source module | Lines | Declarations | Import roots | Responsibility | |---|---:|---:|---|---| @@ -83,7 +83,7 @@ Inventory: **967 Python files**, **202,032 lines**, and **5,694 class/function/m | [`src/core/api/routes/__init__.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/__init__.py#L1) | 25 | 0 | `extensions, mcp, modules, replay, workflows` | Aggregates the Core HTTP API routers that the API server mounts. | | [`src/core/api/routes/extensions.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/extensions.py#L1) | 290 | 9 | `core, fastapi, logging, os, pydantic, security, typing` | Extension Routes | | [`src/core/api/routes/mcp.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/mcp.py#L1) | 302 | 11 | `base64, binascii, core, fastapi, json, secrets, security, typing` | MCP Streamable HTTP Transport | -| [`src/core/api/routes/modules.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L1) | 284 | 5 | `core, fastapi, models, security, time, typing, uuid` | Module Routes | +| [`src/core/api/routes/modules.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/modules.py#L1) | 344 | 6 | `core, fastapi, models, security, time, typing, uuid` | Module Routes | | [`src/core/api/routes/replay.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/replay.py#L1) | 115 | 2 | `core, fastapi, logging, models, security` | Replay Routes | | [`src/core/api/routes/workflows.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/routes/workflows.py#L1) | 218 | 4 | `contextlib, core, evidence_hooks, fastapi, json, logging, models, os, security, time, uuid` | Workflow Routes | | [`src/core/api/security.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/api/security.py#L1) | 209 | 10 | `core, fastapi, logging, os, pathlib, secrets, typing` | Security — CORS, Bearer Token Auth, Module Denylist/Allowlist | @@ -706,7 +706,7 @@ Inventory: **967 Python files**, **202,032 lines**, and **5,694 class/function/m | [`src/core/modules/atomic/verify/report.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/report.py#L1) | 287 | 7 | `base, datetime, html, json, pathlib, registry, schema, typing, utils` | Verify Report Module - Generate verification reports | | [`src/core/modules/atomic/verify/ruleset.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/ruleset.py#L1) | 209 | 12 | `base, dataclasses, pathlib, registry, schema, typing, utils, yaml` | Verify Ruleset Module - Load verification rules from YAML | | [`src/core/modules/atomic/verify/runner.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/runner.py#L1) | 388 | 8 | `base, capture, compare, core, figma, pathlib, registry, report, ruleset, schema, typing, utils` | Verify Run Module - Main entry point for design verification | -| [`src/core/modules/atomic/verify/spec_runner.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L1) | 381 | 12 | `base, dataclasses, datetime, json, logging, pathlib, registry, schema, typing, utils, yaml` | Spec-as-Test Dynamic Runner | +| [`src/core/modules/atomic/verify/spec_runner.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/spec_runner.py#L1) | 400 | 12 | `base, dataclasses, datetime, json, logging, module_policy, pathlib, registry, schema, typing, utils, yaml` | Spec-as-Test Dynamic Runner | | [`src/core/modules/atomic/verify/visual_diff.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/verify/visual_diff.py#L1) | 393 | 7 | `PIL, base, datetime, html, logging, os, pathlib, playwright, registry, schema, testing, typing` | Verify Visual Diff Module - End-to-end visual comparison pipeline | | [`src/core/modules/atomic/vision/__init__.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/vision/__init__.py#L1) | 11 | 0 | `analyze, compare` | Vision Analysis Modules AI-powered image and screenshot analysis using Vision APIs | | [`src/core/modules/atomic/vision/analyze.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/atomic/vision/analyze.py#L1) | 404 | 4 | `aiohttp, base64, httpx, json, logging, os, pathlib, re, registry, schema, typing, utils` | Vision Analyze Module Analyze images/screenshots using OpenAI Vision API (GPT-4V) | @@ -723,7 +723,7 @@ Inventory: **967 Python files**, **202,032 lines**, and **5,694 class/function/m | [`src/core/modules/audit/report_generator.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/audit/report_generator.py#L1) | 268 | 9 | `__future__, collections, datetime, json, os, schema_auditor, standards, typing` | Report Generator - Generate audit reports in various formats | | [`src/core/modules/audit/schema_auditor.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/audit/schema_auditor.py#L1) | 371 | 21 | `__future__, collections, dataclasses, registry, standards, typing` | Schema Auditor - Main audit logic for module schemas | | [`src/core/modules/audit/standards.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/audit/standards.py#L1) | 239 | 1 | `enum, typing` | Quality Standards - Defines schema quality requirements | -| [`src/core/modules/base.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L1) | 581 | 18 | `abc, asyncio, constants, errors, items, logging, module_policy, registry, result, typing, validation` | Base Module Class with Phase 2 execution support and Item-based execution. | +| [`src/core/modules/base.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/base.py#L1) | 589 | 18 | `abc, asyncio, constants, errors, items, logging, module_policy, registry, result, typing, validation` | Base Module Class with Phase 2 execution support and Item-based execution. | | [`src/core/modules/builtin/__init__.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/builtin/__init__.py#L1) | 233 | 5 | `atomic, logging, registry, typing` | Builtin Modules | | [`src/core/modules/catalog.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/catalog.py#L1) | 388 | 10 | `copy, licensing, logging, re, typing` | Module Catalog - Public view sanitization and catalog utilities. | | [`src/core/modules/composite/__init__.py:1`](https://github.com/flytohub/flyto-core/blob/main/src/core/modules/composite/__init__.py#L1) | 97 | 2 | `base, browser, data, developer, notification, test` | Composite Modules (Level 3) | diff --git a/handoffs/2026-08-25-verify-spec-policy-gate.md b/handoffs/2026-08-25-verify-spec-policy-gate.md new file mode 100644 index 00000000..70ed3a03 --- /dev/null +++ b/handoffs/2026-08-25-verify-spec-policy-gate.md @@ -0,0 +1,92 @@ +# verify.spec policy-gate bypass (GHSA-wmwj-g59x-c8px) + +Owner: claude +Branch: claude/verify-spec-policy-gate +Date: 2026-08-25 + +## What changed + +- `src/core/modules/atomic/verify/spec_runner.py` — `execute_module_dynamic()` + now calls the policy-gated `instance.run()` instead of `instance.execute()`. + A `ModulePolicyError` from a child is re-raised rather than folded into a + per-rule error, so a denied module fails the whole `verify.spec` call instead + of reading like an ordinary failed verification. +- `src/core/modules/base.py` — `_execute_with_resilience()` re-raises + `ModulePolicyError` untouched. It used to be retried like a transient failure + and then repackaged as a generic `Exception`, which hid from the caller that + the module was blocked rather than broken. +- `src/core/api/routes/modules.py` — `POST /v1/execute` gained + `_nested_policy_error()`, the nested-module pre-flight the MCP transport + already had. It reuses `core.mcp_handler._collect_workflow_module_ids` and + `_module_missing_permissions` so the two boundaries cannot drift. +- `tests/core/test_reported_security_advisories.py` — the advisory's named + regression test, `test_verify_spec_ruleset_cannot_run_a_denied_module`. +- `tests/core/test_policy_chokepoint.py` — the dispatch matrix (both rule + branches, default denylist, strict allowlist, allowed-but-ungranted), the + MCP and REST boundary checks, and a registry-wide AST test that fails on any + function that resolves a module by a non-constant id and then awaits + `.execute()`. +- `security/advisories.json` + `SECURITY_STATUS.md`, `CHANGELOG.md`, + `STATE.md`, `ARCHITECTURE.md`, `docs/` inventory tokens and + `docs/reference/` regenerated. + +## Why + +`verify.spec` picks its child modules out of the caller's own ruleset — +`rules[].source.module` / `rules[].target.module`, with free-form params — and +dispatched them with `execute()`. Both locks (the module filter and the +dangerous-permission grant) live in `BaseModule.run()`, so a caller restricted +to `verify.spec` could name `shell.exec` in a rule and run host commands as the +service account. This is the same omission that was fixed for nested +Warroom/test steps in `testing/runner.py` (GHSA-675h-j4qg-m52x); `verify.spec` +was the sibling call site that was missed. + +Rejected: routing the fixed-class child calls in `verify/runner.py` and +`browser/readability.py` through `run()` in the same change. Those pick a +module the author named, not one the caller chose, so they are not this +advisory, and changing them alters which modules a `verify.run` call requires +an operator to allow. Recorded as a follow-up instead. + +## Verified + +- Reporter's PoC against the **built artifact**, three configurations: + `flyto-core[api]==2.31.0` from PyPI reproduces (`exploit_reproduced: true`, + `marker_outside_sandbox: true`); the `2.31.1` wheel built from this tree + refuses all three (`bypass_top_level_ok: false`, marker absent, error + `Module 'verify.spec' declares nested module(s) blocked by security policy: + shell.exec`). The negative control — a direct `shell.exec` request is denied — + holds in every run. +- The new tests were run against the unpatched dispatcher first and failed + there (`DID NOT RAISE ModulePolicyError`; the REST case returned `ok: true`). +- `.venv/bin/python -m pytest -m 'not browser and not e2e'` — **3175 passed, 11 + skipped, 275 deselected**, 63.67% coverage. +- `scripts/check_documentation.py`, `scripts/check_brand_identity.py`, + `scripts/lint-project-memory.sh`, `scripts/check_release_drift.py` — PASS. +- `python -m build` + `twine check dist/*` — PASS. `npm audit --audit-level=high` + — 0 vulnerabilities. +- `flyto-index verify . --strict --full-scan` — 20/20 PASS. The first run failed + `rules_policy` on two `except Exception:` lines the pre-flight introduced into + `src/core/api/**`; they are now narrowed and the check passes. +- Ruff on every changed file: no new findings. The four that remain + (`spec_runner.py` I001/F401/SIM108, `test_policy_chokepoint.py` I001) exist on + `main` and were left alone. + +## Not verified + +- `flyto-index task validate` did not run against this repo: it drives the + indexer's own interpreter, which has no `pytest-cov`, and its repo-wide Ruff + scan covers `examples/`, which CI does not lint. Both failures are + environmental; the equivalent gates were run directly and are above. +- Browser and e2e markers were not run. +- 2.31.1 is not released. The advisory names `2.31.1` as the patched version, so + the GHSA must not be published until the tag and the PyPI artifact exist. + +## Follow-ups + +- Cut `v2.31.1` (tag push runs `publish-pypi.yml`), then PATCH the advisory's + patched version and publish it — that is what notifies the reporter. +- `verify/runner.py` (six sites) and `browser/readability.py` still call + `execute()` on fixed child classes, so `verify.run` runs `verify.capture` etc. + without the policy gate. Not caller-selected, so not this advisory, but it + means an operator who denied `verify.capture` still gets it through + `verify.run`. Decide whether those should be gated too. diff --git a/handoffs/_registry.md b/handoffs/_registry.md index 25bb1adf..410b7d8b 100644 --- a/handoffs/_registry.md +++ b/handoffs/_registry.md @@ -2,6 +2,7 @@ | Date | Topic | File | Status | | --- | --- | --- | --- | +| 2026-08-25 | verify.spec policy-gate bypass (GHSA-wmwj-g59x-c8px) | `2026-08-25-verify-spec-policy-gate.md` | Implemented on `claude/verify-spec-policy-gate`: child modules dispatch through `BaseModule.run()`, REST `/v1/execute` gained the nested-module pre-flight, registry-wide regression test added. Reporter PoC reproduces on PyPI 2.31.0 and is refused by the 2.31.1 wheel built here; offline suite 3,175 passed, 11 skipped, 63.67% coverage; strict Indexer 20/20. Not released, advisory not yet published. Owner: claude | | 2026-08-24 | crypto.totp and browser launch diagnosability | `2026-08-24-crypto-totp-and-launch-diagnosis.md` | Implemented on `codex/totp-browser-flow` at `dad6ef4`: RFC 6238 module, launch failure causes surfaced, parameterised login/OTP/act/prove workflow. Full non-browser/non-E2E 3,166 passed, 11 skipped, 63.38% coverage; strict Indexer 19/19 clean. No run against a real 2FA site. Owner: claude | | 2026-08-24 | JSON-to-CSV output and input-error contract | `2026-08-24-csv-output-contract.md` | Implemented locally: sandbox-safe default output, typed parameter failures, and no deterministic retries. Focused Core 109/109; full non-browser/non-E2E 3,070 passed, 11 skipped, 63.37% coverage. Owner: codex | | 2026-08-24 | Runtime and module catalog convergence | `2026-08-24-runtime-and-catalog-convergence.md` | Implemented locally: browser fallback, truthful legacy failure, total label-key metadata, and optional response assertion metadata. Full suite 3,041 passed, 11 skipped, 63.36% coverage; targeted 171/171 and real installed-Chrome launch green. Owner: codex | diff --git a/security/advisories.json b/security/advisories.json index f66a720f..34ce0d8b 100644 --- a/security/advisories.json +++ b/security/advisories.json @@ -467,5 +467,17 @@ "tests/core/test_reported_advisories_2026_08_19.py::test_client_dsn_cannot_reach_a_private_target", "tests/core/test_reported_advisories_2026_08_19.py::test_mongodb_multi_host_dsn_checks_every_host" ] + }, + { + "ghsa": "GHSA-wmwj-g59x-c8px", + "severity": "critical", + "cve": null, + "published": "2026-08-25", + "affected": "<= 2.31.0", + "patched": "2.31.1", + "summary": "verify.spec bypasses the module allowlist and dangerous-permission gate to execute arbitrary host commands", + "regression_tests": [ + "tests/core/test_reported_security_advisories.py::test_verify_spec_ruleset_cannot_run_a_denied_module" + ] } ] diff --git a/src/core/api/routes/modules.py b/src/core/api/routes/modules.py index 057630c5..685dd284 100644 --- a/src/core/api/routes/modules.py +++ b/src/core/api/routes/modules.py @@ -129,6 +129,55 @@ async def refresh_capabilities(): return refresh_capability_manifest() +# --------------------------------------------------------------------------- +# Nested-module pre-flight (shared shape with core.mcp_handler.execute_module) +# --------------------------------------------------------------------------- + +def _nested_policy_error(module_id: str, params: Dict[str, Any]) -> Optional[str]: + """Reason string when the request's nested module ids are denied, else None. + + `_collect_module_ids` walks every `module:` declaration in the params — + including ids inside a verify.spec ruleset and ids smuggled into an inline + workflow_source/template string — so a module that dispatches children + cannot be used to reach a module the caller is not allowed to run. + """ + # The same two helpers the MCP transport pre-flight uses, so the two + # boundaries cannot drift into disagreeing about what a request contains. + from core.mcp_handler import ( + _collect_workflow_module_ids, + _module_missing_permissions, + ) + + try: + nested_module_ids = sorted( + m for m in _collect_workflow_module_ids(params) if m != module_id + ) + except (AttributeError, TypeError, ValueError, RecursionError): + # Malformed params are the caller's problem, and the module's own + # validation reports them. An unreadable payload declares no nested + # module, and BaseModule.run() still gates whatever it does reach. + nested_module_ids = [] + + smuggled = [m for m in nested_module_ids if not module_filter.is_allowed(m)] + if smuggled: + return ( + f"Module '{module_id}' declares nested module(s) blocked by security " + f"policy: {', '.join(smuggled)}" + ) + + for nested_module_id in nested_module_ids: + missing = _module_missing_permissions(nested_module_id) + if missing: + return ( + f"Nested module '{nested_module_id}' requires permission(s) " + f"{missing} that have not been granted. These grant host code " + "execution or money movement and must be enabled explicitly via " + "FLYTO_GRANTED_PERMISSIONS." + ) + + return None + + # --------------------------------------------------------------------------- # POST /v1/execute # --------------------------------------------------------------------------- @@ -142,6 +191,17 @@ async def execute_module(body: ExecuteModuleRequest, request: Request): ok=False, error=f"Module blocked by security policy: {body.module_id}" ) + # SECURITY (GHSA-wmwj-g59x-c8px): the top-level id is not the whole request. + # A nested-execution module (verify.spec rulesets, flow.invoke / + # template.invoke inline workflows) names its child modules inside its own + # params, so an allowed parent can carry a denied child. BaseModule.run() is + # the process-wide backstop; this boundary check matches the MCP transport + # (core.mcp_handler.execute_module), fails before the parent does any work, + # and returns the precise reason. Mirrors the same pre-flight there. + nested = _nested_policy_error(body.module_id, body.params) + if nested: + return ExecuteModuleResponse(ok=False, error=nested) + state = request.app.state.server t0 = time.time() diff --git a/src/core/modules/atomic/verify/spec_runner.py b/src/core/modules/atomic/verify/spec_runner.py index ef75def8..560c0c71 100644 --- a/src/core/modules/atomic/verify/spec_runner.py +++ b/src/core/modules/atomic/verify/spec_runner.py @@ -32,6 +32,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Set, Union, Callable +from ....module_policy import ModulePolicyError from ....utils import validate_path_with_env_config from ...base import BaseModule from ...registry import get_module, register_module @@ -94,6 +95,7 @@ async def execute_module_dynamic(module_id: str, params: Dict[str, Any], context Raises: ValueError: 模组不存在 + ModulePolicyError: 模组被 capability policy 或 permission gate 拒绝 Exception: 模组执行失败 """ try: @@ -103,7 +105,19 @@ async def execute_module_dynamic(module_id: str, params: Dict[str, Any], context ctx = context or {} instance = module_class(params, ctx) - return await instance.execute() + # SECURITY (GHSA-wmwj-g59x-c8px): BaseModule.run() is the process-wide + # policy chokepoint. This dispatcher picks the child module from the + # caller's own ruleset, so calling execute() directly let an inline + # ruleset name any module — shell.exec included — and run it without + # the allowlist/denylist and dangerous-permission checks that the + # caller was restricted by. Same omission as the nested Warroom/test + # steps fixed in testing/runner.py. + return await instance.run() + except ModulePolicyError: + # A denied child is a policy decision, not a verification outcome: + # let it travel up so the whole verify.spec call fails closed instead + # of being reported as an ordinary failed rule. + raise except ValueError: raise except Exception as e: @@ -263,6 +277,11 @@ async def run_spec_rule(rule: Dict[str, Any]) -> SpecResult: orphaned_in_target=orphaned, ) + except ModulePolicyError: + # See execute_module_dynamic: a policy denial must not be swallowed + # into a per-rule error, or a blocked module would read like a normal + # verification failure. + raise except Exception as e: logger.error(f"Rule '{name}' failed: {e}") return SpecResult(name=name, passed=False, error=str(e)) diff --git a/src/core/modules/base.py b/src/core/modules/base.py index bba2d5fe..e8db04ca 100644 --- a/src/core/modules/base.py +++ b/src/core/modules/base.py @@ -332,6 +332,14 @@ async def _execute_with_resilience( raise except Exception as e: + # SECURITY: a policy denial is a deterministic authorization + # decision, not a transient failure. Retrying it only repeats + # the refusal, and repackaging it as a generic Exception hides + # from the caller that the module was BLOCKED rather than + # broken. Re-raise the typed error untouched. + from ..module_policy import ModulePolicyError + if isinstance(e, ModulePolicyError): + raise last_exception = e if attempt == attempts - 1: error_msg = ErrorMessages.format( diff --git a/tests/core/test_policy_chokepoint.py b/tests/core/test_policy_chokepoint.py index 1a8b8fe0..c3e226a2 100644 --- a/tests/core/test_policy_chokepoint.py +++ b/tests/core/test_policy_chokepoint.py @@ -8,7 +8,9 @@ is reached. """ +import ast import os +from pathlib import Path import pytest @@ -115,3 +117,194 @@ async def test_smuggled_child_blocked_even_if_gadget_allowed(monkeypatch): assert result["ok"] is False assert result.get("blocked_by") == "module_filter", result assert "shell.exec" in result.get("blocked_modules", []), result + + +# --------------------------------------------------------------------------- +# GHSA-wmwj-g59x-c8px — verify.spec dynamic child dispatch +# --------------------------------------------------------------------------- +# +# verify.spec chooses its child modules from the CALLER's ruleset: every rule +# names a `source.module` / `target.module` with free-form params. That +# dispatcher used to call the child's execute() directly, so a caller who was +# restricted to verify.spec could name shell.exec in a ruleset and run a host +# command — past both the module filter and the dangerous-permission grant. + + +def _shell_ruleset(marker, branch: str = "source") -> dict: + """A ruleset whose `branch` side runs shell.exec and writes `marker`.""" + rule = { + "name": "execute denied module", + "source": {"keys": []}, + "target": {"keys": []}, + } + rule[branch] = { + # `touch` passes shell.exec's own command allowlist, so the only thing + # that can stop the marker from appearing is the policy gate — the + # point of the test. A command shell.exec rejects on its own would make + # these pass for the wrong reason. + "module": "shell.exec", + "params": {"command": f"touch {marker}"}, + } + return {"name": "policy-bypass-regression", "rules": [rule]} + + +@pytest.mark.asyncio +class TestVerifySpecNestedDispatch: + + @pytest.mark.parametrize("branch", ["source", "target"]) + async def test_denied_child_blocked_by_default(self, default_policy, tmp_path, branch): + # Default policy: shell.* is denied, verify.spec is not. The denied + # child must fail closed, and it must surface as a policy error rather + # than a failed verification rule. + marker = tmp_path / f"marker-{branch}.txt" + module = ModuleRegistry.get("verify.spec")( + {"ruleset": _shell_ruleset(marker, branch)}, {} + ) + with pytest.raises(ModulePolicyError): + await module.run() + assert marker.exists() is False + + async def test_denied_child_blocked_under_strict_allowlist(self, monkeypatch, tmp_path): + # The reported scenario: the caller is allowed exactly one module. + monkeypatch.delenv("FLYTO_MODULE_DENYLIST", raising=False) + monkeypatch.delenv("FLYTO_GRANTED_PERMISSIONS", raising=False) + monkeypatch.setenv("FLYTO_MODULE_ALLOWLIST", "verify.spec") + monkeypatch.setattr(module_policy, "module_filter", ModuleFilter()) + + marker = tmp_path / "marker-allowlist.txt" + module = ModuleRegistry.get("verify.spec")( + {"ruleset": _shell_ruleset(marker)}, {} + ) + with pytest.raises(ModulePolicyError): + await module.run() + assert marker.exists() is False + + async def test_allowed_child_still_needs_the_permission_grant(self, monkeypatch, tmp_path): + # Even an operator who allows shell.exec by id has not granted the + # dangerous permission it declares. run() checks both; execute() checked + # neither. + monkeypatch.delenv("FLYTO_MODULE_DENYLIST", raising=False) + monkeypatch.delenv("FLYTO_GRANTED_PERMISSIONS", raising=False) + monkeypatch.setenv("FLYTO_MODULE_ALLOWLIST", "verify.spec,shell.exec") + monkeypatch.setattr(module_policy, "module_filter", ModuleFilter()) + + marker = tmp_path / "marker-permission.txt" + module = ModuleRegistry.get("verify.spec")( + {"ruleset": _shell_ruleset(marker)}, {} + ) + with pytest.raises(ModulePolicyError): + await module.run() + assert marker.exists() is False + + +@pytest.mark.asyncio +async def test_verify_spec_ruleset_rejected_at_the_mcp_boundary(default_policy, tmp_path): + # Defense in depth: the transport pre-flight already walks nested `module:` + # declarations, so the ruleset is refused before verify.spec runs at all. + marker = tmp_path / "marker-boundary.txt" + result = await execute_module("verify.spec", {"ruleset": _shell_ruleset(marker)}) + assert result["ok"] is False + assert result.get("blocked_by") == "module_filter", result + assert "shell.exec" in result.get("blocked_modules", []), result + assert marker.exists() is False + + +def test_rest_execute_rejects_a_denied_nested_module(monkeypatch, tmp_path): + # The REST route checked only the top-level module id, so verify.spec was + # admitted and the ruleset's shell.exec rode in with it. + from starlette.testclient import TestClient + + import core.api.routes.modules as modules_route + from core.api import security as sec + from core.api.server import create_app + + monkeypatch.delenv("FLYTO_MODULE_ALLOWLIST", raising=False) + monkeypatch.delenv("FLYTO_MODULE_DENYLIST", raising=False) + monkeypatch.delenv("FLYTO_GRANTED_PERMISSIONS", raising=False) + fresh = ModuleFilter() + monkeypatch.setattr(modules_route, "module_filter", fresh) + monkeypatch.setattr(module_policy, "module_filter", fresh) + + marker = tmp_path / "marker-rest.txt" + with TestClient(create_app()) as client: + headers = {"Authorization": f"Bearer {sec._active_token}"} + direct = client.post( + "/v1/execute", + json={"module_id": "shell.exec", "params": {"command": f"printf X > {marker}"}}, + headers=headers, + ).json() + assert direct["ok"] is False + assert "blocked" in (direct.get("error") or "").lower() + + nested = client.post( + "/v1/execute", + json={"module_id": "verify.spec", "params": {"ruleset": _shell_ruleset(marker)}}, + headers=headers, + ).json() + + assert nested["ok"] is False + # Refused by the route's own pre-flight (before verify.spec runs at all), + # not only by the engine chokepoint underneath it. + assert "nested" in (nested.get("error") or "").lower(), nested + assert "shell.exec" in (nested.get("error") or "") + assert marker.exists() is False + + +# --------------------------------------------------------------------------- +# Registry-wide: no dynamic dispatch may call execute() directly +# --------------------------------------------------------------------------- + +def _dynamic_dispatch_offenders(root: Path) -> list: + """Functions that resolve a module by a NON-constant id and then await + `.execute()` instead of the policy-gated `.run()`. + + A constant id (`ModuleRegistry.get('ai.extract')`) is a fixed collaborator + the author chose; a variable id is whatever the caller asked for, and that + is the shape that turns an allowed module into a launcher for a denied one + (GHSA-675h-j4qg-m52x in the test/warroom runner, GHSA-wmwj-g59x-c8px in + verify.spec). + """ + offenders = [] + for path in sorted(root.rglob("*.py")): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: # pragma: no cover - source must parse + continue + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + dynamic_lookup = False + direct_execute = False + for inner in ast.walk(node): + if isinstance(inner, ast.Call): + func = inner.func + name = ( + func.attr if isinstance(func, ast.Attribute) + else func.id if isinstance(func, ast.Name) + else "" + ) + if name in {"get_module", "get"} and inner.args: + target = inner.args[0] + looks_like_registry = name == "get_module" or ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id == "ModuleRegistry" + ) + if looks_like_registry and not isinstance(target, ast.Constant): + dynamic_lookup = True + if ( + isinstance(func, ast.Attribute) + and func.attr == "execute" + and isinstance(func.value, ast.Name) + and func.value.id != "self" + ): + direct_execute = True + if dynamic_lookup and direct_execute: + offenders.append(f"{path}:{node.lineno}:{node.name}") + return offenders + + +def test_no_dynamic_dispatch_bypasses_the_chokepoint(): + src = Path(__file__).resolve().parents[2] / "src" / "core" + assert src.is_dir(), src + assert _dynamic_dispatch_offenders(src) == [] diff --git a/tests/core/test_reported_security_advisories.py b/tests/core/test_reported_security_advisories.py index f3bb40fc..2d7c5262 100644 --- a/tests/core/test_reported_security_advisories.py +++ b/tests/core/test_reported_security_advisories.py @@ -1484,3 +1484,62 @@ async def test_expanded_beta_audit_confines_huggingface_file_inputs( 'model_id': 'test-model', path_key: str(secret), }}) + + +@pytest.mark.asyncio +async def test_verify_spec_ruleset_cannot_run_a_denied_module(monkeypatch): + """GHSA-wmwj-g59x-c8px. + + verify.spec picks its child modules out of the caller's own ruleset and + used to call the child's execute() directly. Both locks live in + BaseModule.run(), so a caller restricted to verify.spec could name + shell.exec in a rule and run a host command with neither the module filter + nor the shell.execute grant consulted. + """ + module_policy = importlib.import_module('core.module_policy') + registry = importlib.import_module('core.modules.registry') + spec_runner = importlib.import_module('core.modules.atomic.verify.spec_runner') + + monkeypatch.delenv('FLYTO_GRANTED_PERMISSIONS', raising=False) + monkeypatch.delenv('FLYTO_MODULE_DENYLIST', raising=False) + # The reported configuration: exactly one module is permitted. + monkeypatch.setenv('FLYTO_MODULE_ALLOWLIST', 'verify.spec') + monkeypatch.setattr(module_policy, 'module_filter', module_policy.ModuleFilter()) + + reached_shell_sink = False + + async def forbidden_execute(self): + nonlocal reached_shell_sink + reached_shell_sink = True + return {'ok': True} + + monkeypatch.setattr( + registry.ModuleRegistry.get('shell.exec'), + 'execute', + forbidden_execute, + ) + + ruleset = { + 'name': 'policy-bypass-regression', + 'rules': [{ + 'name': 'execute denied module', + 'source': { + 'module': 'shell.exec', + 'params': {'command': 'echo CHOKEPOINT'}, + }, + 'target': {'keys': []}, + }], + } + + with pytest.raises(module_policy.ModulePolicyError): + await spec_runner.VerifySpecModule({'ruleset': ruleset}, {}).run() + assert reached_shell_sink is False + + # Defense in depth: the transport pre-flight walks the ruleset's nested + # module declarations, so the request is refused before verify.spec runs. + mcp_handler = importlib.import_module('core.mcp_handler') + preflight = await mcp_handler.execute_module('verify.spec', {'ruleset': ruleset}) + assert preflight['ok'] is False + assert preflight['blocked_by'] == 'module_filter' + assert 'shell.exec' in preflight['blocked_modules'] + assert reached_shell_sink is False