strictcli 0.37.0__tar.gz → 0.38.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 (99) hide show
  1. {strictcli-0.37.0 → strictcli-0.38.0}/PKG-INFO +1 -1
  2. {strictcli-0.37.0 → strictcli-0.38.0}/pyproject.toml +1 -1
  3. {strictcli-0.37.0 → strictcli-0.38.0}/strictcli/__init__.py +124 -9
  4. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_effects_bypass_check.py +147 -0
  5. {strictcli-0.37.0 → strictcli-0.38.0}/uv.lock +1 -1
  6. strictcli-0.37.0/.rlsbl/bases/.github/workflows/ci.yml +0 -33
  7. strictcli-0.37.0/.rlsbl/bases/.github/workflows/publish.yml +0 -195
  8. strictcli-0.37.0/.rlsbl/bases/.gitignore +0 -18
  9. strictcli-0.37.0/.rlsbl/lint/python.toml +0 -25
  10. strictcli-0.37.0/.rlsbl/version +0 -1
  11. {strictcli-0.37.0 → strictcli-0.38.0}/.claude/settings.json +0 -0
  12. {strictcli-0.37.0 → strictcli-0.38.0}/.github/workflows/ci.yml +0 -0
  13. {strictcli-0.37.0 → strictcli-0.38.0}/.github/workflows/publish.yml +0 -0
  14. {strictcli-0.37.0 → strictcli-0.38.0}/.gitignore +0 -0
  15. {strictcli-0.37.0 → strictcli-0.38.0}/.rlsbl/config.json +0 -0
  16. {strictcli-0.37.0/.rlsbl/bases → strictcli-0.38.0}/.rlsbl/lint/python.toml +0 -0
  17. {strictcli-0.37.0 → strictcli-0.38.0}/.rlsbl/managed-files.json +0 -0
  18. {strictcli-0.37.0 → strictcli-0.38.0}/.strictcli/schema.json +0 -0
  19. {strictcli-0.37.0 → strictcli-0.38.0}/CLAUDE.md +0 -0
  20. {strictcli-0.37.0 → strictcli-0.38.0}/LICENSE +0 -0
  21. {strictcli-0.37.0 → strictcli-0.38.0}/README.md +0 -0
  22. {strictcli-0.37.0 → strictcli-0.38.0}/scripts/add_effect_classification.py +0 -0
  23. {strictcli-0.37.0 → strictcli-0.38.0}/scripts/add_forwarding_declaration.py +0 -0
  24. {strictcli-0.37.0 → strictcli-0.38.0}/strictcli/py.typed +0 -0
  25. {strictcli-0.37.0 → strictcli-0.38.0}/tests/conftest.py +0 -0
  26. {strictcli-0.37.0 → strictcli-0.38.0}/tests/flagship_app.py +0 -0
  27. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_arg_default.py +0 -0
  28. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_arg_default_validation.py +0 -0
  29. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_at_prefix.py +0 -0
  30. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_auto_version.py +0 -0
  31. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_call.py +0 -0
  32. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_check_command.py +0 -0
  33. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_check_discovery.py +0 -0
  34. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_check_provider.py +0 -0
  35. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_check_public_api.py +0 -0
  36. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_check_runner.py +0 -0
  37. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_check_schema.py +0 -0
  38. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_check_types.py +0 -0
  39. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_choices.py +0 -0
  40. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_choices_none.py +0 -0
  41. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_classification.py +0 -0
  42. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_command_help_suggestion.py +0 -0
  43. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_command_tags.py +0 -0
  44. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_compound_types.py +0 -0
  45. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_config.py +0 -0
  46. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_config_fields.py +0 -0
  47. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_config_file_path.py +0 -0
  48. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_config_set_bugs.py +0 -0
  49. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_confirm.py +0 -0
  50. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_connection_env.py +0 -0
  51. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_context.py +0 -0
  52. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_coverage.py +0 -0
  53. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_deep_nesting.py +0 -0
  54. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_dependencies.py +0 -0
  55. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_deprecated.py +0 -0
  56. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_dry_run_unsupported.py +0 -0
  57. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_dump_schema.py +0 -0
  58. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_e2e.py +0 -0
  59. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_effects.py +0 -0
  60. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_env.py +0 -0
  61. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_exit_codes.py +0 -0
  62. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_flag_sets.py +0 -0
  63. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_flagship_preview.py +0 -0
  64. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_float_format.py +0 -0
  65. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_float_type.py +0 -0
  66. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_float_vectors.py +0 -0
  67. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_global_flag_conflict_position.py +0 -0
  68. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_global_flags.py +0 -0
  69. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_guard_v2.py +0 -0
  70. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_help.py +0 -0
  71. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_hermetic.py +0 -0
  72. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_infra_env.py +0 -0
  73. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_int_type.py +0 -0
  74. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_invoke.py +0 -0
  75. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_keyword_flags.py +0 -0
  76. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_mcp.py +0 -0
  77. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_mutex.py +0 -0
  78. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_nesting.py +0 -0
  79. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_parser.py +0 -0
  80. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_passthrough.py +0 -0
  81. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_provenance.py +0 -0
  82. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_provenance_phase2.py +0 -0
  83. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_registration.py +0 -0
  84. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_repeatable.py +0 -0
  85. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_reserved_global_flags.py +0 -0
  86. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_reserved_quartet.py +0 -0
  87. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_tagdsl.py +0 -0
  88. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_toml_loading.py +0 -0
  89. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_tool_export.py +0 -0
  90. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_typed_args.py +0 -0
  91. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_unique.py +0 -0
  92. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_utilities.py +0 -0
  93. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_validate.py +0 -0
  94. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_variadic.py +0 -0
  95. {strictcli-0.37.0 → strictcli-0.38.0}/tests/test_visibility.py +0 -0
  96. {strictcli-0.37.0 → strictcli-0.38.0}/todo/.defer/deferred.md +0 -0
  97. {strictcli-0.37.0 → strictcli-0.38.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  98. {strictcli-0.37.0 → strictcli-0.38.0}/todo/.done/original-idea.md +0 -0
  99. {strictcli-0.37.0 → strictcli-0.38.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.37.0
3
+ Version: 0.38.0
4
4
  Summary: A strict CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.37.0"
7
+ version = "0.38.0"
8
8
  description = "A strict CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.37.0"
5
+ __version__ = "0.38.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -44,7 +44,7 @@ from collections import deque
44
44
  from collections.abc import Sequence
45
45
  from dataclasses import dataclass, field
46
46
  from pathlib import Path
47
- from typing import Any, Callable, Protocol, TypeVar, get_args, get_origin, runtime_checkable
47
+ from typing import Any, Callable, NamedTuple, Protocol, TypeVar, get_args, get_origin, runtime_checkable
48
48
 
49
49
  # TypeVar for decorator return types — preserves the decorated function's type
50
50
  F = TypeVar("F", bound=Callable[..., Any])
@@ -1423,13 +1423,30 @@ def _validate_grants(cmd_name: str, grants) -> tuple:
1423
1423
  # Closed lists, matched on the called attribute/function name: process starts,
1424
1424
  # filesystem mutations, and network calls. The analyser is stdlib `ast` -- a
1425
1425
  # regular dependency, no optional import and no soft degradation.
1426
+ #
1427
+ # Several leaves are RECEIVER-SCOPED: a name alone is not evidence of an
1428
+ # effect, and a finding a consumer cannot act on is worse than no finding.
1429
+ # `mapping.get(...)` is not a network call and `platform.system()` is not a
1430
+ # process start, so those leaves are banned only through a receiver the
1431
+ # module's own imports let the analyser resolve.
1426
1432
  # ---------------------------------------------------------------------------
1427
1433
 
1428
1434
  _BYPASS_PROCESS = frozenset({
1429
1435
  "run", "Popen", "call", "check_call", "check_output", "getoutput",
1430
- "getstatusoutput", "system", "popen", "execv", "execvp", "execve",
1436
+ "getstatusoutput", "popen", "execv", "execvp", "execve",
1431
1437
  "spawnv", "spawnl", "fork",
1432
1438
  })
1439
+ # Process leaves that start a process only through `os`. `system` is the whole
1440
+ # set: `os.system` runs a shell, but `platform.system()` is a pure in-process
1441
+ # string read -- no process, no effect, and nothing the effects handle could
1442
+ # carry, since its closed method set has no in-process-observe method. Banning
1443
+ # the leaf on any receiver produced a finding whose own remediation ("route it
1444
+ # through ctx.effects") could not be followed, which is the one thing a lint
1445
+ # must never emit. An UNKNOWN receiver (`foo.system()`) is exempt for the same
1446
+ # reason the network leaves are receiver-scoped: without a resolvable binding
1447
+ # to `os` there is no evidence a process starts, and a name is not evidence.
1448
+ _BYPASS_PROCESS_OS_ONLY = frozenset({"system"})
1449
+ _BYPASS_OS_RECEIVERS = frozenset({"os"})
1433
1450
  _BYPASS_FILESYSTEM = frozenset({
1434
1451
  "remove", "unlink", "rmdir", "removedirs", "mkdir", "makedirs",
1435
1452
  "rename", "renames", "replace", "chmod", "chown", "symlink", "link",
@@ -1453,6 +1470,63 @@ _BYPASS_SKIP_DIRS = frozenset({
1453
1470
  "site-packages", "build", "dist", ".tox", ".mypy_cache", ".ruff_cache",
1454
1471
  ".pytest_cache", ".eggs",
1455
1472
  })
1473
+ # The modules whose imports bind a receiver the analyser trusts. Closed, like
1474
+ # every other list here: `import requests as rq` must resolve to `requests` and
1475
+ # `from os import system` to `os`, while `from mylib import get` must bind
1476
+ # NOTHING -- widening this to every module would re-create exactly the ordinary
1477
+ # `mapping.get(...)` noise the network receiver list exists to remove.
1478
+ _BYPASS_EFFECT_MODULES = frozenset({
1479
+ "os", "os.path", "subprocess", "shutil", "pathlib", "socket", "tempfile",
1480
+ "requests", "httpx", "urllib", "urllib.request", "http", "http.client",
1481
+ "aiohttp", "urllib3",
1482
+ })
1483
+
1484
+
1485
+ class _BypassImports(NamedTuple):
1486
+ """What a module's imports say about the names it calls.
1487
+
1488
+ ``receivers`` maps a bound module name to the effect module it denotes
1489
+ (``import os as o`` -> ``{"o": "os"}``); ``calls`` maps a bound member name
1490
+ to the ``(module, member)`` pair it came from (``from os import system as
1491
+ sh`` -> ``{"sh": ("os", "system")}``). Both are used to normalize a call
1492
+ before the ban lists see it, so the lists stay written in terms of real
1493
+ module and member names rather than whatever the consumer spelled.
1494
+ """
1495
+
1496
+ receivers: dict
1497
+ calls: dict
1498
+
1499
+
1500
+ _BYPASS_NO_IMPORTS = _BypassImports({}, {})
1501
+
1502
+
1503
+ def _bypass_import_bindings(tree) -> _BypassImports:
1504
+ """Names bound to effect modules and to their members, in one module.
1505
+
1506
+ Relative imports are skipped: ``from .os import system`` is the consumer's
1507
+ own module, not the stdlib one, and the analyser cannot resolve it.
1508
+ """
1509
+ receivers: dict = {}
1510
+ calls: dict = {}
1511
+ for node in ast.walk(tree):
1512
+ if isinstance(node, ast.Import):
1513
+ for alias in node.names:
1514
+ if alias.name not in _BYPASS_EFFECT_MODULES:
1515
+ continue
1516
+ if alias.asname is None:
1517
+ # `import os.path` binds `os`, and `os.system` still works.
1518
+ bound = module = alias.name.split(".")[0]
1519
+ else:
1520
+ bound = alias.asname
1521
+ module = alias.name.rsplit(".", 1)[-1]
1522
+ receivers[bound] = module
1523
+ elif isinstance(node, ast.ImportFrom):
1524
+ if node.level or node.module not in _BYPASS_EFFECT_MODULES:
1525
+ continue
1526
+ module = node.module.rsplit(".", 1)[-1]
1527
+ for alias in node.names:
1528
+ calls[alias.asname or alias.name] = (module, alias.name)
1529
+ return _BypassImports(receivers, calls)
1456
1530
 
1457
1531
 
1458
1532
  def _call_target_name(node) -> tuple:
@@ -1613,10 +1687,45 @@ def _bypass_reachable_functions(tree) -> set:
1613
1687
  return reachable
1614
1688
 
1615
1689
 
1616
- def _bypass_call_is_banned(node, target: str, receiver) -> bool:
1690
+ def _bypass_resolve_call(leaf: str, receiver,
1691
+ imports: _BypassImports) -> tuple:
1692
+ """The ``(leaf, receiver)`` a call really goes through, per its imports.
1693
+
1694
+ A bare name imported from an effect module answers with that module and the
1695
+ member's REAL name (``from os import system as sh`` -> ``("system",
1696
+ "os")``), and an aliased module receiver answers with the module it denotes
1697
+ (``import os as o`` -> ``os``). Anything else is returned untouched, so an
1698
+ unresolvable receiver stays unresolvable rather than being guessed at.
1699
+ """
1700
+ if receiver is None:
1701
+ bound = imports.calls.get(leaf)
1702
+ if bound is None:
1703
+ return leaf, None
1704
+ module, member = bound
1705
+ return member, module
1706
+ return leaf, imports.receivers.get(receiver, receiver)
1707
+
1708
+
1709
+ def _bypass_call_is_banned(node, target: str, receiver,
1710
+ imports: _BypassImports = _BYPASS_NO_IMPORTS) -> bool:
1711
+ """True when one call is a direct effect the handle should have carried.
1712
+
1713
+ Two leaves are deliberately narrower than the rest: builtin ``open`` is a
1714
+ finding only in a writing mode, and ``system`` only through ``os`` --
1715
+ ``platform.system()`` observes this process and starts nothing, and the
1716
+ effects handle has no method that could carry it.
1717
+
1718
+ Builtin ``open`` is answered BEFORE import resolution, because it is the
1719
+ one leaf whose meaning comes from being unqualified. Everything after it is
1720
+ resolved through the module's imports (see :func:`_bypass_resolve_call`),
1721
+ so the lists below are written in terms of real module and member names.
1722
+ """
1617
1723
  leaf = target.rsplit(".", 1)[-1]
1618
1724
  if leaf == "open" and receiver is None:
1619
1725
  return _open_is_write_mode(node)
1726
+ leaf, receiver = _bypass_resolve_call(leaf, receiver, imports)
1727
+ if leaf in _BYPASS_PROCESS_OS_ONLY:
1728
+ return receiver in _BYPASS_OS_RECEIVERS
1620
1729
  return (
1621
1730
  (leaf in _BYPASS_PROCESS and receiver is not None)
1622
1731
  or leaf in _BYPASS_FILESYSTEM
@@ -1625,7 +1734,7 @@ def _bypass_call_is_banned(node, target: str, receiver) -> bool:
1625
1734
 
1626
1735
 
1627
1736
  def _bypass_walk(node, stack: list, reachable: set, findings: list, rel: str,
1628
- aliases: frozenset) -> None:
1737
+ aliases: frozenset, imports: _BypassImports) -> None:
1629
1738
  """Walk one subtree, carrying the enclosing-function stack.
1630
1739
 
1631
1740
  A banned call is reported once, at the INNERMOST enclosing function, when
@@ -1638,10 +1747,11 @@ def _bypass_walk(node, stack: list, reachable: set, findings: list, rel: str,
1638
1747
  if stack and any(id(fn) in reachable for fn in stack):
1639
1748
  if not _reaches_effects_handle(node.func, aliases):
1640
1749
  target, receiver = _call_target_name(node.func)
1641
- if target is not None and _bypass_call_is_banned(node, target, receiver):
1750
+ if target is not None and _bypass_call_is_banned(
1751
+ node, target, receiver, imports):
1642
1752
  findings.append((rel, node.lineno, stack[-1].name, target))
1643
1753
  for child in ast.iter_child_nodes(node):
1644
- _bypass_walk(child, stack, reachable, findings, rel, aliases)
1754
+ _bypass_walk(child, stack, reachable, findings, rel, aliases, imports)
1645
1755
 
1646
1756
 
1647
1757
  def _open_is_write_mode(node) -> bool:
@@ -1685,7 +1795,8 @@ def _scan_effects_bypasses(root: Path) -> list[tuple]:
1685
1795
  if not reachable:
1686
1796
  continue
1687
1797
  _bypass_walk(tree, [], reachable, findings, rel,
1688
- _bypass_effects_aliases(tree))
1798
+ _bypass_effects_aliases(tree),
1799
+ _bypass_import_bindings(tree))
1689
1800
  return findings
1690
1801
 
1691
1802
 
@@ -4846,7 +4957,11 @@ class App:
4846
4957
 
4847
4958
  - ``effects-bypass`` (error) fails on any direct process,
4848
4959
  filesystem-mutation or network call REACHABLE FROM A REGISTERED
4849
- COMMAND HANDLER;
4960
+ COMMAND HANDLER. Its remediation is always "route it through
4961
+ ctx.effects", so a leaf the handle could not carry must never be a
4962
+ finding: the handle's closed method set has no in-process-observe
4963
+ method, which is why ``platform.system()`` is exempt while
4964
+ ``os.system(...)`` is not (see :data:`_BYPASS_PROCESS_OS_ONLY`);
4850
4965
  - ``observe-allowlist-breadth`` (warn) surfaces short
4851
4966
  ``proc_observe_allowlist`` prefixes, which authorize real execution
4852
4967
  under ``--dry-run``;
@@ -326,6 +326,153 @@ async def deploy(ctx):
326
326
  assert r.exit_code == 1
327
327
 
328
328
 
329
+ class TestSystemIsBannedOnlyThroughOs:
330
+ """`system` starts a shell only as `os.system`.
331
+
332
+ `platform.system()` is a pure in-process string read: no process, no
333
+ effect, and NOTHING the effects handle could carry it -- the handle's
334
+ closed method set has no in-process-observe method. A finding on it is
335
+ unfixable by its own remediation ("route it through ctx.effects"), which
336
+ is the one thing a lint must never emit. So the leaf is receiver-scoped,
337
+ exactly as the network leaves already are.
338
+ """
339
+
340
+ def _run(self, tmp_path, source):
341
+ app, root = _app(tmp_path)
342
+ (root / "handlers.py").write_text(source)
343
+ return app.test(["check", "--name", "effects-bypass"])
344
+
345
+ def test_platform_system_is_not_a_finding(self, tmp_path):
346
+ r = self._run(tmp_path, '''
347
+ import platform
348
+
349
+ def deploy(ctx):
350
+ ctx.effects.run(["true"])
351
+ return platform.system()
352
+ ''')
353
+ assert r.exit_code == 0, r.stdout
354
+ assert "no direct effect calls bypass ctx.effects" in r.stdout
355
+
356
+ def test_system_imported_from_platform_is_not_a_finding(self, tmp_path):
357
+ r = self._run(tmp_path, '''
358
+ from platform import system
359
+
360
+ def deploy(ctx):
361
+ ctx.effects.run(["true"])
362
+ return system()
363
+ ''')
364
+ assert r.exit_code == 0, r.stdout
365
+
366
+ def test_an_unknown_receiver_named_system_is_not_a_finding(self, tmp_path):
367
+ """No resolvable binding to `os` is no evidence a process starts.
368
+
369
+ `foo.system()` is some object's method. Receiver-awareness means the
370
+ lint reports what it can show, and an unknown receiver shows nothing.
371
+ """
372
+ r = self._run(tmp_path, '''
373
+ def deploy(ctx, foo):
374
+ ctx.effects.run(["true"])
375
+ return foo.system()
376
+ ''')
377
+ assert r.exit_code == 0, r.stdout
378
+
379
+ def test_os_system_is_still_a_finding(self, tmp_path):
380
+ r = self._run(tmp_path, '''
381
+ import os
382
+
383
+ def deploy(ctx):
384
+ ctx.effects.run(["true"])
385
+ os.system("rm -rf /")
386
+ ''')
387
+ assert r.exit_code == 1
388
+ assert "deploy calls os.system directly" in r.stdout
389
+
390
+ def test_system_imported_from_os_is_a_finding(self, tmp_path):
391
+ r = self._run(tmp_path, '''
392
+ from os import system
393
+
394
+ def deploy(ctx):
395
+ ctx.effects.run(["true"])
396
+ system("rm -rf /")
397
+ ''')
398
+ assert r.exit_code == 1, r.stdout
399
+ assert "deploy calls system directly" in r.stdout
400
+
401
+ def test_system_imported_from_os_under_an_alias_is_a_finding(self, tmp_path):
402
+ r = self._run(tmp_path, '''
403
+ from os import system as shell_out
404
+
405
+ def deploy(ctx):
406
+ ctx.effects.run(["true"])
407
+ shell_out("rm -rf /")
408
+ ''')
409
+ assert r.exit_code == 1, r.stdout
410
+ assert "deploy calls shell_out directly" in r.stdout
411
+
412
+ def test_an_aliased_os_module_receiver_is_a_finding(self, tmp_path):
413
+ r = self._run(tmp_path, '''
414
+ import os as o
415
+
416
+ def deploy(ctx):
417
+ ctx.effects.run(["true"])
418
+ o.system("rm -rf /")
419
+ ''')
420
+ assert r.exit_code == 1, r.stdout
421
+ assert "deploy calls o.system directly" in r.stdout
422
+
423
+
424
+ class TestImportBindingsResolveReceivers:
425
+ """An effect-module import is a receiver the analyser can resolve.
426
+
427
+ The same binding table that keeps `platform.system` clean also closes two
428
+ escapes it would otherwise open: an aliased effect module and a bare name
429
+ imported from one.
430
+ """
431
+
432
+ def _run(self, tmp_path, source):
433
+ app, root = _app(tmp_path)
434
+ (root / "handlers.py").write_text(source)
435
+ return app.test(["check", "--name", "effects-bypass"])
436
+
437
+ def test_an_aliased_requests_module_is_a_network_finding(self, tmp_path):
438
+ r = self._run(tmp_path, '''
439
+ import requests as rq
440
+
441
+ def deploy(ctx):
442
+ ctx.effects.run(["true"])
443
+ rq.post("https://x.test")
444
+ ''')
445
+ assert r.exit_code == 1, r.stdout
446
+ assert "deploy calls rq.post directly" in r.stdout
447
+
448
+ def test_a_bare_name_imported_from_subprocess_is_a_finding(self, tmp_path):
449
+ r = self._run(tmp_path, '''
450
+ from subprocess import run
451
+
452
+ def deploy(ctx):
453
+ ctx.effects.run(["true"])
454
+ run(["git", "push"])
455
+ ''')
456
+ assert r.exit_code == 1, r.stdout
457
+ assert "deploy calls run directly" in r.stdout
458
+
459
+ def test_a_bare_name_from_an_unrelated_module_is_not_a_finding(self, tmp_path):
460
+ """Only the closed list of effect modules binds a receiver.
461
+
462
+ `from mylib import get` must stay silent -- widening the binding table
463
+ to every module would re-create the `mapping.get(...)` noise the
464
+ network receiver list exists to remove.
465
+ """
466
+ r = self._run(tmp_path, '''
467
+ from mylib import get
468
+
469
+ def deploy(ctx):
470
+ ctx.effects.run(["true"])
471
+ return get("k")
472
+ ''')
473
+ assert r.exit_code == 0, r.stdout
474
+
475
+
329
476
  class TestObserveAllowlistBreadth:
330
477
  """§6.2's hazard, surfaced as a WARNING and never as an error.
331
478
 
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.37.0"
235
+ version = "0.38.0"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
@@ -1,33 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
- workflow_dispatch:
9
-
10
- # Per-SHA group: re-runs of the same commit dedupe, but a new commit never
11
- # cancels an earlier commit's in-flight run (release CI conclusions stay intact).
12
- concurrency:
13
- group: ${{ github.workflow_ref }}-${{ github.sha }}
14
- cancel-in-progress: true
15
-
16
- jobs:
17
- test:
18
- runs-on: ubuntu-latest
19
- strategy:
20
- matrix:
21
- # requires-python: >= 3.11
22
- python-version: ["3.12", "3.13", "3.14"]
23
- steps:
24
- - uses: actions/checkout@v6
25
- - uses: astral-sh/setup-uv@v7
26
- - run: uv python install ${{ matrix.python-version }}
27
- - run: uv sync --locked
28
- - run: uv run python -c "import strictcli"
29
- - name: Install gitleaks
30
- run: |
31
- GITLEAKS_VERSION=8.24.3
32
- curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
33
- - run: uv run pytest --rootdir .
@@ -1,195 +0,0 @@
1
- name: Publish
2
- on:
3
- release:
4
- types: [published]
5
- workflow_dispatch:
6
- inputs:
7
- tag:
8
- description: Release tag to publish (e.g. v1.2.3). Overrides the ref for
9
- retry dispatch.
10
- required: false
11
- type: string
12
-
13
- # One publish run per tag: a workflow_dispatch retry at the same tag
14
- # queues behind the in-flight run instead of racing it. A publish is never
15
- # cancelled mid-flight.
16
- concurrency:
17
- group: publish-${{ inputs.tag || github.ref_name }}
18
- cancel-in-progress: false
19
- permissions:
20
- contents: read
21
- id-token: write
22
- jobs:
23
- gate:
24
- name: Gate on CI
25
- runs-on: ubuntu-latest
26
- permissions:
27
- checks: read
28
- contents: read
29
- env:
30
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31
- GH_REPO: ${{ github.repository }}
32
- GATE_TIMEOUT_MINUTES: '20'
33
- GATE_GRACE_MINUTES: '5'
34
- GATE_POLL_SECONDS: '15'
35
- GATE_MARKER_ATTEMPTS: '5'
36
- GATE_MARKER_RETRY_SECONDS: '5'
37
- CI_CHECK_REGEX: ^(test)( \(.*\))?$
38
- steps:
39
- - name: Wait for CI to succeed on the release commit
40
- run: |
41
- set -euo pipefail
42
-
43
- # Commit resolution -- explicit marker-first order (never reads the release
44
- # event payload; dispatch retries have none, so this path is uniform for
45
- # release events and dispatch retries alike):
46
- # (1) Marker: rlsbl writes the exact commit CI ran on into the GitHub
47
- # Release body as a machine-parseable line of the form
48
- # <!-- rlsbl-ci-sha: <40-hex> -->
49
- # Prefer it when present -- it pins the precise commit and is immune to
50
- # ref races. A just-created release can lag on a GitHub API read replica
51
- # (the marker is written at release creation but may not be visible on
52
- # the first read), so RETRY the read GATE_MARKER_ATTEMPTS times,
53
- # GATE_MARKER_RETRY_SECONDS apart, before concluding it is absent. The
54
- # tag is inputs.tag (TAG_INPUT) when dispatched with an explicit
55
- # override, else GITHUB_REF_NAME (matches the router resolver).
56
- # (2) Fallback: older releases predate the marker, so fall back to
57
- # $GITHUB_SHA, the tag's commit for both release and dispatch-at-tag.
58
- extract_ci_sha() {
59
- # Emit the first rlsbl-ci-sha marker SHA found on stdin, if any.
60
- sed -n 's/.*<!-- rlsbl-ci-sha: \([0-9a-f]\{40\}\) -->.*/\1/p' | head -n1
61
- }
62
- tag="${TAG_INPUT:-$GITHUB_REF_NAME}"
63
- marker_attempts="${GATE_MARKER_ATTEMPTS:-5}"
64
- marker_retry_seconds="${GATE_MARKER_RETRY_SECONDS:-5}"
65
- sha=""
66
- attempt=1
67
- while [ "$attempt" -le "$marker_attempts" ]; do
68
- if body="$(gh release view "$tag" --json body --jq .body 2>/dev/null)"; then
69
- sha="$(printf '%s\n' "$body" | extract_ci_sha)"
70
- fi
71
- if [ -n "$sha" ]; then
72
- break
73
- fi
74
- if [ "$attempt" -lt "$marker_attempts" ]; then
75
- echo "Publish gate: rlsbl-ci-sha marker not yet visible in the '$tag' release body (attempt $attempt/$marker_attempts); retrying in ${marker_retry_seconds}s..."
76
- sleep "$marker_retry_seconds"
77
- fi
78
- attempt=$(( attempt + 1 ))
79
- done
80
- if [ -n "$sha" ]; then
81
- echo "Publish gate: resolved release commit from rlsbl-ci-sha marker in the '$tag' release body."
82
- else
83
- sha="$GITHUB_SHA"
84
- echo "Publish gate: no rlsbl-ci-sha marker after $marker_attempts attempt(s) on the '$tag' release body; falling back to \$GITHUB_SHA."
85
- fi
86
- echo "Publish gate: waiting for CI on $GITHUB_REF_NAME (commit $sha)"
87
- echo "Check-run name filter: $CI_CHECK_REGEX"
88
- # Timeout, grace window, and poll interval come from the job env above;
89
- # edit them there if this repository's CI needs different limits.
90
-
91
- now() { date +%s; }
92
- start="$(now)"
93
- deadline=$(( start + GATE_TIMEOUT_MINUTES * 60 ))
94
- grace_deadline=$(( start + GATE_GRACE_MINUTES * 60 ))
95
-
96
- while :; do
97
- if ! resp="$(gh api --paginate "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?per_page=100")"; then
98
- echo "Checks API request failed; retrying in ${GATE_POLL_SECONDS}s..."
99
- sleep "$GATE_POLL_SECONDS"
100
- continue
101
- fi
102
- # Match this project's CI check runs by name; exclude check runs that
103
- # belong to THIS workflow run (the gate itself and the queued publish
104
- # jobs would otherwise deadlock the poll loop).
105
- # A retried CI run creates a BRAND-NEW check-run with the same name as the
106
- # old one, so a stale failure would block the gate forever. Project id and
107
- # started_at, then reduce to the latest check-run per name (max started_at,
108
- # numeric id as tiebreak for retries within the same second) BEFORE the
109
- # pending / not-success logic runs. This way the newest run decides: still
110
- # running -> wait; genuinely red -> hard fail.
111
- runs="$(jq -s --arg re "$CI_CHECK_REGEX" --arg run_id "$GITHUB_RUN_ID" '
112
- [ .[].check_runs[]
113
- | select(.name | test($re))
114
- | select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)
115
- | {name, status, conclusion, id, started_at} ]
116
- | group_by(.name)
117
- | map(sort_by(.started_at, .id) | last)' <<< "$resp")"
118
- total="$(jq 'length' <<< "$runs")"
119
-
120
- if [ "$total" -eq 0 ]; then
121
- if [ "$(now)" -ge "$grace_deadline" ]; then
122
- echo "::error::Publish gate: no CI check runs matching $CI_CHECK_REGEX appeared on $sha within $GATE_GRACE_MINUTES minutes."
123
- echo "A scaffolded repository always has a CI workflow, and rlsbl verifies CI on this exact commit BEFORE tagging it, so check runs must exist here."
124
- echo "Their absence means the check runs were deleted, the commit resolution is wrong, or this tag was created outside rlsbl."
125
- echo "If CI jobs were renamed, update CI_CHECK_REGEX in this workflow's gate job to match the new names."
126
- exit 1
127
- fi
128
- echo "No matching CI check runs yet; retrying in ${GATE_POLL_SECONDS}s..."
129
- sleep "$GATE_POLL_SECONDS"
130
- continue
131
- fi
132
-
133
- pending="$(jq '[ .[] | select(.status != "completed") ] | length' <<< "$runs")"
134
- if [ "$pending" -gt 0 ]; then
135
- if [ "$(now)" -ge "$deadline" ]; then
136
- echo "::error::Publish gate: timed out after $GATE_TIMEOUT_MINUTES minutes waiting for CI to complete on $sha."
137
- jq -r '.[] | " \(.name): status=\(.status) conclusion=\(.conclusion // "none")"' <<< "$runs"
138
- exit 1
139
- fi
140
- echo "$pending of $total matching CI check runs still running; retrying in ${GATE_POLL_SECONDS}s..."
141
- sleep "$GATE_POLL_SECONDS"
142
- continue
143
- fi
144
-
145
- not_success="$(jq '[ .[] | select(.conclusion != "success") ]' <<< "$runs")"
146
- if [ "$(jq 'length' <<< "$not_success")" -gt 0 ]; then
147
- echo "::error::Publish gate: CI did not pass on $sha -- refusing to publish."
148
- jq -r '.[] | " \(.name): \(.conclusion)"' <<< "$not_success"
149
- while IFS= read -r conclusion; do
150
- case "$conclusion" in
151
- failure|timed_out)
152
- echo "CI concluded '$conclusion' on the release commit."
153
- echo "rlsbl tags and releases a commit only AFTER its CI has gone green, so reaching this branch means one of: CI was re-run on an already-released commit and regressed, a required check was added after the release, or this tag/Release was created outside rlsbl."
154
- echo "Do NOT re-dispatch this publish workflow expecting a different answer -- a failure baked into the code at this commit fails identically every time, and there is no way to make this tag green."
155
- echo "Remedy: fix forward on the release branch and cut a NEW release with 'rlsbl release run' (its own CI gate must go green before it is tagged), then mark this one with 'rlsbl release deprecate <version>'."
156
- ;;
157
- cancelled)
158
- echo "A CI check run was CANCELLED. A cancelled run proves nothing about the commit, so the gate treats it as a hard failure instead of waiting for a conclusion that will never come."
159
- echo "Remedy: re-run the cancelled CI workflow on this exact commit (gh run rerun <run-id>). If it concludes success, re-dispatch this publish workflow at the tag ref: gh workflow run <publish workflow> --ref $GITHUB_REF_NAME"
160
- ;;
161
- skipped)
162
- echo "A CI check run matching the filter was SKIPPED. The gate cannot treat a skipped check as passing: this project must actually run its own CI on the release commit."
163
- echo "Remedy: check paths filters and job conditions so CI runs for this project, re-run CI on this commit, then re-dispatch this publish workflow at the tag ref."
164
- ;;
165
- *)
166
- echo "CI check concluded '$conclusion' (not success). The gate only proceeds when every matching check concluded success."
167
- ;;
168
- esac
169
- done <<< "$(jq -r '.[].conclusion' <<< "$not_success" | sort -u)"
170
- exit 1
171
- fi
172
-
173
- echo "Publish gate: all $total matching CI check runs succeeded."
174
- jq -r '.[] | " \(.name): \(.conclusion)"' <<< "$runs"
175
- exit 0
176
- done
177
- pypi:
178
- needs: gate
179
- runs-on: ubuntu-latest
180
- steps:
181
- - uses: actions/checkout@v6
182
- with:
183
- ref: ${{ inputs.tag || github.event.release.tag_name }}
184
- - uses: astral-sh/setup-uv@v7
185
- - name: Install gitleaks
186
- run: |
187
- GITLEAKS_VERSION=8.24.3
188
- curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
189
- - run: uv build --out-dir dist
190
- - name: Scan artifacts for secrets
191
- run: |
192
- gitleaks dir dist/
193
- - uses: pypa/gh-action-pypi-publish@release/v1
194
- with:
195
- skip-existing: true
@@ -1,18 +0,0 @@
1
- node_modules/
2
- __pycache__/
3
- *.pyc
4
- *.log
5
- .DS_Store
6
- coverage/
7
- build/
8
- dist/
9
- target/
10
- *.egg-info/
11
- .rlsbl-notes-*.tmp
12
- .rlsbl/lock
13
- .rlsbl-monorepo/lock
14
- .credentials.json
15
- .*-cache.json
16
- .env
17
- .env.local
18
- *.local-only
@@ -1,25 +0,0 @@
1
- [forbidden-imports]
2
- modules = [
3
- "argparse",
4
- "click",
5
- "typer",
6
- "flask",
7
- "fastapi",
8
- "django",
9
- "uvicorn",
10
- "granian",
11
- "starlette",
12
- "tornado",
13
- "bottle",
14
- ]
15
-
16
- [stdout]
17
- enabled = true
18
- ignore = []
19
-
20
- [entry-point]
21
- enabled = true
22
- ignore = []
23
-
24
- [files]
25
- exclude = []
@@ -1 +0,0 @@
1
- 0.110.2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes