diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d038425e5..12a871b80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,10 @@ jobs: # 15-minute budget on it depending on runner speed — so whether a green # commit stayed green was decided by which runner it drew, and `main` # itself was cancelled at the cap. Splitting the *suite* rather than - # raising the number keeps a real bound: each shard is roughly a third of - # the work, so the margin grows back and stays there as the suite grows - # (add a shard). + # raising the number keeps a real bound: each shard is roughly a quarter + # of the work, so the margin grows back and stays there as the suite grows + # (add a shard). Three shards had reached 13 of their 15 minutes on `main` + # by #904, and #872's tests took one past the cap: a fourth was added. # # `conftest.py` assigns whole test *files* to shards, deterministically, # from the collection and the measured seconds per file in @@ -34,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4] runs-on: ubuntu-latest timeout-minutes: 15 @@ -83,11 +84,11 @@ jobs: # there too, so keep it out of the coverage pass rather than doing the # same AST sweep twice on every PR. # - # No `--cov-fail-under` here: a shard covers a third of the suite, so + # No `--cov-fail-under` here: a shard covers a quarter of the suite, so # its coverage is a fragment. The threshold is enforced once, on the # combined data, in `coverage` below. env: - SHIPGATE_TEST_SHARDS: 3 + SHIPGATE_TEST_SHARDS: 4 SHIPGATE_TEST_SHARD: ${{ matrix.shard }} run: python -m pytest -n auto -m "not perf" --ignore=tests/test_adapter_static_only.py --cov=agents_shipgate --cov-report= @@ -107,7 +108,7 @@ jobs: coverage: # The coverage gate, enforced once over the combined fragments. # - # Splitting the suite split its coverage with it, and three partial + # Splitting the suite split its coverage with it, and four partial # measurements each fail an 85% threshold that the whole run passes. This # job is where the number is a number about the suite again. needs: [suite] @@ -148,7 +149,7 @@ jobs: # test pass. # # `coverage combine` consumes its inputs, so the fragments are copied - # to distinct names first — three files all called `.coverage` in + # to distinct names first — four files all called `.coverage` in # separate directories would otherwise collide on the way in. run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c517316..33e63e34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,22 @@ - **Submodules.** A gitlink under the scope is materialized as the empty directory a checkout without `--recurse-submodules` leaves; its content is never fetched. The same gitlink commit on both sides is named in `limits` as unchanged and does not make the comparison `partial`, since identical content cannot carry a change. An added, removed or moved gitlink is a coverage gap over its path on each side that has it, and over the whole scope when it is the selected scope itself, so the result is `partial`, never `compared`. A gitlink was refused (exit 2) before. - **Google ADK.** `google.adk.Agent`, the package-root re-export of `google.adk.agents.llm_agent.Agent`, is read as an agent constructor by the ADK reader, for `from google.adk import Agent` and `import google.adk as adk` alike. CubeSandbox#1508 now establishes `cube_code_agent` and is `partial`, naming the tool it imports from a sibling module (#864), instead of `not_established`. - **Re-measured.** At the root scope, main `a430e81a` refused CubeSandbox#1508, dlt#4417 and asterinas#3834; this change compares each, with its own limits (a Python census past `--max-python-files`, an unsupported framework, an unresolved import). No application comparison schema change; `application_comparison_schema_version` stays `"0.1"`. +- `diff --application` rows now name what a bound tool reaches: the endpoint, the request fields, the credential it sends and which arguments the model chooses. (#872, part of #868) + - **The problem.** tensorflow/tensorflow#128063 was the only one of 134 open SDK/ADK PRs whose rows were both correct and complete. Even so, each row named only a signature. `submit_pr_code_review` posts a pull-request review whose `event` can be `APPROVE`, using `GITHUB_TOKEN`; `get_pull_request_details` only reads. The existing assessment gave all three tools `write` with unknown evidence. + - **Reach.** Each side of a function-tool row carries `reach`: the outbound `requests`, `httpx`, `aiohttp` and `urllib` calls read from the tool's own code and its same-scope helpers, up to three calls deep. Every call the read cannot follow is a named limit with its location. Each call records: + - the method and the URL template (`{pr_number}`, `{env OWNER}`, `{…}`); + - literal request fields, and the literals a field is chosen from with the parameters that decide; + - `model_supplied`: which parameters flow where; + - `credential_sources`: environment variable names only, never a value; + - for GraphQL, query or mutation. + - **Effect evidence.** `effect_evidence` is the existing `assess_tool_semantics` over the tool, with the reach as one more structural source (`source_http_call`). A write or delete call supports that effect. `read` needs every call followed and every outbound call a read. Read-only method names pass only on plain data or on objects a known library returned. A decorator, a store into an unknown object, or a method on an object the read cannot name blocks `read`. + - **Secrets.** A hard-coded credential is `literal: true` and never printed. In a URL, a query value prints only when it is a short lowercase word or a number, and a token-shaped path piece is withheld. A field value prints only when it is a plain word and its name does not suggest a secret. + - **Two constructions of one ADK name.** Each row's `binding_location` names the first construction that lists the tool, and `construction_sites` lists every one. Before, every row pointed at the first construction. + - **Measured.** On the 134-PR corpus, rows, statuses and exit codes are unchanged, and run time is flat. Of 96 row sides in 19 PRs: + - 9 name outbound calls (3 PRs) and 5 name a credential; + - 7 carry structural effect evidence (3 read, 4 write); + - 65 name at least one limit, mostly database helpers, tracing and SDK clients. + - `reach`, `effect_evidence` and `construction_sites` are evidence outside the compared meaning. `application_comparison_schema_version` is `0.2`. - Follow a tool an agent binds from another module in the repository. (#864) - **The problem.** jpka/attest#3 added `memory_bank.remember_firm_finding` and `memory_bank.recall_firm_memory` to an ADK agent's `tools=[...]`, with the module brought in by `from . import memory_bank`; the reader stopped at the module boundary, so `diff --application` showed no row and `scan` catalogued only the unchanged local tools. The same gap left OpenAI Agents SDK tools such as `from ..tools.shop import add_to_cart` unresolved. - **What resolves.** For Google ADK, a name imported from a sibling module or re-exported by a package, a module-qualified `module.function`, a plain `alias = function`, and `FunctionTool(imported_function)` / `LongRunningFunctionTool(...)`, including a wrapper built in the imported module; for the OpenAI Agents SDK, a name or `module.function` that reaches a definition carrying the SDK's `@function_tool`. The tool is the definition, with its own signature, location and implementation digest, so jpka/attest#3 now shows the two memory tools as `ADDED` and leaves its four unchanged bindings, `scorer.score_answer` included, alone. A definition reached by several spellings is one tool; same-named functions in different modules stay two. diff --git a/docs/application-comparison.md b/docs/application-comparison.md index d8eae0864..63ed763ab 100644 --- a/docs/application-comparison.md +++ b/docs/application-comparison.md @@ -139,10 +139,12 @@ An absent side names the missing scope and suggests `--base-scope`/`--scope` for relocation. If neither selected directory exists, the command refuses with exit 2. A removal describes the selected source path, not the entire repository. -`--json` emits `application_comparison_schema_version: "0.1"`, engine identity, +`--json` emits `application_comparison_schema_version: "0.2"`, engine identity, requested and compared refs/tree IDs, per-side scope/coverage, rows, source correspondence, `scope_selection`, `comparisons` when a derived change spans -more than one application, and a deterministic `comparison_id`. This is a separate advisory +more than one application, and a deterministic `comparison_id`. Version 0.2 adds +`reach`, `effect_evidence` and `construction_sites` to a row's sides (see +[What a bound tool reaches](#what-a-bound-tool-reaches)). This is a separate advisory artifact from the existing host diff JSON and verifier receipt. - `compared`: the selected supported source observations were compared. An @@ -504,6 +506,236 @@ module it lives in: moving a function is not a change. So retargeting a binding between two functions whose definitions are the same text in different modules shows no row, even when the modules differ in what the function body refers to. +## What a bound tool reaches + +A signature says what the model may pass, not what the call does. Each +`before`/`after` side of a function tool also carries `reach`: the outbound +HTTP calls the tool's own code makes. It is read statically from the function +and the repository helpers it calls, up to three helper calls deep. + +```text +ADDED tensorflow_pr_review_agent → submit_pr_code_review + after: submit_pr_code_review(pr_number, overall_assessment, summary_comment, inline_comments) -> dict[str, Any] at pr_review_agent/agent/agent.py:591 + implementation: pr_review_agent/agent/agent.py:140 (f4919a519638) + reaches: POST https://api.github.com/repos/{env OWNER}/{env REPO}/pulls/{pr_number}/reviews at pr_review_agent/agent/utils.py:172 via pr_review_agent/agent/agent.py:190 post_pull_request_review, and 1 more call site + field event ∈ {APPROVE, COMMENT, REQUEST_CHANGES}, decided by overall_assessment + model-supplied: inline_comments → field comments; overall_assessment → field event; pr_number → url; summary_comment → field body; inline_comments → field body + credential: env GITHUB_TOKEN → header Authorization + effect: write (structural evidence: outbound call at pr_review_agent/agent/utils.py:172) + reach limit: pr_review_agent/agent/agent.py:165 calls ….get ('_PREFETCHED_PR_DETAILS' is bound more than once in agent.py (lines 51, 55)), which is not read +``` + +What each call records: + +- **The method and the URL.** Calls through `requests`, `httpx`, an `aiohttp` + session and `urllib.request.urlopen` are read, including a + `requests.Session()` or `httpx.Client(base_url=...)` built in the function or + at module level. The URL is a template. Each part is a literal, a tool + parameter (`{pr_number}`), an environment variable (`{env OWNER}`), or `{…}` + for a part the read cannot name. Module constants are followed through + imports. +- **Request fields** (`json=`, `data=`). A field is one of: + - a literal value, printed only when it is a number or a word without + digits and the field's name does not suggest a secret (`pass`, `token`, + `key`, …); any other literal is `literal: true`; + - the literals it is chosen from, with the parameters that decide + (`field event ∈ {…}, decided by overall_assessment`); + - the parameters or environment variables it is made from. +- **`model_supplied`.** Which parameters the model supplies flow into the URL, + method, query, fields or headers. A parameter the framework injects, such as + a tool or run context, is not model input, and neither is a parameter the + function overwrites before reading it (`pr_number = int(os.environ[...])`). +- **`credential_sources`.** For a credential-named header (`Authorization`, + `X-Api-Key`, `Cookie`, …), `auth=`, a secret-named query key or field, or a + value spliced into a URL's userinfo (names are matched by whole word, so + `max_tokens` and `Idempotency-Key` are not credentials), these are the environment variables its + value is made from, by name only. A hard-coded credential, or a literal + default (`os.getenv("KEY", "sk-test")`), is reported as `literal: true`, and + its value is never printed. A value fetched at run time is `computed`. In a + printed URL, a query value is shown only when it is a short lowercase word + or a number (or a date, or a list of words). A token-shaped path piece after + the host is withheld: a run of 20 characters, or of 7 with letters and at + least two digits, so `us-central1` stays readable. So is the whole path of a + webhook or bot URL except its method name + (`hooks.slack.com/services/[REDACTED:sensitive_field]/…`). These are shapes, + not proof: a secret spelled like an ordinary word in an ordinary place can + still print, as it already appears in the source. +- **GraphQL.** For a call to a GraphQL endpoint (the URL, or the variable it + comes from, says `graphql`), this records whether the literal `query` + document defines a query or a mutation. Transport is not effect: a query + sent over POST reads. A `query` field elsewhere (SQL, LogQL) is a plain POST. + +Each side also carries `effect_evidence`: the existing semantic assessment of +the tool (see [effect evidence](effect-evidence.md)), with the reach as one +more structural source, `source_http_call`. It gives the conservative effect, +the evidence status and the claims. + +- **Write or destructive.** A POST, PUT or PATCH call, or a GraphQL mutation, + supports `write`; a DELETE supports `destructive`. It does so whatever else + the tool does. +- **Read.** A tool is said to read only when all of these hold: + - it makes at least one outbound call, and every outbound call reads (GET, + HEAD, OPTIONS or a GraphQL query); + - every call the tool makes was followed, and none of them is a limit. +- **Limits.** The read passes over only calls with no effect outside the + process: + - a closed list of builtins, and functions such as `json`, `re`, `os.path` + and `logging`; + - read-only methods (`get`, `strip`, `json`, …) on plain data (an + environment value, a model argument with a JSON type, or a list or dict + holding only those) or on an object one of those libraries returned (an + HTTP response, a regex match, a date). What such a method returns is plain + data only when what it reads is: `CLIENTS.values()` hands back clients; + - list and dict changes (`append`, `update`, …) on the function's own + containers or on plain data. + + Every other call is a `reach.limits[]` entry with its location, and the tool + is then not said to read. That includes: + - a library function, `open`, and a method on an object the read cannot + name (a repository class's `get` may POST); + - a helper beyond the bound or outside the scope; + - a decorator the read cannot see into; + - a store into an object it cannot name (`cache[key] = value` on a redis + client), or an attribute set on something the function did not build + (`r.method = "DELETE"` on a request passed in). + + A repository function handed to another call (`sorted(items, key=helper)`) + is read as called. So is one set as a request's or client's hook or auth + (`hooks={"response": [audit]}`, httpx `event_hooks`, `session.auth = sign`), + since it runs on every request. Any other function that a passed-over call + runs is a limit: `map(es.delete, ids)`, a `key=` that is not a builtin, + `iter(queue.pop, None)`. A request's changes after it is built are read: its + `data`, its headers, and a method set on it, which leaves the method unread. + Setting anything on a client other than its transport settings (headers, + auth, timeouts, proxies, …) — `session.request = send` — is a limit. + Options spread into a call (`requests.get(url, **opts)`) are read one by one. + + Five rules bound what `read` can rest on: + - **A call through a client or request built elsewhere is a limit.** A + module-level, imported or factory-made client (`SESSION.get(...)`), or a + module-level `urllib` request (`urlopen(PURGE)`), can be configured + anywhere. So only a direct library call (`requests.get`), or a client or + request the same function constructs, can support `read`. Its hooks and + auth are still read, so a write they make is still found. + - **A value read out of a module-level dict is never taken as written.** + Any code may change such a dict, through routes a static read cannot + bound: a helper two calls away, `*args`, an accessor, a loop. So + `CONFIG["method"]`, `QUERIES["viewer"]` or `HANDLERS["drop"]` is unknown, + and cannot support `read`. So is a value read out of a copy of one + (`{**DEFAULTS}`, `config.update(DEFAULTS)`). A dict the tool's own + function builds from literals is read as written. + - **A module-level constant or function rebound elsewhere is unknown.** + Every attribute and `setattr` name the scope stores under is collected + (`agent_config.METHOD = "DELETE"`, `helpers.fetch = purge`), whatever the + store goes through: an import in a function, a dotted import, an alias or + a parameter. So is every module whose namespace is changed under names + the read cannot see. That covers a computed `setattr`, `vars(m)`, + `m.__dict__`, `globals()`, `exec` or `eval`, and `sys.modules[...]` + (replaced, or its `__class__` swapped). It also covers a namespace + handed to a call that is not a known reader + (`code.interact(local=vars(m))`). What the changed object may be is + followed: + - through names, closures, `global` and `nonlocal`; + - through parameters and their defaults, however many helpers deep; + - through what a function returns, and through displays, loops and + `.values()`; + - back to an `import`, `sys.modules[...]`, `importlib.import_module`, + `__import__` or `globals()`. + + Any other object is taken as not a module: a call's result, an ORM row, + an instance attribute. So `setattr(user, field, value)` withholds + nothing. A module is also tracked when it is kept where it is not + followed, in a container, an attribute, a class attribute, or a call the + scope does not define (`Holder(config)`, `SimpleNamespace(m=config)`). + If any namespace change in the scope goes to an object the scan does + not follow, every module kept that way is unknown. Every value from + module scope is unknown in these cases: + - a namespace change the scan cannot tie to one module, such as a + computed `sys.modules[name]` store, or a frame's `f_globals`; + - a lambda, or a function passed on as a value (a callback, + `functools.partial`, an import hook), that changes its argument's + namespace. A library may hand it any module. A function used only as + a decorator (`@tag`, or returned by a factory used only as + `@tag(...)`) is handed definitions, never a module; + - a file the scan cannot read, or a scope past its 2000-file bound. + + A module-level name another file imports is followed to what it holds: + an alias (`import config as settings_module`, `CONFIG = config`, a tuple) + is that module, and so is a name a star import may bind. A module held in + a container there (`SETTINGS_MODULES = [config]`), or returned by a + module's `__getattr__`, is also kept. Any other imported name that is no + module file of the scope is an object the scan does not follow. `del sys.modules[name]` only makes the next import read the module + again. A module registered lazily under its own name and spec + (`module_from_spec(find_spec(name))`) is itself. A builtin rebound + anywhere in the scope (`builtins.print = send_log`, + `builtins.__dict__["print"] = …`) is not read as the builtin. A library + function the read takes as pure and replaced anywhere in the scope + (`json.dumps = audited`, `logging.Logger.info = …`, `setattr(re, "sub", …)`) + is a limit where the tool calls it, however the module was reached + (`sys.modules["json"]`, `import_module("json")`, an alias, a helper's + parameter). So is a method replaced on a library class + (`pathlib.Path.read_text = …`) where the tool calls it on such an object. + + A value the tool takes from module scope under such a name, or from such + a module, is unknown. Stores through a method's own `self` change an + instance and are not collected. Two unrelated uses of one name only ever + withhold a value. + - **State a closure shares is not taken as written.** A dict the tool reads + from an enclosing function (an ADK factory's `state = {...}`) outlives + one call, so what is read out of it is unknown. A name a function + nested in that enclosing function rebinds (`nonlocal method`) or stores + into (`state["method"] = "DELETE"`) is unknown too, whichever of its + closures is the tool. + - **A patch to an HTTP library anywhere in the scope is a limit** on every + tool that sends: a store into `requests`, `httpx`, `urllib3`, `http`, + `socket` or the like (`requests.get = logged_get`, + `setattr(requests, …)`, `sys.modules["requests"].get = …`, or through an + alias or a helper's parameter, a client class's method included), and a + call that installs or instruments the + stack (`install_opener`, `patch_all`, `ddtrace.patch(…)`, + `instrument_requests()`), in any file outside tests, inside a function or + not. A setting stored as a plain value (`DEFAULT_RETRIES = 3`, a class's + `timeout` or `max_retries`) and a retrying transport or adapter are not + patches. A class's other attributes are, even as a plain value + (`urllib.request.Request.method = "DELETE"`). So is any attribute but a + tuning one stored on a class reached through an object or its bases + (`req.__class__.method = …`, `type(req)`, `Sub.__mro__[1]`, through an + alias, a loop or a helper too), unless it is the method's own class + (`type(self)`). A patch is followed however its library was reached: + through another module's attribute or a class's (`agent.requests.get = …`, + `clients.Http.lib.get = …`, an import in a class body, re-exported + relatively or with `*`), kept + on an object, `self` or one built around it and patched by name + (`http.module.get = …`, `self.http.get = …`, + `Holder(urllib.request).module.Request.method = …`, a method the stack + sends through such as `Session.prepare_request`, one of its classes + however reached), kept in a container, under another attribute or a + property, set with `setattr` or `__dict__`, or passed through a call + (`typing.cast(type, requests.Session).request = …`), or through + `mock.patch.multiple`, `patch.object` under any alias and `wrapt`'s + wrappers. A function of the stack handed along + (`asyncio.to_thread(requests.get, url)`), a constant and an exception + are not the stack kept. A chain of re-exports too long to follow counts + as any module. A file the scan cannot + read is named. A patch outside the compared scope is not seen, nor is code run + from a string (`exec`, `eval`): 13 of the 99 corpus scopes run an interpreter + or a calculator that way, so failing closed there would limit most of them. A recursive call with other arguments is followed twice as called, + then once with every parameter unnamed. Past 24 outbound calls, the rest + are not listed but still count for the effect. +- **Unread method or document.** When a request method or a GraphQL document is + not a literal, it is a limit, and that call supports no effect. + +`reach` and `effect_evidence` are evidence, not compared meaning. A helper's +changed endpoint does not make a binding `changed` on its own, and neither +field is a verdict or a risk score. The read never runs the code or fetches, so +a URL built from a response stays `{…}`. + +When one Google ADK agent name is constructed more than once in a module, each +row's `binding_location` names the first construction, in line order, that +lists the tool, and `construction_sites` lists every construction that does. +When the constructions differ, which one runs is not established, so the agent +stays a named limit; identical constructions are one agent. + ## Evidence identity and recovery `coverage_gaps` records each gap's source/agent/tool and whether it affects diff --git a/docs/distribution-surfaces.md b/docs/distribution-surfaces.md index f2d80fcf4..55f936c47 100644 --- a/docs/distribution-surfaces.md +++ b/docs/distribution-surfaces.md @@ -75,7 +75,7 @@ and this document are checked against each other by | `human_review_decision` | `docs/human-review-decision.md` | `release_decision_vocabulary` | `test_surface_enumerations_match_the_engine_vocabulary` | Host-neutral read-only evaluator; no GitHub acquisition, persistence or operation authority. | | `github_action` | `action.yml`, `scripts/github_action_outputs.py` | `merge_verdict_vocabulary` | `test_action_input_enumerates_engine_merge_verdicts`, `test_action_output_script_shares_the_engine_merge_verdicts` | The paired `shipgate_wheel`/`shipgate_wheel_sha256` inputs install a caller-supplied local wheel instead of a published version, so that route names no channel and claims no `executable_pin`; it is refused unless both halves are given, and it installs `--no-deps`. `tests/test_action_engine_install.py` proves the refusals. Every `python` the Action starts in the workspace runs with `-P` or as a script path, so a pull request's `pip/` or `agents_shipgate/` package cannot stand in for pip or the engine; the same file executes the install and merge-verdict steps against such a checkout. The `v1.0.0` tag predates that fix; the published `v1.1.0` carries it. | | `capability_diff` | `src/agents_shipgate/cli/diff.py`, `src/agents_shipgate/core/capability_diff_rows.py`, `src/agents_shipgate/core/host_comparison.py`, `src/agents_shipgate/report/host_comparison.py`, `src/agents_shipgate/core/unread_inputs.py`, `src/agents_shipgate/cli/verify/changed_inputs.py` | — | — | Answers no question the engine answers: it emits no verdict, no release decision and no pin. Every field is read from the drift payload the engine already produces — `risk` is the engine's severity and `expansion_signals` is the engine's word on widening — so there is no second implementation to drift. A `permission_mode` or `sandbox` row names the setting and its value as the file spells it (`enableAllProjectMcpServers: true`, `defaultMode: dontAsk`), recovered from the grant's published value and digest, and a Claude Code setting's `why` is the basis the engine's one setting table (`core/host_settings.py`) records for the value; that table also rates the grant and `check`'s violation, so a row's severity and the violation's risk give one answer (#827, `tests/test_prompt_disabling_settings.py`). `verify`/PR and `check` reuse the host comparator (#684, `tests/test_manifest_free_pr_rows.py`), and the source name each named reusable-workflow secret refers to, also non-widening, with a redacting name or target refused rather than compared, and an unreadable value neither compared nor named on this surface — only the host inventory and `audit --host` name its `job/destination`, as on `1.0.0` (#693, `tests/test_reusable_workflow_secret_mappings.py`); check retains argument redaction, except that an allow rule the engine rates as reaching a [documented arbitrary-code launcher](engineering/exec-equivalent-permissions.md) is shown in table text (`Bash(npx *)`, or a wider rule's own prefix of it, `Bash(python3 *)`) and never with a user operand, the one rating `diff`, `audit --host`, `verify` and the PR comment also carry, and its existing local-policy control (#824, `tests/test_exec_equivalent_permissions.py`). Missing comparison evidence never supplies empty comparable rows. Default host mode only (`--application` is registered separately below); workflow rows compare effective writes and reusable secret recipients (#685, `tests/test_workflow_capability_diff.py`) and each job's remote step action references, as a non-widening change (#771, `tests/test_workflow_step_action_references.py`); and each job's agent launches — a documented agent action's permission inputs, the permission flags of a `run:` that is one plain `claude -p` / `codex exec` command — and checkout refs, compared as text, as a change unless the job gains a documented widening rule, which the engine names in `expansion_signals` (`workflow_agent_widened_*`) — a rule read only from text the engine reads exactly (no shell is parsed; an argument input that is not a plain list of words is compared by a digest and read for no rule), and a gain the engine does not claim (a rule moved in from a job the launch left, one an unread step of the job rewritten as a read launch may already have met, or one the job's launch held before in an expression or an unread argument input) named in the `why` from the same engine function, never counted — with a note on a workflow row naming the untrusted-input trigger, write scopes, secrets and pull request checkout beside each agent step, read off the grant the engine published and moving no direction; an unread `run:` agent step (never compared, so never a row), an unreadable value or a setting published redacted is named only by the host inventory and `audit --host`, as for an unread secret value, an unread argument input or an unresolved launch is named there and in the `why` of a row reporting its launch, and a checkout ref holding credential-shaped text is refused as a redacting step reference is (#823, `tests/test_workflow_agent_launches.py`); every job id, step label, trigger and scope name those rows print is the label the engine published once where it built the grant, redacted, never re-derived here; `check`'s workflow evidence is derived from the raw declarations, which it still compares, and redacts job and scope names by the same rule; two distinct job ids or triggers in one workflow, or scope names in one `permissions` mapping, that publish alike are refused rather than compared, so while such a workflow exists `check` refuses on every run even when it is unchanged (#802, `tests/test_workflow_label_redaction.py`); artifact-only edits remain separate evidence. Tool-source subjects are #655. Where a partial or experimental surface is byte-identical on both sides, `diff` and `verify` compare the rest and name it in `unchanged_limits`; `check` keeps refusing, because its boundary result cannot carry a limit yet (#721). A surface the reader reaches through an in-tree link it reads through qualifies only when that link, a link with the same text at each link on the way, and the file it lands on, the same blob at the same path, are both unchanged, read from the base's Git tree entries against a commit's or, without following any link, the working tree's; any change to either is treated as before. The same proof decides which shared plugin-reference limits `check` leaves out, so behind such a link `check` compares, and publishes the rows it finds, exactly as for a limit at its own path, and a comparison `partial` only because of such a limit is `comparable` with it in `unchanged_limits` (#822, `tests/test_linked_unchanged_limits.py`). An added or changed MCP row's `why` appends a launch-source note — `launch source is mutable`, or `launch source moved from pinned … to mutable …` when both sides establish a pin — read from the `launch_source` the engine published on the grant by a bounded declaration grammar ([mcp-launch-source-notes](engineering/mcp-launch-source-notes.md)); grant equality, the inventory digests and saved baselines leave that fact out, so the note adds no row and moves no direction, `expands`, severity, expansion signal or `check` decision (#825, `tests/test_mcp_launch_source.py`). A hook row's `why` states the grant's loading basis, read from its published `source`, `access` and `risk` by the engine's `hook_loading_basis`; only a hook the host loads for this project earns an expansion signal — one a settings layer declares, or one a plugin selects that the repository's project settings enable from an in-repository marketplace — so a declared-only hook, or one a plugin selects without that enablement, is a row and never an expansion, and a removal names no basis (#714). An added or changed Claude Code `PreToolUse` hook row whose basis is host configuration or a project-enabled plugin appends `inline allow auto-approves matched tool calls without a prompt (matcher …)` when a handler's published `inline_allow` is true — the engine's reading of a literal, unconditional allow on a broad matcher by a bounded declaration grammar ([inline-hook-allow-notes](engineering/inline-hook-allow-notes.md)), which grant equality, the inventory digests and saved baselines leave out — so the note adds no row and moves no direction, `expands`, severity, expansion signal or `check` decision (#826, `tests/test_inline_hook_allow.py`). `check` compares without a plugin-reference limit both sides share on an untouched source, which it cannot name and, untouched, does not route; a limit only one side carries makes its comparison incomparable. Those rows are not what routes a change: `check`, and the boundary check a manifest-backed `verify` runs, route a changed hook declaration of a plugin the project settings enable through the existing protected-surface rule, from the plugin hook reader's selection on both compared sides, and count a changed hook file such a plugin selects that the reader does not open as incomplete input; the rows beside either are unchanged (#809, `tests/test_enabled_plugin_hook_routing.py`). A partial clone that never fetched the base's objects is refused as `objects_missing`, exit `2`, never compared and never fetched; the refusal ends with the remediation sentence `verify` reports for the same reason, produced by the same function (#817, `tests/test_capability_diff_partial_clone.py`). The text of `diff`, `verify`, the PR comment and `check` reads the rows through one function, `review_changes`, and adds no row and changes no row value in any JSON projection (#795, `tests/test_host_diff_review_changes.py`): a permission rule is named with its disposition; an MCP server with the command name (never its path) or redacted URL, its package and argument digest (#819) and the env and header key names its grant already publishes, a URL printing only in the engine's sanitized scheme-and-host form and otherwise as `url not shown`, or, when none of those differ, a sentence naming what was compared and that the change is in a detail not shown, such as the command's path or another setting; a hook with each handler field that changed — its group's matcher, its command as its executable's name and digest, its timeout — before and after, a handler only one side declares, or the published handlers in a different order with a detail not shown that may also differ, and past the handler bound the same kind of sentence naming a handler past it, all read from the handlers its host-grants `0.7` grant publishes, which hold no command or argument text, and never re-derived here, and a declaration outside the documented hooks shape named as not shown rather than guessed (#819, `tests/test_hook_mcp_detail_fields.py`); those hook and MCP members display what `config_sha256` already binds, so grant equality and every inventory digest leave them out, a saved baseline holds none of them, and no row, row value, reason, digest or control answer moves; the PR comment gives the lines 1.1.0 printed their room first, the coverage block included, and prints an entry whole when the whole comment fits, otherwise cut to the widest length of at least 60 characters at which it does, or else in its shortest form (a difference cut after its name, an added or removed grant as its row), never longer than the entry 1.1.0 printed, with one line naming `verifier.json`, so no long entry hides a row, the coverage block, the change count, the review question, the reproduction or the advisory that 1.1.0 kept (#819 review, cycles 4 and 6); an allow rule the permission lattice decided another replaced (`widened` or `narrowed`), or the exact rule text that moved between dispositions in one host and source (`moved`), is one entry, never on the routes that redact rule arguments; and `diff` counts entries `from N rows` when one joins rows. Comparable results with entries end with one review question, naming the row count when an entry joins rows, and every result whose comparison names a base commit and a commit or working-tree head — a zero-row result and a refusal included (#812 follow-up, `tests/test_host_comparison_coverage.py`) — ends with the compared commits, the tool version and an `agents-shipgate diff --base ` reproduction, labelled `Inputs:` rather than `Compared:` where the comparison was refused, since that run compared nothing — and a refused comparison publishes no `review` object at all, so those two lines are the only place that run states its provenance, built from the `base_commit` it publishes beside the refusal; `check` and a provided diff print the question alone, and no result without a change asks a question. Every one of those facts is published beside the rows, so a machine consumer reads what a human reads (#795 slice 2, same test file): a row adds `disposition`, the `allow`/`ask`/`deny` list a permission rule is declared under and `null` for any other kind, on every route that publishes rows; and `review` in `diff --json` (capability diff `0.3`) and `host_comparison.review` in `verifier.json` (verifier `0.20`) — one object for one comparison — carry the presented changes, each naming the `row_indexes` it stands for, the `direction` the text uses (`widened`, `narrowed` and `moved` included, which no single row can carry), its cells, its `why` and one `expands`, plus a `summary` of `{rows, changes, widenings}` equal to `diff`'s summary line, the review question and the reproduction command. The block is refused unless its changes stand for every published row exactly once, its counters match and no joined change's two sides read alike, so the routes that redact rule arguments publish their rows alone and never a pair that reads `X → X`; `check`'s boundary result carries rows, with their dispositions, and no block. It is presentation, not a second opinion: it is the one `review_changes` projection the text prints, so the rows, their values, their count and every control answer are what they were. A comparison read back from JSON prints the changes it published, and one whose rows a caller sliced falls back to those rows. Each comparison also says what it established (#812, `tests/test_host_comparison_coverage.py`): `coverage` in `diff --json` (capability diff `0.3`) and `host_comparison.coverage` in `verifier.json` (verifier `0.20`) are the same object, printed as `What this run established` by `diff`, `verify` text and the PR comment. It is read off the grant changes, artifact changes, observed sources and blocking issues the comparator already computed: a file's rows, counting a source inside it (`#profiles.`, `#plugins.`); a file with no row and no artifact change called unchanged (`compared`, `0` rows) only when Git proves its blob identical, as the check `unchanged_limits` uses does, asked privately in one bounded batch and never published, because the artifact digest redacts `env` values and `apiKeyHelper`; a file that changed with no compared grant moving (`changed_without_grant_change`), whose artifact differs only in its digest or whose content Git shows differs while its artifact did not (never a difference a checkout line-ending conversion or a converting attribute explains, and no filter is run), never a plugin manifest or marketplace, a retargeted link or project settings while a hook's loading basis moved, worded as no compared grant changing and never as which fields changed; any other changed file with no row (`changed_without_rows`); a file Git neither proves identical nor shows differs — a provided diff, a link read, a redacted path, a working-tree file a checkout wrote with `CRLF` that Git reports unchanged — as `unchanged_not_proven`, never no change and never a change (#812 review cycle 3); the side that published a source, worded `published by` rather than `read in` for a plugin manifest or marketplace, which is published only while it declares hooks; and on a refused comparison each blocking source and its kind. Outside the bounded candidate rules below, a file no inventory observed is never an item and its absence is no claim, which the block states where it is read — one line under the heading and `read_sources_only` in the JSON — so a true list cannot be taken for the account of the change (#812 follow-up); a source already in `unchanged_limits` is not repeated; the list is capped at ten with `omitted_items`, ordered so what no row shows precedes a file's rows and, among blocking limits, by kind (`unreadable`, `parse_failed`, `unresolved_precedence`, then `unsupported`, `dynamic_source_excluded`, `remote_source_excluded`) — order, not severity, and a ranking of kinds rather than of items, since `unsupported` carries both a file this entry merely does not accept and one whose own text would not parse, so an item behind the count may still be one to repair; total down to every field an item is keyed by, the source name and then its side, limit and status — and counted in text as items not listed, ranked below those listed, and the PR comment lists only what fits in the room its entries, review question, reproduction, advisory, next action and evidence leave, at most 2000 characters, so the block never pushes out a line the comment prints without it (a row list that fills the comment by itself still truncates it, as on `1.0.0`); an instruction file's line carries no redacted-values note; sources are the inventory's redacted paths; `null` means not recorded, which is how a `0.19` verifier reads. It moves no row, reason, digest, baseline, control state or next action, and `check`'s boundary result and text carry none, so neither `check` nor a provided diff asks Git anything for it. The same list names the changed inputs this entry does not read (#821, `tests/test_unread_changed_inputs.py`): capability diff `0.4` and verifier `0.21` add a `changed_not_read` item, with the `candidate` rule that named it, for each path in the comparison's own changed-file set — the committed change, or the working tree's tracked and untracked changes — that a bounded, documented rule set recognises as plausibly agent configuration (`mcp.json` in a plugin directory, a plugin manifest's `mcpServers`, a Codex, Cursor or Copilot manifest's `hooks` and the hook files it names, a manifest or marketplace that does not parse, `.cursor/hooks.json`, host settings below the repository root, a marketplace entry's external `source`) and that no inventory published; a member is named whatever read its file, because no reader reads it. It is named from the path and, for a manifest or marketplace member, its text: nothing is fetched, run or read as a grant, so it is never a row, a widening, a `check` violation or a loading claim, and an external source is described redacted and never fetched. It ranks right after the blocking limits, inside the same cap; `read_sources_only` is `false` while one is named, and the first line says so instead; `unread_candidates` and `unread_candidates_not_examined` say whether the change set was examined and how many candidates were not — past the bound of 32, or because a file the rule needed was not read or did not parse, one count the text names both causes of. A manifest-free `verify` whose only host-relevant change is such an input, or a changed candidate it counts as not examined, publishes the comparison instead of the setup route, and `verify --preview` then names `audit --host` instead of `init --write`, in an agent-related workspace too; a `0.20` verifier reads with the search not recorded. A comparison refused only by plugin-reference limits, each bounded by its plugin directory, that no compared source depends on, is `partial` instead (#808, `tests/test_partial_host_comparison.py`); any other blocking limit it carries must be one both sides share on an unchanged source, named in `unchanged_limits` as on a comparable result. Capability diff `0.4` and verifier `0.21` publish `comparison_status: partial` with the refusal's `incomparable_reasons`, the rows, review and unchanged limits established outside those directories, and each directory (the outermost, where one holds another) as the reserved `coverage.items[].scope` on the `blocking_limit` items it bounds, and never call a changed project settings file without a row `changed_without_grant_change`, since the hooks whose loading basis it decides are not all compared; `diff`, `verify` text and the PR comment lead with `Partial comparison against …` or `Host capability comparison partial: …` and `Not compared: , …` before any entry, and a partial result with no entry is never printed as no change. Independence is read off the reader's reference graph, never off directory names: any other limit that is not unchanged, a reference leaving its plugin, a plugin at the root or holding project settings, a marketplace elsewhere declaring inline hooks for it, or a directory that does not publish as itself refuses as before. It answers no engine question and moves no control: a partial comparison is not comparable, `verify`'s control and route are the refusal's, the control envelope projects it as `incomparable` with no rows, and `check`, whose boundary result cannot name a directory, refuses its comparison and decides exactly as before. A `0.20` verifier claiming a partial comparison or a scope is refused. A selected hook's script bytes are a comparison input of its hook grant (#702, `tests/test_hook_script_capture.py`, `tests/test_hook_script_comparison_limits.py`): a script-only edit is one `changed` row on the declaring hook, never an expansion, whose `why` names the script and loading basis and whose change carries the digests, read from the `script_inputs` the engine published; the script's own coverage line is `changed_without_rows`, which the text words as the row of the hook that runs it naming it, and a script only one side's hooks select is `compared` on that side, its selection change being the hook's row. A script the reader could not read costs that script only: a shared limit Git proves unchanged, or a script Git proves is on neither side, is an `unchanged_limits` entry of kind `unreadable`, and any other makes the comparison `partial` with a `blocking_limit` item naming the script as both `source` and `scope`, and `Not compared: the bytes of …` in the text; a working-tree difference a line-ending conversion can explain is `unchanged_not_proven`, not a row. A selected hook whose script is not resolved is a `script_not_resolved` item naming each handler and its reason, while the change could touch it, never a row, widening or limit. `check` and the boundary check of a manifest-backed `verify` route a changed selected script as a protected change of the hosts selecting it, from both compared sides' declarations, reading the base's only when the change touches a declaration (`tests/test_hook_script_routing.py`); `check` and a provided diff, which name no limit, leave out a selected script the change does not touch, from their comparison and input coverage alike, and a provided diff that touches both a script and its declaring file compares the script's bytes as the diff states them (`tests/test_hook_script_comparison_limits.py`). | -| `application_diff` | `src/agents_shipgate/cli/application_diff.py`, `src/agents_shipgate/cli/application_scope.py` | — | — | Advisory SDK/ADK source-wiring comparison through `diff --application`. Without `--scope` the scope is derived from the change: the outermost package holding each changed Python file and the agent-building SDK/ADK files (discovery's own signals) that import it or that it imports within six hops, with what they import. Each independent application is its own comparison and never the root by default; agents only the root would join are named as `outside` and make the answer `partial`. A whole application directory moved is one relocation. A change no agent reaches is an explicit `not_established` answer, and a bound reached is named in `scope_selection` and makes the answer `partial` (#875, `tests/test_application_scope.py`). Reuses framework observations and the binding graph; its comparator answers no release or merge verdict, activation verdict, executable pin, or declared authority question. Unlike host mode it computes per-agent source differences, not a projection of host drift. Scoped gaps and `not_established` candidates bound the answer; no deployment-root reachability is asserted. An agent the other side's file still names through a construction no reader supports is a gap there, never a removal or addition, and SDK identity follows the import, so LiveKit's `Agent`/`function_tool` are not read as the SDK's. `tests/test_application_diff.py`, `tests/test_application_diff_review.py` and `tests/test_application_diff_identity.py` prove the advisory boundary, isolation, uncertainty and evidence identity. Every scope, the root included, is materialized by the scoped verified materializer, so a link is recreated rather than refused and never read through; every link under the scope, a dangling one included, is censused and gapped only where it can hide application source (a `*.py` link not aliasing an input the scope reads, a directory link holding Python outside the scope, or an unresolved link where the other side reads source); a submodule is never read, named as a limit when its gitlink commit is unchanged and a coverage gap over its path (the whole scope at the scope itself) otherwise; the host-configuration census adds no application gap (`tests/test_application_diff_reach.py`). A tool an agent binds from another module inside the selected scope is followed by the SDK/ADK readers to its definition, never imported or run, and each module read is published with its digest as `import_path` evidence outside the compared meaning; an import they cannot follow stays a named gap scoped to its agent (#864, `tests/test_imported_tool_bindings.py`). An unobserved agent is not no change (#876): every OpenAI Agents SDK construction in a file the scope reads is an observed agent or a named limit — `return Agent(...)`, `self.agent = Agent(...)`, an inline agent, `Agent[Context](...)` and a positional name are read under their literal `name`; `**` or extra positional arguments, a capability-passing copy, a change to an agent's tools after construction, one identity constructed twice with different tools, a construction without a literal name, and an agent built from an SDK `Agent` subclass or from a class deriving from a Google ADK agent class are limits — so `compared` holds no unaccounted construction site. Test code, by discovery's test-path convention relative to the scope, establishes nothing and is listed per side in the additive `excluded_tests` field, a list of the comparison's own unread input paths that restates no engine answer and adds no claim; a tool name one application file defines twice is a gap on that name, never a refusal (`tests/test_application_diff_unobserved.py`). | +| `application_diff` | `src/agents_shipgate/cli/application_diff.py`, `src/agents_shipgate/cli/application_scope.py`, `src/agents_shipgate/inputs/tool_reach.py` | — | — | Advisory SDK/ADK source-wiring comparison through `diff --application`. Without `--scope` the scope is derived from the change: the outermost package holding each changed Python file and the agent-building SDK/ADK files (discovery's own signals) that import it or that it imports within six hops, with what they import. Each independent application is its own comparison and never the root by default; agents only the root would join are named as `outside` and make the answer `partial`. A whole application directory moved is one relocation. A change no agent reaches is an explicit `not_established` answer, and a bound reached is named in `scope_selection` and makes the answer `partial` (#875, `tests/test_application_scope.py`). Reuses framework observations and the binding graph; its comparator answers no release or merge verdict, activation verdict, executable pin, or declared authority question. Unlike host mode it computes per-agent source differences, not a projection of host drift. Scoped gaps and `not_established` candidates bound the answer; no deployment-root reachability is asserted. An agent the other side's file still names through a construction no reader supports is a gap there, never a removal or addition, and SDK identity follows the import, so LiveKit's `Agent`/`function_tool` are not read as the SDK's. `tests/test_application_diff.py`, `tests/test_application_diff_review.py` and `tests/test_application_diff_identity.py` prove the advisory boundary, isolation, uncertainty and evidence identity. Every scope, the root included, is materialized by the scoped verified materializer, so a link is recreated rather than refused and never read through; every link under the scope, a dangling one included, is censused and gapped only where it can hide application source (a `*.py` link not aliasing an input the scope reads, a directory link holding Python outside the scope, or an unresolved link where the other side reads source); a submodule is never read, named as a limit when its gitlink commit is unchanged and a coverage gap over its path (the whole scope at the scope itself) otherwise; the host-configuration census adds no application gap (`tests/test_application_diff_reach.py`). A tool an agent binds from another module inside the selected scope is followed by the SDK/ADK readers to its definition, never imported or run, and each module read is published with its digest as `import_path` evidence outside the compared meaning; an import they cannot follow stays a named gap scoped to its agent (#864, `tests/test_imported_tool_bindings.py`). An unobserved agent is not no change (#876): every OpenAI Agents SDK construction in a file the scope reads is an observed agent or a named limit — `return Agent(...)`, `self.agent = Agent(...)`, an inline agent, `Agent[Context](...)` and a positional name are read under their literal `name`; `**` or extra positional arguments, a capability-passing copy, a change to an agent's tools after construction, one identity constructed twice with different tools, a construction without a literal name, and an agent built from an SDK `Agent` subclass or from a class deriving from a Google ADK agent class are limits — so `compared` holds no unaccounted construction site. Test code, by discovery's test-path convention relative to the scope, establishes nothing and is listed per side in the additive `excluded_tests` field, a list of the comparison's own unread input paths that restates no engine answer and adds no claim; a tool name one application file defines twice is a gap on that name, never a refusal (`tests/test_application_diff_unobserved.py`). Each side of a row also carries `reach` (#872). It holds the outbound HTTP calls read statically from the tool's own code and its same-scope helpers, up to three calls deep. For each call it records the method, the URL template and literal request fields, which model-supplied parameters flow where, and the environment variables sent as credentials, by name, never a value. Every call it cannot follow is a named limit. `effect_evidence` is the engine's own `assess_tool_semantics` over that tool, with the reach as one more structural source, `source_http_call`, so it is not a second classifier. `read` is claimed only when every call was followed and every outbound call reads. Both are evidence outside the compared meaning, so they move no row or status. For a Google ADK name constructed more than once, `binding_location` names a construction that lists the tool, and `construction_sites` lists every one (`tests/test_application_diff_tool_reach.py`). | | `zero_install_detector` | `tools/shipgate-detect.py` | `agent_project_verdict` | `test_detector_verdict_matches_cli` | Emits no `diagnostics[]` and no `next_actions[]`; evidence strings and framework scores are simplified. See the script's own "Intentional simplifications". | | `emitted_ci_workflow` | `src/agents_shipgate/cli/discovery/ci_workflow.py` | `executable_pin` | `tests/test_adopter_pins_resolve.py::test_the_emitted_workflow_pins_the_release_and_not_the_source_tree`, `tests/test_release_source.py::test_candidate_workflow_uses_immutable_source_before_and_after_publication` | Ordinary/source/preview builds use the published fallback; a stamped candidate pins its verified Action SHA and package version. Before publication its smoke substitutes the exact local wheel inputs. Provenance asserts no qualification. | | `prompts` | `prompts/` | `contract_floor`, `executable_pin`, `placeholder_ownership`, `release_decision_vocabulary` | `test_executable_pin_resolves_in_a_published_channel`, `test_surface_enumerations_match_the_engine_vocabulary`, `test_surface_routes_human_owned_placeholders_to_a_human`, `tests/test_adopter_pins_resolve.py::test_every_pin_init_writes_into_an_adopter_repo_names_the_published_release`, `tests/test_adopter_pins_resolve.py::test_the_shipped_floor_is_decided_against_the_release_the_prompts_pin` | — | diff --git a/docs/effect-evidence.md b/docs/effect-evidence.md index 0f2e8de09..e99eb4f58 100644 --- a/docs/effect-evidence.md +++ b/docs/effect-evidence.md @@ -44,6 +44,13 @@ No new report field or enum is introduced. For each - `semantic_assessment.pass_eligible` answers the combined semantic question; a structural effect can still have an unresolved binding or authority. +`diff --application` shows the same assessment for each tool it compares, as +`effect_evidence` (see [What a bound tool reaches](application-comparison.md#what-a-bound-tool-reaches)). +There, the outbound HTTP calls read from the tool's own code are one more +structural source, `source_http_call`. Such a call supports the effect of the +call it makes. It supports `read` only when every call the tool makes was +followed and every outbound call reads. + Policy severity cannot turn a heuristic claim into typed evidence. Renderers leave claims, risk tags, severity, pass eligibility and the release decision unchanged. Runtime behavior is never proven by this static presentation. diff --git a/src/agents_shipgate/cli/application_diff.py b/src/agents_shipgate/cli/application_diff.py index e2830c0aa..e4ab5d169 100644 --- a/src/agents_shipgate/cli/application_diff.py +++ b/src/agents_shipgate/cli/application_diff.py @@ -37,17 +37,23 @@ from agents_shipgate.core.domain import ANY_TOOL from agents_shipgate.core.errors import ConfigError, InputParseError from agents_shipgate.core.privacy import sanitize_report_payload +from agents_shipgate.core.semantic_assessment import REACH_CLAIM_SOURCE, assess_tool_semantics from agents_shipgate.core.verification_identity import build_engine_requirement from agents_shipgate.inputs.google_adk import adk_agent_subclasses, load_google_adk_artifacts from agents_shipgate.inputs.openai_sdk_static import ( census_module, load_openai_sdk_static_tools, ) -from agents_shipgate.inputs.python_imports import RepositoryLayout, repository_layout +from agents_shipgate.inputs.python_imports import ( + ImportResolver, + RepositoryLayout, + repository_layout, +) +from agents_shipgate.inputs.tool_reach import read_tool_reach from agents_shipgate.schemas.manifest import ToolSourceConfig SUPPORTED = frozenset({"openai_agents_sdk", "google_adk"}) -SCHEMA_VERSION = "0.1" +SCHEMA_VERSION = "0.2" MAX_PYTHON_BYTES = 2_000_000 @@ -232,6 +238,101 @@ def _definition(root: Path, tool: Any, agent: str | None = None) -> dict[str, An return result +def _reach(root: Path, tool: Any) -> dict[str, Any]: + """The outbound HTTP calls the tool's own code makes, read statically (#872). + + Evidence beside the signature, never meaning: what a tool reaches can say + what a binding lets the model do, but a change confined to a helper is not + a changed binding here. + """ + path = _source_path(root, tool.source_ref or "") + symbol = tool.annotations.get("python_symbol") + if not isinstance(symbol, str): + symbol = ( + (tool.native_locator or "").split("#")[-1] + if "#" in (tool.native_locator or "") + else (tool.function_signature or tool.name).split("(")[0] + ) + file = root / path + if ( + not path.endswith(".py") + or not file.is_file() + or not file.resolve().is_relative_to(root.resolve()) + ): + return {} + location = tool.source_location or "" + line = location.rsplit(":", 1)[-1] + try: + text = file.read_bytes().decode("utf-8", errors="replace") + tree = ast.parse(text) + nodes = [ + (node, enclosing) + for node, enclosing in _functions(tree) + if node.name == symbol and (not line.isdecimal() or node.lineno == int(line)) + ] + if not line.isdecimal(): + nodes = [(node, enclosing) for node, enclosing in nodes if node in tree.body] + if len(nodes) != 1: + return {} + resolver = ImportResolver(root) + module = resolver.entry(file, tree, text) + if module is None: + return {} + properties = (tool.input_schema or {}).get("properties") + return read_tool_reach( + resolver, + module, + nodes[0][0], + model_params=frozenset(properties) if isinstance(properties, dict) else frozenset(), + enclosing=nodes[0][1], + is_test=_is_test_path, + ) + except (SyntaxError, ValueError, RecursionError, OSError): + return {} + + +def _functions( + tree: ast.Module, +) -> list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, ast.FunctionDef | ast.AsyncFunctionDef | None]]: + """Every function, with the function that directly encloses it, if any.""" + found: list[ + tuple[ast.FunctionDef | ast.AsyncFunctionDef, ast.FunctionDef | ast.AsyncFunctionDef | None] + ] = [] + stack: list[tuple[ast.AST, ast.FunctionDef | ast.AsyncFunctionDef | None]] = [(tree, None)] + while stack: + node, enclosing = stack.pop() + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): + found.append((child, enclosing)) + stack.append((child, child)) + elif isinstance(child, ast.ClassDef): + stack.append((child, None)) + else: + stack.append((child, enclosing)) + return found + + +def _effect_evidence(tool: Any, reach: dict[str, Any]) -> dict[str, Any]: + """The existing semantic assessment of the tool, given what it reaches.""" + assessed = assess_tool_semantics( + tool.model_copy(update={"extraction": {**tool.extraction, "reach": reach}}) + ) + return { + "conservative_effect": assessed.conservative_effect, + "status": assessed.effect.status, + "claims": [ + { + "value": claim.value, + "source": claim.source, + "basis": claim.basis, + "at": claim.source_pointer, + } + for claim in assessed.effect.claims + if claim.dimension == "effect" + ], + } + + #: Bytes one repository-directory listing may take. _MAX_LAYOUT_LISTING_BYTES = 4 * 1024 * 1024 @@ -776,10 +877,14 @@ def _observe_source(result: Observations, root: Path, source: ToolSourceConfig) # reader already names it as incomplete; a tool both sites bind the same # way is one binding of it, not an ambiguous one (#876 review). sites: dict[tuple[str, str], int] = {} + # Where each tool is listed when one ADK name is constructed more than + # once: the row points at a construction that lists it (#872). + tool_sites: dict[tuple[str, str], dict[str, list[str]]] = {} for item in loaded: for observation in item.binding_observations: site = (_source_path(root, observation.source), observation.agent) sites[site] = sites.get(site, 0) + 1 + tool_sites.setdefault(site, {}).update(observation.tool_sites) tools, warnings = _canonical_tools(result, source, loaded) for warning in warnings: result.gap(warning, source=source.path) @@ -852,6 +957,16 @@ def _observe_source(result: Observations, root: Path, source: ToolSourceConfig) "evidence_basis": edge.provenance_kind, **_import_path(tool, key[0]), } + listed = tool_sites.get(key, {}).get(tool.name) + if listed: + binding["binding_location"] = listed[0] + if len(listed) > 1: + binding["construction_sites"] = listed + if binding_key not in result.bindings: + reach = _reach(root, tool) + if reach: + binding["reach"] = reach + binding["effect_evidence"] = _effect_evidence(tool, reach) if binding_key in result.bindings: if sites.get(key, 0) > 1 and _meaning(result.bindings[binding_key]) == _meaning( binding @@ -990,7 +1105,16 @@ def _meaning(binding: dict[str, Any]) -> dict[str, Any]: k: v for k, v in binding.items() if k - not in {"binding_location", "definition", "evidence_basis", "agent_source", "import_path"} + not in { + "binding_location", + "definition", + "evidence_basis", + "agent_source", + "import_path", + "construction_sites", + "reach", + "effect_evidence", + } } | {"implementation_sha256": binding.get("definition", {}).get("implementation_sha256")} @@ -1223,6 +1347,10 @@ def _published_binding(binding: dict[str, Any] | None, scope: str) -> dict[str, result = dict(binding) result["agent_source"] = _location(scope, result["agent_source"]) result["binding_location"] = _location(scope, result.get("binding_location")) + if "construction_sites" in result: + result["construction_sites"] = [ + _location(scope, site) for site in result["construction_sites"] + ] if "target_source" in result: result["target_source"] = _location(scope, result["target_source"]) if "definition" in result: @@ -1249,9 +1377,131 @@ def _published_binding(binding: dict[str, Any] | None, scope: str) -> dict[str, } for item in result["import_path"] ] + if "reach" in result: + reach = result["reach"] + result["reach"] = { + **reach, + "calls": [ + { + **call, + "at": _location(scope, call["at"]), + "via": [_location(scope, hop) for hop in call["via"]], + } + for call in reach["calls"] + ], + "limits": [{**item, "at": _location(scope, item["at"])} for item in reach["limits"]], + "effect_claims": [ + {**item, "at": _location(scope, item["at"])} for item in reach["effect_claims"] + ], + } + if "effect_evidence" in result: + result["effect_evidence"] = { + **result["effect_evidence"], + "claims": [ + {**claim, "at": _location(scope, claim["at"])} + if claim["source"] == REACH_CLAIM_SOURCE + else claim + for claim in result["effect_evidence"]["claims"] + ], + } return result +def _reach_lines(binding: dict[str, Any]) -> list[str]: + """A side's outbound calls and effect evidence, one fact per line (#872).""" + from agents_shipgate.report.human_order import EFFECT_EVIDENCE_LABELS + + reach = binding.get("reach") or {} + calls = reach.get("calls") or [] + limits = reach.get("limits") or [] + lines: list[str] = [] + groups: dict[tuple[str, str, str | None], list[dict[str, Any]]] = {} + for call in calls: + key = (call["method"] or "an unread method", call["url"], call.get("graphql")) + groups.setdefault(key, []).append(call) + for (method, url, graphql), group in groups.items(): + first = group[0] + operation = f" (GraphQL {graphql})" if graphql else "" + via = f" via {' → '.join(first['via'])}" if first["via"] else "" + more = ( + f", and {len(group) - 1} more call site{'s' if len(group) > 2 else ''}" + if len(group) > 1 + else "" + ) + lines.append(f"reaches: {method} {url}{operation} at {first['at']}{via}{more}") + facts: list[str] = [] + for call in group: + for item in call.get("fields", []): + name = item.get("field") + if name is None: + continue + if "values" in item: + fact = f" field {name} ∈ {{{', '.join(item['values'])}}}" + ( + f", decided by {', '.join(item['decided_by'])}" + if item.get("decided_by") + else "" + ) + elif "value" in item: + fact = f" field {name} = {json.dumps(item['value'])}" + else: + continue + if fact not in facts: + facts.append(fact) + supplied: list[str] = [] + for call in group: + for item in call.get("model_supplied", []): + fact = f"{item['param']} → {item['into']}" + if fact not in supplied: + supplied.append(fact) + if supplied: + facts.append(" model-supplied: " + "; ".join(supplied)) + credentials: dict[str, int] = {} + for call in group: + for item in call.get("credential_sources", []): + target = next( + f"{kind} {item[kind]}" if item[kind] else kind + for kind in ("header", "auth", "query", "field", "userinfo") + if kind in item + ) + sources = [f"env {name}" for name in item.get("env", [])] + if item.get("from"): + sources.append( + "a value made from model-supplied " + ", ".join(item["from"]) + ) + if item.get("literal"): + sources.append("a literal (not printed)") + source = ", ".join(sources) or "a computed value" + fact = f" credential: {source} → {target}" + credentials[fact] = credentials.get(fact, 0) + 1 + for fact, count in credentials.items(): + # A credential only some call sites send (a 401 retry without it) + # is not said of all of them. + facts.append(fact if count >= len(group) else f"{fact} (at some call sites)") + lines.extend(facts) + evidence = binding.get("effect_evidence") + if evidence and (calls or limits): + status = evidence["status"] + label = EFFECT_EVIDENCE_LABELS.get(status, status) + basis = "" + claims = [c for c in evidence["claims"] if c["source"] == REACH_CLAIM_SOURCE] + if status == "structural" and claims: + claim = next( + (c for c in claims if c["value"] == evidence["conservative_effect"]), claims[0] + ) + basis = ( + ": every call was followed, and every outbound call reads" + if claim["value"] == "read" + else f": outbound call at {claim['at']}" + ) + lines.append(f"effect: {evidence['conservative_effect']} ({label}{basis})") + for item in limits[:3]: + lines.append(f"reach limit: {item['at']} {item['why']}") + hidden = len(limits) - 3 + reach.get("more_limits", 0) + if hidden > 0: + lines.append(f"reach limit: and {hidden} more") + return lines + + def run_application_diff( *, workspace: Path, @@ -1356,7 +1606,7 @@ def run_application_diff( _LIMITS = [ "Covers supported OpenAI Agents SDK and Google ADK source wiring only.", - "Deployment-root reachability, runtime behavior, indirect helper effects and business authority are not established.", + "Deployment-root reachability, runtime behavior, effects other than the outbound HTTP calls a row names, and business authority are not established.", "This comparison is advisory evidence and supplies no release verdict or merge permission.", ] @@ -1598,8 +1848,14 @@ def _print_rows(payload: dict[str, Any], _one_line: Any) -> None: ) else: definition = value.get("definition", {}) + also = [ + site + for site in value.get("construction_sites", []) + if site != value.get("binding_location") + ] typer.echo( f" {side}: {_one_line(value.get('signature') or value['tool'])} at {_one_line(value.get('binding_location'))}" + + (f" (also listed at {_one_line(', '.join(also))})" if also else "") ) if definition: typer.echo( @@ -1612,6 +1868,8 @@ def _print_rows(payload: dict[str, Any], _one_line: Any) -> None: if step.get("line") is not None ) typer.echo(f" imported: {_one_line(hops)}") + for line in _reach_lines(value): + typer.echo(f" {_one_line(line)}") typer.echo(f" {_one_line(row['why'])}") for side, reasons in row["uncertainty"].items(): for reason in reasons: diff --git a/src/agents_shipgate/core/domain.py b/src/agents_shipgate/core/domain.py index 6a77f3046..12465af06 100644 --- a/src/agents_shipgate/core/domain.py +++ b/src/agents_shipgate/core/domain.py @@ -703,6 +703,10 @@ class AgentBindingObservation(BaseModel): #: definition for it but could not establish that it is the one bound #: (#879 review). The binding is reported, never as established. tool_issues: dict[str, str] = Field(default_factory=dict) + #: ``tool_name -> ["file:line", ...]``: the constructions of this agent + #: that list the tool, when one name is constructed more than once in the + #: source (#872). Where each binding is, not which one runs. + tool_sites: dict[str, list[str]] = Field(default_factory=dict) handoff_names: list[str] = Field(default_factory=list) tools_complete: bool = True handoffs_complete: bool = True diff --git a/src/agents_shipgate/core/semantic_assessment.py b/src/agents_shipgate/core/semantic_assessment.py index 899b166a8..2560f5991 100644 --- a/src/agents_shipgate/core/semantic_assessment.py +++ b/src/agents_shipgate/core/semantic_assessment.py @@ -52,6 +52,8 @@ from agents_shipgate.schemas.surfaces import ActionEffect _EFFECT_VALUES = frozenset(_EFFECT_RANK) +#: The effect claim source for a tool's own outbound HTTP calls (#872). +REACH_CLAIM_SOURCE = "source_http_call" MCP_SOURCE_TYPES = frozenset( { "mcp", @@ -337,6 +339,33 @@ def _assess_effect( ) ) + # The outbound HTTP calls a static read of the tool's own code followed + # (#872): the method of a call that was made, or — only when every call + # was followed and every outbound call reads — ``read``. The reader + # decides which of the two it can support; this is the one place either + # becomes effect evidence. + reach = tool.extraction.get("reach") + if isinstance(reach, dict): + for item in reach.get("effect_claims") or []: + if not isinstance(item, dict) or item.get("effect") not in _EFFECT_VALUES: + continue + claims.append( + _claim( + "effect", + item["effect"], + "high", + "static_declaration", + "protocol_structure", + REACH_CLAIM_SOURCE, + item.get("at") or pointer, + { + key: item[key] + for key in ("method", "url", "graphql", "calls") + if key in item + }, + ) + ) + for name in _BOOLEAN_ANNOTATIONS: if name in tool.annotations and type(tool.annotations[name]) is not bool: issues.append( @@ -490,6 +519,7 @@ def _assess_effect( "permission_class", "auth_scope", "action_scope", + REACH_CLAIM_SOURCE, } or (claim.policy_eligible and claim.basis == "typed_provider_fact") ] diff --git a/src/agents_shipgate/inputs/google_adk.py b/src/agents_shipgate/inputs/google_adk.py index 6781685ef..48e58e923 100644 --- a/src/agents_shipgate/inputs/google_adk.py +++ b/src/agents_shipgate/inputs/google_adk.py @@ -196,7 +196,8 @@ #: callables, so which one runs is not something the source settles (#864). SURFACE_GAP_DUPLICATE_TOOL_NAME = "duplicate_tool_name" #: One agent name constructed at more than one call site in a module: the -#: binding graph merges them, so no tool can be attributed to either (#876). +#: binding graph merges them, so which one runs is not established (#876). Each +#: row names the constructions that list its tool (#872). SURFACE_GAP_DUPLICATE_AGENT_NAME = "duplicate_agent_name" #: A class deriving from an ADK agent class: instances are built by calling the #: subclass, which this reader does not follow, so their tools are unread (#876). @@ -906,6 +907,8 @@ class _AdkAgentBinding: #: bound before or not, while ``extract`` reads that construction (#876 #: review); None between constructions. recording: list[tuple[str, str | None]] | None = field(default=None, repr=False) + #: ``tool_name -> lines`` of the constructions that list it (#872). + tool_sites: dict[str, list[int]] = field(default_factory=dict) def bind( self, tool_name: str, locator: str | None = None, location: str | None = None @@ -1179,6 +1182,10 @@ def _read_construction( loaded.extend(self._extract_tool_expr(item, tools, agent_name, binding)) finally: recorded, binding.recording = binding.recording, None + for tool_name, _ in recorded or (): + lines = binding.tool_sites.setdefault(tool_name, []) + if call.lineno not in lines: + lines.append(call.lineno) clean = ( not loaded and all(locator is not None for _, locator in recorded or ()) @@ -1219,8 +1226,9 @@ def _record_duplicate_constructions(self) -> None: lines = ", ".join(str(line) for line in sorted(line for line, _ in sites.values())) reason = ( f"Google ADK agent {agent_name!r} is constructed more than once in " - f"{self.source_ref} (lines {lines}); its tools are not attributed to " - "either construction." + f"{self.source_ref} (lines {lines}); which one runs is not established, " + "and their tools are compared as one agent, each row naming the " + "constructions that list its tool." ) self._surface_warning(reason, SURFACE_GAP_DUPLICATE_AGENT_NAME) self.agent_bindings[agent_name].issues.append(reason) @@ -1687,6 +1695,12 @@ def _binding_observations(self) -> list[AgentBindingObservation]: tool_names=list(binding.tool_names), tool_locators=dict(binding.tool_locators), tool_issues=dict(binding.tool_issues), + tool_sites={ + name: [f"{self.source_ref}:{line}" for line in sorted(lines)] + for name, lines in binding.tool_sites.items() + } + if len(self.agent_sites.get(binding.agent, {})) > 1 + else {}, tools_complete=not binding.issues, issues=list(binding.issues), ) diff --git a/src/agents_shipgate/inputs/tool_reach.py b/src/agents_shipgate/inputs/tool_reach.py new file mode 100644 index 000000000..5959acfc0 --- /dev/null +++ b/src/agents_shipgate/inputs/tool_reach.py @@ -0,0 +1,5624 @@ +"""What a bound tool reaches: its outbound HTTP calls, read statically (#872). + +A comparison row that names only a signature tells the reviewer nothing they +would not see on the first screen of the file. This reader follows one tool +function, and the repository helpers it calls up to :data:`MAX_DEPTH` hops, +and records each outbound HTTP call made through ``requests``, ``httpx``, +``aiohttp`` or ``urllib.request``: + +* the method, and the URL as a template whose parts are labelled by source: a + literal, a tool parameter (``{pr_number}``) or an environment variable + (``{env OWNER}``); +* request fields: a literal value, the literals a field is chosen from and + what decides between them, or the parameters it is made from; +* the environment variables a request sends as credentials, by name only; +* for GraphQL, whether the document is a query or a mutation. Transport is not + effect: a GraphQL query sent over POST reads. + +Nothing is imported or run. A value the read cannot name is labelled as such, +and a call it cannot follow is a named limit with its location, never a guess. +A tool is said to read only when every call it makes was followed and every +outbound call reads. +""" + +from __future__ import annotations + +import ast +import functools +import os +import re +import string +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any + +from agents_shipgate.core.privacy import ( + is_credential_key, + looks_like_secret_value, + redact_text, + redact_url_credentials, +) +from agents_shipgate.inputs.python_imports import ( + MODULE_NOT_FOUND, + ImportResolver, + PythonModule, + Resolution, + reference_spelling, +) + +#: Helper hops followed from the tool function. +MAX_DEPTH = 3 +#: Function bodies read for one tool. +MAX_FRAMES = 64 +#: Outbound calls recorded for one tool. +MAX_CALLS = 24 +#: Limits recorded for one tool; the rest are counted. +MAX_LIMITS = 12 +#: Hops through module constants when evaluating one value. +MAX_CONSTANT_DEPTH = 8 +#: Longest literal request-field value printed. +MAX_LITERAL = 64 +#: Parts one string template keeps. +MAX_PARTS = 64 +#: Recursive calls followed with their own arguments before the body is read +#: once with every parameter unnamed. +MAX_RECURSION = 2 + +_METHOD_EFFECT = { + "GET": "read", + "HEAD": "read", + "OPTIONS": "read", + "POST": "write", + "PUT": "write", + "PATCH": "write", + "DELETE": "destructive", +} +_VERBS = {verb.lower(): verb for verb in _METHOD_EFFECT} +_HTTP_LIBRARIES = ("requests", "httpx") +#: Positional parameters after ``url`` for each verb function. +_VERB_POSITIONALS = { + "get": ("params",), + "post": ("data", "json"), + "put": ("data",), + "patch": ("data",), +} +_CLIENTS = { + "requests.Session": "requests", + "requests.session": "requests", + "requests.sessions.Session": "requests", + "httpx.Client": "httpx", + "httpx.AsyncClient": "httpx", + "aiohttp.ClientSession": "aiohttp", +} +_URLOPEN = frozenset({"urllib.request.urlopen"}) +_REQUEST_CLASSES = frozenset({"urllib.request.Request"}) +_BASIC_AUTH = frozenset( + {"requests.auth.HTTPBasicAuth", "httpx.BasicAuth", "aiohttp.BasicAuth"} +) +_ENV_READERS = frozenset({"os.getenv", "os.environ.get", "os.environ.setdefault"}) + +#: Calls with no effect outside the process. A call is followed, recorded or +#: named as a limit; these are the only ones passed over. +_PURE_BUILTINS = frozenset( + { + "abs", "all", "any", "bool", "callable", "dict", "divmod", "enumerate", + "filter", "float", "format", "frozenset", "getattr", "hasattr", "hash", + "id", "int", "isinstance", "issubclass", "iter", "len", "list", "map", + "max", "min", "next", "print", "range", "repr", "reversed", "round", + "set", "sorted", "str", "sum", "tuple", "type", "zip", + } +) +_PURE_FUNCTIONS = frozenset( + { + "asyncio.sleep", "base64.b64decode", "base64.b64encode", + "base64.urlsafe_b64decode", "base64.urlsafe_b64encode", + "datetime.datetime.now", "datetime.datetime.utcnow", "datetime.date.today", + "datetime.datetime.fromisoformat", "datetime.timedelta", + "json.dumps", "json.loads", "logging.getLogger", "math.ceil", "math.floor", + *( + f"{module}.{name}" + for module in ("os.path", "posixpath", "ntpath") + for name in ( + "abspath", "basename", "dirname", "exists", "expanduser", "getsize", + "isdir", "isfile", "join", "normpath", "realpath", "relpath", "splitext", + ) + ), + "pathlib.Path", "pathlib.PurePath", "pathlib.PurePosixPath", + "re.compile", "re.escape", "re.findall", "re.finditer", "re.fullmatch", + "re.match", "re.search", "re.split", "re.sub", "re.subn", + "textwrap.dedent", "time.monotonic", "time.sleep", "time.time", + "operator.attrgetter", "operator.itemgetter", "typing.cast", + "urllib.parse.quote", "urllib.parse.quote_plus", "warnings.warn", + "urllib.parse.unquote", "urllib.parse.urlencode", "urllib.parse.urljoin", + "urllib.parse.urlparse", "urllib.parse.urlsplit", "uuid.uuid4", + *_ENV_READERS, + } +) +#: Methods passed over on any receiver. None of them writes on the types that +#: define them (``dict.get``, ``str.strip``, ``Response.json``, +#: ``Match.group``); on another object the same name at worst reads. +_PURE_METHODS = frozenset( + { + "capitalize", "count", "decode", "encode", "endswith", "find", "findall", + "format", "fullmatch", "get", "group", "groupdict", "groups", "isalnum", + "isalpha", "isdigit", "isoformat", "items", "join", "json", "keys", "lower", + "lstrip", "match", "raise_for_status", "rfind", "rsplit", "rstrip", "search", + "split", "splitlines", "startswith", "strftime", "strip", "sub", "title", + "total_seconds", "upper", "values", + # Dates and paths: at worst they read. + "absolute", "as_posix", "astimezone", "date", "exists", "expanduser", + "is_dir", "is_file", "isoweekday", "joinpath", "read_bytes", "read_text", + "relative_to", "resolve", "timestamp", "weekday", "with_name", "with_suffix", + # A response's own readers. + "geturl", "getcode", "info", "read", "readlines", "text", + } +) +#: Pure methods whose result is another object, not plain data. +_OBJECT_METHODS = frozenset( + { + "absolute", "astimezone", "date", "expanduser", "finditer", "fullmatch", + "joinpath", "match", "relative_to", "resolve", "search", "with_name", + "with_suffix", + } +) +#: Attributes of a library object that are plain data. +_DATA_ATTRIBUTES = frozenset( + {"content", "headers", "name", "ok", "reason", "status", "status_code", "stem", "suffix", "text", "url"} +) +#: Pure library calls whose result is an object rather than plain data. +_OBJECT_FUNCTIONS = frozenset( + { + "datetime.date.today", "datetime.datetime.fromisoformat", "datetime.datetime.now", + "datetime.datetime.utcnow", "datetime.timedelta", "logging.getLogger", + "pathlib.Path", "pathlib.PurePath", "pathlib.PurePosixPath", "re.compile", + "re.finditer", "re.fullmatch", "re.match", "re.search", "uuid.uuid4", + } +) +#: Decorators that leave a function doing what its body says. +_INERT_DECORATORS = frozenset( + { + "abc.abstractmethod", "agents.function_tool", "functools.cache", + "functools.lru_cache", "functools.wraps", "typing.overload", + } +) +_REDACTED = "[REDACTED:sensitive_field]" +#: A bot API's method name (`sendMessage`, `getUpdates`): lower camelCase. +_METHOD_NAME = re.compile(r"[a-z]{2,12}[A-Z][A-Za-z]{1,20}") +#: A query value short and plain enough to print: a lowercase word or a number. +_PLAIN_QUERY_VALUE = re.compile( + r"(?:[A-Za-z][A-Za-z_-]{0,15}|\d{1,10}|\d{4}-\d{2}-\d{2}(?:-[a-z]{1,12})?" + r"|[a-z_]{1,16}(?:,[a-z_]{1,16}){1,7})" +) +#: A request-field value plain enough to print: a word without digits. +_PLAIN_FIELD_VALUE = re.compile(r"[A-Za-z][A-Za-z_.-]{0,31}") +#: Words of a field, header or query name that mark it as carrying a secret. +#: Whole words, never substrings: `max_tokens`, `author` and `assignees` are not +#: credentials (#872 review 2). +_SECRET_WORDS = frozenset( + { + "accesskey", "apikey", "auth", "authorization", "bearer", "contrasena", "cookie", + "credential", "credentials", "haslo", "jwt", "kennwort", "motdepasse", "otp", + "parola", "pass", "passcode", "passphrase", "passwd", "password", "passwort", "pin", + "privatekey", "pw", "pwd", "secret", "secretkey", "senha", "session", "sessionid", + "sid", "sig", "signature", "token", "wachtwoord", + } +) +#: Words that make a following `key` a secret: `X-Api-Key`, `subscription_key`. +#: `Idempotency-Key` and `sort_key` are not. +_KEY_QUALIFIERS = frozenset( + { + "access", "account", "api", "app", "application", "auth", "client", "consumer", "developer", + "encryption", "function", "functions", "license", "master", "private", "secret", + "service", "signing", "subscription", "x", + } +) +#: Callees that run a function passed to them, and where it is passed. +_CALLABLE_POSITIONS = { + "filter": (0,), + "functools.partial": (0,), + "functools.reduce": (0,), + "itertools.dropwhile": (0,), + "itertools.filterfalse": (0,), + "itertools.starmap": (0,), + "itertools.takewhile": (0,), + "map": (0,), + "re.sub": (1,), + "re.subn": (1,), + "sub": (0,), + "subn": (0,), +} +_CALLABLE_KEYWORDS = frozenset( + { + "auth", "callback", "default", "event_hooks", "func", "function", "hook", "hooks", + "key", "mounts", "object_hook", "object_pairs_hook", "repl", "target", "trace_configs", + "transport", + } +) +#: Marks an outbound call or a client or request built here: its keywords that +#: take a function (`hooks=`, `auth=`) are read as strictly as a passed-over +#: call's (#872 review 3). +_SENDS = "" +#: A client's settings that only tune transport. Any other attribute set on a +#: client (`s.request = fn`) replaces what it does. +_CLIENT_SETTINGS = frozenset( + {"cert", "cookies", "headers", "max_redirects", "params", "proxies", "stream", "timeout", "trust_env", "verify"} +) +#: Transport constructors that only retry or pool, given plain values. +_TRANSPORTS = frozenset( + { + "httpx.AsyncHTTPTransport", "httpx.HTTPTransport", "requests.adapters.HTTPAdapter", + "urllib3.Retry", "urllib3.util.Retry", "urllib3.util.retry.Retry", + } +) +#: Pure library calls whose result is text or JSON whatever they are given. +_TEXT_FUNCTION_PREFIXES = ("base64.", "ntpath.", "os.path.", "posixpath.", "urllib.parse.") +_TEXT_FUNCTIONS = frozenset( + {"json.dumps", "json.loads", "math.ceil", "math.floor", "re.escape", "textwrap.dedent", "time.monotonic", "time.time"} +) +#: Hosts whose URL path is itself the credential: a webhook or bot URL. +_CAPABILITY_HOSTS = ( + "api.telegram.org", "discord.com", "discordapp.com", "hooks.slack.com", + "hooks.zapier.com", "outlook.office.com", +) +_CAPABILITY_HOST_SUFFIXES = (".m.pipedream.net", ".webhook.office.com") +#: Fixed path words kept on a capability URL. +_CAPABILITY_WORDS = frozenset( + {"api", "catch", "hooks", "IncomingWebhook", "services", "webhook", "webhookb2", "webhooks"} +) +#: Methods passed over only on a value read as a string. +_STRING_METHODS = frozenset({"replace", "zfill", "ljust", "rjust", "center"}) +#: Methods that change a container, passed over only on a list, dict or set +#: the function itself built: on another object ``append`` or ``update`` may +#: store something (#872 review). +_CONTAINER_METHODS = frozenset( + {"add", "append", "clear", "copy", "extend", "insert", "pop", "remove", "setdefault", "sort", "update"} +) +_CONTAINER_BUILDERS = frozenset({"dict", "list", "set", "sorted", "tuple"}) +#: The builtins module's names (Python 3.12 and later), written out: the +#: scanner imports no `builtins` (tests/test_adapter_static_only.py). +_BUILTIN_NAMES = frozenset( + { + "ArithmeticError", "AssertionError", "AttributeError", "BaseException", "BaseExceptionGroup", + "BlockingIOError", "BrokenPipeError", "BufferError", "BytesWarning", "ChildProcessError", + "ConnectionAbortedError", "ConnectionError", "ConnectionRefusedError", "ConnectionResetError", + "DeprecationWarning", "EOFError", "Ellipsis", "EncodingWarning", "EnvironmentError", "Exception", + "ExceptionGroup", "False", "FileExistsError", "FileNotFoundError", "FloatingPointError", + "FutureWarning", "GeneratorExit", "IOError", "ImportError", "ImportWarning", "IndentationError", + "IndexError", "InterruptedError", "IsADirectoryError", "KeyError", "KeyboardInterrupt", + "LookupError", "MemoryError", "ModuleNotFoundError", "NameError", "None", "NotADirectoryError", + "NotImplemented", "NotImplementedError", "OSError", "OverflowError", "PendingDeprecationWarning", + "PermissionError", "ProcessLookupError", "PythonFinalizationError", "RecursionError", + "ReferenceError", "ResourceWarning", "RuntimeError", "RuntimeWarning", "StopAsyncIteration", + "StopIteration", "SyntaxError", "SyntaxWarning", "SystemError", "SystemExit", "TabError", + "TimeoutError", "True", "TypeError", "UnboundLocalError", "UnicodeDecodeError", + "UnicodeEncodeError", "UnicodeError", "UnicodeTranslateError", "UnicodeWarning", "UserWarning", + "ValueError", "Warning", "ZeroDivisionError", "_IncompleteInputError", "__build_class__", + "__debug__", "__doc__", "__import__", "__loader__", "__name__", "__package__", "__spec__", "abs", + "aiter", "all", "anext", "any", "ascii", "bin", "bool", "breakpoint", "bytearray", "bytes", + "callable", "chr", "classmethod", "compile", "complex", "copyright", "credits", "delattr", "dict", + "dir", "divmod", "enumerate", "eval", "exec", "exit", "filter", "float", "format", "frozenset", + "getattr", "globals", "hasattr", "hash", "help", "hex", "id", "input", "int", "isinstance", + "issubclass", "iter", "len", "license", "list", "locals", "map", "max", "memoryview", "min", "next", + "object", "oct", "open", "ord", "pow", "print", "property", "quit", "range", "repr", "reversed", + "round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super", "tuple", + "type", "vars", "zip" + } +) + + +# -- values -------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Lit: + """A literal: str, int, float, bool or None.""" + + value: Any + + +@dataclass(frozen=True) +class Tpl: + """A string built from parts, each a :class:`Lit` or an unnamed value.""" + + parts: tuple[Any, ...] + + +@dataclass(frozen=True) +class Rec: + """A dict with literal keys. ``open``: other keys may be added. + + ``shared``: a module-level dict. Any code in the process may change it, by + routes a static read cannot bound (a helper two calls away, `*args`, an + accessor, a loop), so a value read out of it is never taken as written + (#872 review 9). + """ + + fields: tuple[tuple[str, Any], ...] + open: bool = False + shared: bool = False + + def get(self, key: str) -> Any | None: + for name, value in self.fields: + if name == key: + return value + return None + + +@dataclass(frozen=True) +class Seq: + """A list or tuple display.""" + + items: tuple[Any, ...] + + +@dataclass(frozen=True) +class Alt: + """One of several values; ``deciders`` are the parameters that choose.""" + + options: tuple[Any, ...] + deciders: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class Op: + """A value the read does not name exactly, and the inputs it is made from. + + ``exact``: the value is exactly its one parameter or environment variable. + ``literal``: it is made from literals alone. ``data``: it is plain data — + an environment value, a JSON value the model supplies, or made only from + such values — whose methods cannot reach outside the process. ``what`` + names a value the tool cannot see into, such as a parameter of an + enclosing factory. + """ + + params: frozenset[str] = frozenset() + envs: frozenset[str] = frozenset() + exact: bool = False + literal: bool = False + what: str | None = None + data: bool = False + #: An object a known library returned (a response, a regex match, a + #: date): its read-only methods reach nothing outside the process. + inert: bool = False + + +@dataclass(frozen=True) +class Lib: + """An object a library outside the repository provides, by dotted name.""" + + dotted: str + + +@dataclass(frozen=True) +class Client: + """An HTTP client or session, with its base URL and default headers.""" + + library: str + base_url: Any = None + headers: Any = None + auth: Any = None + params: Any = None + + +@dataclass(frozen=True) +class Request: + """``urllib.request.Request(url, data=..., headers=..., method=...)``.""" + + url: Any + data: Any + headers: Any + method: Any + + +class Func: + """A repository function, and the frame of the function that encloses it.""" + + __slots__ = ("module", "node", "enclosing") + + def __init__( + self, + module: PythonModule, + node: ast.FunctionDef | ast.AsyncFunctionDef, + enclosing: _Frame | None = None, + ) -> None: + self.module = module + self.node = node + self.enclosing = enclosing + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, Func) + and other.node is self.node + and other.enclosing is self.enclosing + ) + + def __hash__(self) -> int: + return hash((id(self.node), id(self.enclosing))) + + +_UNKNOWN = Op() + + +def _sources(value: Any) -> tuple[frozenset[str], frozenset[str]]: + """The parameters and environment variables a value is made from.""" + + params: set[str] = set() + envs: set[str] = set() + stack = [value] + while stack: + item = stack.pop() + if isinstance(item, Op): + params.update(item.params) + envs.update(item.envs) + elif isinstance(item, Tpl): + stack.extend(item.parts) + elif isinstance(item, Rec): + stack.extend(value for _, value in item.fields) + elif isinstance(item, Seq): + stack.extend(item.items) + elif isinstance(item, Alt): + params.update(item.deciders) + stack.extend(item.options) + elif isinstance(item, Request): + stack.extend((item.url, item.data, item.headers, item.method)) + elif isinstance(item, Client): + stack.extend((item.base_url, item.headers)) + return frozenset(params), frozenset(envs) + + +def _literal_only(value: Any) -> bool: + if isinstance(value, Lit): + return True + if isinstance(value, Op): + return value.literal + if isinstance(value, Tpl): + return all(_literal_only(part) for part in value.parts) + if isinstance(value, Rec): + return not value.open and all(_literal_only(item) for _, item in value.fields) + if isinstance(value, Seq): + return all(_literal_only(item) for item in value.items) + if isinstance(value, Alt): + return not value.deciders and all(_literal_only(item) for item in value.options) + return False + + +def _derived( + *values: Any, + what: str | None = None, + result: bool = False, + data: bool | None = None, + inert: bool | None = None, + literal: bool | None = None, +) -> Op: + """An unnamed value made from ``values``. + + ``result``: what a call returned. It is never a literal, however literal + its arguments (a secret a client fetched is not written in the source), + and it is plain data or an inert object only when said so. + """ + + params: frozenset[str] = frozenset() + envs: frozenset[str] = frozenset() + for value in values: + more_params, more_envs = _sources(value) + params |= more_params + envs |= more_envs + if result: + data = bool(data) + inert = bool(inert) + if literal is None: + literal = not result and bool(values) and all(_literal_only(value) for value in values) + return Op( + params=params, + envs=envs, + literal=literal, + what=what, + data=(bool(values) and all(_is_data(value) for value in values)) if data is None else data, + inert=(bool(values) and all(_is_quiet(value) for value in values)) if inert is None else inert, + ) + + +def _is_quiet(value: Any) -> bool: + """Plain data, or an object whose read-only methods reach nothing outside.""" + + if isinstance(value, Op) and value.inert: + return True + if isinstance(value, Alt): + return all(_is_quiet(option) for option in value.options) + return _is_data(value) + + +def _is_data(value: Any) -> bool: + """Plain data: strings, numbers, and lists or dicts of them.""" + + if isinstance(value, Lit | Tpl): + return True + if isinstance(value, Op): + return value.data or value.literal + if isinstance(value, Alt): + return all(_is_data(option) for option in value.options) + if isinstance(value, Seq): + return all(_is_data(item) for item in value.items) + if isinstance(value, Rec): + return not value.open and all(_is_data(item) for _, item in value.fields) + return False + + +#: Annotation names a model-supplied argument can have and still be the plain +#: JSON value the framework decoded. +_JSON_ANNOTATIONS = frozenset( + { + "Any", "Dict", "List", "Literal", "Mapping", "Optional", "Sequence", "Tuple", + "Union", "bool", "dict", "float", "int", "list", "str", "tuple", + } +) + + +def _json_annotation(node: ast.AST | None, *, literal: bool = False) -> bool: + if node is None: + return True + if isinstance(node, ast.Constant): + # A string outside `Literal[...]` is a forward reference to a class. + return node.value is None or (literal and isinstance(node.value, str | int | bool)) + if isinstance(node, ast.Name): + return node.id in _JSON_ANNOTATIONS + if isinstance(node, ast.Attribute): + return node.attr in _JSON_ANNOTATIONS + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _json_annotation(node.left, literal=literal) and _json_annotation(node.right, literal=literal) + if isinstance(node, ast.Subscript): + inside = literal or ( + isinstance(node.value, ast.Name | ast.Attribute) + and (node.value.id if isinstance(node.value, ast.Name) else node.value.attr) == "Literal" + ) + return _json_annotation(node.value) and _json_annotation(node.slice, literal=inside) + if isinstance(node, ast.Tuple): + return all(_json_annotation(item, literal=literal) for item in node.elts) + return False + + +def _text(value: Any) -> Any: + """``value`` as a string part: a template stays one, anything else is a part.""" + + if isinstance(value, Lit) and not isinstance(value.value, str): + return Lit(str(value.value)) if value.value is not None else _derived(value) + return value + + +def _join(parts: list[Any]) -> Any: + flat: list[Any] = [] + for part in parts: + part = _text(part) + flat.extend(part.parts if isinstance(part, Tpl) else [part]) + merged: list[Any] = [] + for part in flat: + if merged and isinstance(part, Lit) and isinstance(merged[-1], Lit): + merged[-1] = Lit(merged[-1].value + part.value) + else: + merged.append(part) + if len(merged) == 1 and isinstance(merged[0], Lit): + return merged[0] + if not merged: + return Lit("") + if len(merged) > MAX_PARTS: + # A string built by doubling grows exponentially; past the bound it + # is only what it is made from. + return _derived(*merged) + return Tpl(tuple(merged)) + + +def _is_text(value: Any) -> bool: + return (isinstance(value, Lit) and isinstance(value.value, str)) or isinstance(value, Tpl) + + +def _alt(options: list[Any], deciders: frozenset[str] = frozenset()) -> Any: + unique: list[Any] = [] + for option in options: + if isinstance(option, Alt): + deciders |= option.deciders + items: tuple[Any, ...] = option.options + else: + items = (option,) + for item in items: + if item not in unique: + unique.append(item) + if len(unique) == 1 and not deciders: + return unique[0] + return Alt(tuple(unique), deciders) + + +# -- frames -------------------------------------------------------------------- + + +@dataclass(eq=False) +class _Frame: + """One function body being read, with the values its parameters hold.""" + + module: PythonModule + node: ast.FunctionDef | ast.AsyncFunctionDef | None + args: dict[str, Any] + enclosing: _Frame | None = None + via: tuple[str, ...] = () + depth: int = 0 + local: dict[str, list[tuple[str, ast.AST, ast.AST]]] = field(default_factory=dict) + mutations: dict[str, list[ast.AST]] = field(default_factory=dict) + conditions: dict[int, list[ast.expr]] = field(default_factory=dict) + raised: set[int] = field(default_factory=set) + calls: list[ast.Call] = field(default_factory=list) + nested: list[ast.FunctionDef | ast.AsyncFunctionDef] = field(default_factory=list) + referenced: set[str] = field(default_factory=set) + evaluating: set[str] = field(default_factory=set) + partial: dict[str, list[Any]] = field(default_factory=dict) + cache: dict[str, Any] = field(default_factory=dict) + #: ``id(Name) -> iterable``: a name a comprehension binds, read inside it. + comprehension_names: dict[int, ast.expr] = field(default_factory=dict) + #: Item stores and deletes: ``(statement, the object stored into)``. + stores: list[tuple[ast.AST, ast.expr]] = field(default_factory=list) + #: Loads of each name, by (line, column), for flow checks. + loads: dict[str, list[tuple[int, int]]] = field(default_factory=dict) + #: Attribute stores: ``(statement, target)``. + attribute_stores: list[tuple[ast.AST, ast.Attribute]] = field(default_factory=list) + + @property + def name(self) -> str: + return self.node.name if self.node is not None else "" + + def binds(self, name: str) -> bool: + return name in self.args or name in self.local + + +def _scan(frame: _Frame) -> None: + """Record the frame's own bindings, mutations, calls and conditions. + + Nested functions and classes are their own scopes; a comprehension or + lambda body is read as part of this one, since it runs here. + """ + + node = frame.node + assert node is not None + + def bind(name: str, kind: str, value: ast.AST, statement: ast.AST) -> None: + frame.local.setdefault(name, []).append((kind, value, statement)) + + def visit(item: ast.AST, tests: list[ast.expr], comprehension: dict[str, ast.expr]) -> None: + if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef): + # Decorators and defaults run where the function is defined. + for part in [*item.decorator_list, *item.args.defaults, *item.args.kw_defaults]: + if part is not None: + visit(part, tests, comprehension) + bind(item.name, "def", item, item) + frame.nested.append(item) + return + if isinstance(item, ast.ClassDef): + # A class body runs where the class is defined; its methods do not. + bind(item.name, "opaque", item, item) + for part in [*item.decorator_list, *item.bases, *(k.value for k in item.keywords)]: + visit(part, tests, comprehension) + for child in item.body: + if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): + for part in [*child.decorator_list, *child.args.defaults, *child.args.kw_defaults]: + if part is not None: + visit(part, tests, comprehension) + else: + visit(child, tests, comprehension) + return + if isinstance(item, ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp): + inner = dict(comprehension) + for generator in item.generators: + visit(generator.iter, tests, inner) + for name in _names(generator.target): + inner[name] = generator.iter + for condition in generator.ifs: + visit(condition, tests, inner) + for part in ( + [item.key, item.value] if isinstance(item, ast.DictComp) else [item.elt] + ): + visit(part, tests, inner) + return + if isinstance(item, ast.Raise): + for part in (item.exc, item.cause): + if isinstance(part, ast.Call): + frame.raised.add(id(part)) + if isinstance(item, ast.Assign): + for target in item.targets: + _target(target, item.value, item, bind, frame) + elif isinstance(item, ast.AnnAssign) and item.value is not None: + _target(item.target, item.value, item, bind, frame) + elif isinstance(item, ast.AugAssign): + if isinstance(item.target, ast.Name): + bind(item.target.id, "aug", item, item) + else: + _target(item.target, item.value, item, bind, frame) + elif isinstance(item, ast.For | ast.AsyncFor): + for name in _names(item.target): + bind(name, "iter", item.iter, item) + elif isinstance(item, ast.With | ast.AsyncWith): + for with_item in item.items: + if isinstance(with_item.optional_vars, ast.Name): + bind(with_item.optional_vars.id, "value", with_item.context_expr, item) + elif with_item.optional_vars is not None: + for name in _names(with_item.optional_vars): + bind(name, "opaque", with_item.context_expr, item) + elif isinstance(item, ast.ExceptHandler) and item.name: + bind(item.name, "opaque", item, item) + elif isinstance(item, ast.NamedExpr) and isinstance(item.target, ast.Name): + bind(item.target.id, "value", item.value, item) + elif isinstance(item, ast.Import | ast.ImportFrom): + for alias in item.names: + if alias.name != "*": + bind(alias.asname or alias.name.split(".", 1)[0], "import", alias, item) + elif isinstance(item, ast.Global | ast.Nonlocal): + kind = "global" if isinstance(item, ast.Global) else "nonlocal" + for name in item.names: + bind(name, kind, item, item) + elif isinstance(item, ast.Delete): + for target in item.targets: + if isinstance(target, ast.Subscript): + frame.stores.append((item, target.value)) + if isinstance(target.value, ast.Name): + frame.mutations.setdefault(target.value.id, []).append(item) + elif isinstance(item, ast.MatchAs | ast.MatchStar) and item.name: + bind(item.name, "opaque", item, item) + elif isinstance(item, ast.MatchMapping) and item.rest: + bind(item.rest, "opaque", item, item) + elif isinstance(item, ast.Call): + if tests: + frame.conditions[id(item)] = list(tests) + root = _root_name(item.func.value) if isinstance(item.func, ast.Attribute) else None + if root is not None: + frame.mutations.setdefault(root, []).append(item) + elif isinstance(item, ast.Name) and isinstance(item.ctx, ast.Load): + frame.referenced.add(item.id) + frame.loads.setdefault(item.id, []).append((item.lineno, item.col_offset)) + if item.id in comprehension: + frame.comprehension_names[id(item)] = comprehension[item.id] + if isinstance(item, ast.stmt | ast.NamedExpr) and tests: + frame.conditions[id(item)] = list(tests) + if isinstance(item, ast.If | ast.While): + visit(item.test, tests, comprehension) + for child in item.body: + visit(child, [*tests, item.test], comprehension) + for child in item.orelse: + visit(child, [*tests, item.test], comprehension) + return + if isinstance(item, ast.IfExp): + visit(item.test, tests, comprehension) + visit(item.body, [*tests, item.test], comprehension) + visit(item.orelse, [*tests, item.test], comprehension) + return + if isinstance(item, ast.Match): + visit(item.subject, tests, comprehension) + for case in item.cases: + visit(case.pattern, [*tests, item.subject], comprehension) + if case.guard is not None: + visit(case.guard, [*tests, item.subject], comprehension) + for child in case.body: + visit(child, [*tests, item.subject], comprehension) + return + for child in ast.iter_child_nodes(item): + visit(child, tests, comprehension) + if isinstance(item, ast.Call): + # After its arguments and receiver: an inner call is classified + # before the call made on what it returns. + frame.calls.append(item) + + for statement in node.body: + visit(statement, [], {}) + + +def _target( + target: ast.AST, + value: ast.expr, + statement: ast.AST, + bind: Any, + frame: _Frame, +) -> None: + if isinstance(target, ast.Name): + bind(target.id, "value", value, statement) + elif isinstance(target, ast.Tuple | ast.List): + for name in _names(target): + bind(name, "unpacked", value, statement) + elif isinstance(target, ast.Subscript): + frame.stores.append((statement, target.value)) + root = _root_name(target.value) + if root is not None: + frame.mutations.setdefault(root, []).append(statement) + elif isinstance(target, ast.Attribute): + frame.attribute_stores.append((statement, target)) + root = _root_name(target.value) + if root is not None: + frame.mutations.setdefault(root, []).append(statement) + elif isinstance(target, ast.Starred): + _target(target.value, value, statement, bind, frame) + + +def _attribute_name(node: ast.AST) -> str | None: + return node.attr if isinstance(node, ast.Attribute) else None + + +def _root_name(node: ast.AST) -> str | None: + while isinstance(node, ast.Attribute | ast.Subscript): + node = node.value + return node.id if isinstance(node, ast.Name) else None + + +def _names(target: ast.AST) -> list[str]: + return [ + node.id + for node in ast.walk(target) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) + ] + + +def _module_library(module: PythonModule, name: str) -> str | None: + """The dotted library name a module-level import binds to ``name``. + + Only one unconditional import binding counts. A relative import is the + repository's own code. + """ + + bindings = module.bindings.get(name, []) + if len(bindings) != 1 or not bindings[0].top_level: + return None + alias, statement = bindings[0].node, bindings[0].statement + return _import_dotted(alias, statement) + + +def _import_dotted(alias: ast.AST, statement: ast.AST) -> str | None: + if not isinstance(alias, ast.alias): + return None + if isinstance(statement, ast.Import): + return alias.name if alias.asname else alias.name.split(".", 1)[0] + if isinstance(statement, ast.ImportFrom) and statement.module and not statement.level: + return f"{statement.module}.{alias.name}" + return None + + +# -- the reader ---------------------------------------------------------------- + + +class _Reach: + def __init__( + self, + resolver: ImportResolver, + mutated: dict[str, str] | None = None, + whole: dict[str, str] | None = None, + library: dict[str, str] | None = None, + ) -> None: + self.resolver = resolver + #: ``"json.dumps" -> "file:line"``: library attributes stored into. + self.library = library or {} + #: The attribute stored last through an object the scan does not + #: follow, by name (`http.get` -> `get`). + self.unseen_segments: dict[str, str] = {} + holders = _holders(self.library) + for key, where in self.library.items(): + if key.startswith("unseen:") and (not key.startswith("unseen:^") or _held_store(key[7:], holders)): + self.unseen_segments.setdefault(key[7:].rsplit(".", 1)[-1], where) + #: Method names stored into on a library object (`Path.read_text`). + self.replaced_methods = { + key.rsplit(".", 1)[-1]: where + for key, where in self.library.items() + if not key.endswith("*") + and not key.startswith(("kept:", "unseen:", "class:", "via:")) + # A method replaced on a class (`Path.read_text`), not a + # function stored on a module (`config.get = cached`). + and len(parts := key.split(".")) >= 2 + and parts[-2][:1].isupper() + } + #: ``name -> "file:line"``: names stored under somewhere in the scope. + self.mutated = mutated or {} + #: ``name -> "file:line"``: containers or modules changed under names + #: the read cannot see. + self.whole = whole or {} + self.calls: list[dict[str, Any]] = [] + self.limits: list[dict[str, str]] = [] + self.limit_count = 0 + self.frames = 0 + self.walked: set[tuple[Any, ...]] = set() + self.stack: list[int] = [] + self.returns: dict[tuple[Any, ...], Any] = {} + self.constants = 0 + self.module_frames: dict[int, _Frame] = {} + self.names: dict[tuple[int, str], Any] = {} + self.truncated = False + #: Effects of outbound calls past :data:`MAX_CALLS`, never dropped. + self.dropped: list[dict[str, Any]] = [] + self.recursions: dict[int, int] = {} + #: Calls already named as limits: a method called on what one returns + #: is part of the same unread chain. + self.limited: set[int] = set() + #: Module-level constructions and modules whose configuration was read. + self.configured: set[tuple[int, int]] = set() + self.patched: set[int] = set() + + # -- limits ------------------------------------------------------------ + + def limit(self, frame: _Frame, node: ast.AST, why: str) -> None: + self.limited.add(id(node)) + self.limit_count += 1 + entry = {"at": f"{frame.module.ref}:{getattr(node, 'lineno', 0)}", "why": why} + if entry not in self.limits and len(self.limits) < MAX_LIMITS: + self.limits.append(entry) + + # -- frames ------------------------------------------------------------ + + def frame( + self, + func: Func, + args: dict[str, Any], + *, + via: tuple[str, ...], + depth: int, + ) -> _Frame: + frame = _Frame( + module=func.module, + node=func.node, + args=args, + enclosing=func.enclosing, + via=via, + depth=depth, + ) + _scan(frame) + return frame + + def module_frame(self, module: PythonModule) -> _Frame: + frame = self.module_frames.get(id(module)) + if frame is None: + frame = _Frame(module=module, node=None, args={}) + self.module_frames[id(module)] = frame + return frame + + def bind( + self, + func: Func, + call: ast.Call | None, + caller: _Frame | None, + *, + defaults: bool = True, + ) -> dict[str, Any]: + """Parameter values for one call of ``func``; unmatched ones are unnamed.""" + + arguments = func.node.args + positional = [*arguments.posonlyargs, *arguments.args] + values: dict[str, Any] = {} + default_values = dict( + zip( + [param.arg for param in positional[len(positional) - len(arguments.defaults):]], + arguments.defaults, + strict=True, + ) + ) + default_values.update( + { + param.arg: default + for param, default in zip(arguments.kwonlyargs, arguments.kw_defaults, strict=True) + if default is not None + } + ) + spread = False + if call is not None and caller is not None: + for index, arg in enumerate(call.args): + if isinstance(arg, ast.Starred): + spread = True + break + if index < len(positional): + values[positional[index].arg] = self.value(arg, caller) + for keyword in call.keywords: + if keyword.arg is None: + spread = True + elif keyword.arg not in values: + values[keyword.arg] = self.value(keyword.value, caller) + module = self.module_frame(func.module) + for param in [*positional, *arguments.kwonlyargs]: + if param.arg in values: + continue + if spread: + values[param.arg] = Op(what=f"parameter {param.arg} of {func.node.name}") + elif param.arg in default_values and defaults: + values[param.arg] = self.value(default_values[param.arg], module) + else: + values[param.arg] = Op(what=f"parameter {param.arg} of {func.node.name}") + for extra in (arguments.vararg, arguments.kwarg): + if extra is not None: + values[extra.arg] = Op(what=f"parameter {extra.arg} of {func.node.name}") + return values + + # -- walking ----------------------------------------------------------- + + def walk(self, frame: _Frame) -> None: + key = (id(frame.node), id(frame.enclosing), tuple(sorted(frame.args.items(), key=repr))) + if key in self.walked: + return + if id(frame.node) in self.stack: + # Recursion with other arguments: follow it twice as called, then + # read the body once more with every parameter unnamed (defaults + # too), which covers whatever else it recurses with. + entries = self.recursions.get(id(frame.node), 0) + self.recursions[id(frame.node)] = entries + 1 + if entries >= MAX_RECURSION: + widened = (id(frame.node), id(frame.enclosing), "*") + if widened in self.walked or frame.node is None: + return + self.walked.add(widened) + func = Func(frame.module, frame.node, frame.enclosing) + frame = self.frame( + func, self.bind(func, None, None, defaults=False), via=frame.via, depth=frame.depth + ) + key = widened + if self.frames >= MAX_FRAMES: + if not self.truncated: + self.truncated = True + self.limit(frame, frame.node or frame.module.tree, ( + f"reading {frame.name} would read more than {MAX_FRAMES} function bodies" + )) + return + self.frames += 1 + self.walked.add(key) + self.stack.append(id(frame.node)) + try: + self.patches(frame.module) + self.decorators(frame) + for call in frame.calls: + self.classify(call, frame) + for statement, attribute in frame.attribute_stores: + self.attribute_store(statement, attribute, frame) + for statement, target in frame.stores: + stored = self.value(target, frame) + if not ( + isinstance(stored, Rec | Seq) + or _is_data(stored) + or self._container(target, stored, frame) + ): + self.limit( + frame, + statement, + f"stores into {_spelling(target) or 'an object'}[…], which is not read", + ) + for nested in frame.nested: + if nested.name in frame.referenced and not self._only_called(frame, nested.name): + # Handed on rather than called here: it may run with + # arguments this read does not see. + func = Func(frame.module, nested, frame) + self.walk( + self.frame(func, self.bind(func, None, None), via=frame.via, depth=frame.depth) + ) + finally: + self.stack.pop() + + def attribute_store(self, statement: ast.AST, target: ast.Attribute, frame: _Frame) -> None: + """``x.attr = v`` on something this function did not build is a limit. + + A setter may send, and a request or client handed in (`r.method = + "DELETE"` in a helper) changes what its caller's call does (#872 review + 2). A local request or client is read by its own name instead. + """ + + root = _root_name(target.value) + if ( + root is not None + and root not in frame.args + and root in frame.local + and all(self._built_here(kind, value, frame) for kind, value, _ in frame.local[root]) + ): + owner = self.value(target.value, frame) + if not isinstance(owner, Client): + if isinstance(owner, Request | Rec | Seq) or _is_data(owner): + return + # `box[0].request = send`: something held in a local container. + self.limit(frame, statement, f"sets {_spelling(target) or 'an attribute'}, which is not read") + return + value = getattr(statement, "value", None) + if target.attr in {"auth", "hooks"} and isinstance(value, ast.expr): + # A function set as the client's auth or hook runs on every + # request (#872 review 3). + self._handed(statement, value, frame, strict=True) + return + if target.attr in _CLIENT_SETTINGS: + return + self.limit( + frame, statement, f"sets {_spelling(target) or 'an attribute'} on a client, which is not read" + ) + return + if _is_data(self.value(target.value, frame)): + return + self.limit(frame, statement, f"sets {_spelling(target) or 'an attribute'}, which is not read") + + def _client_statement(self, change: ast.AST, frame: _Frame) -> None: + """One module-level statement changing a client: read it or name it.""" + + if isinstance(change, ast.Call): + func = change.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "update" + and _attribute_name(func.value) == "headers" + ): + return + self.limit(frame, change, f"calls {_spelling(func)} on a client, which is not read") + return + if not isinstance(change, ast.Assign | ast.AnnAssign | ast.AugAssign): + return + targets = change.targets if isinstance(change, ast.Assign) else [change.target] + for target in targets: + if isinstance(target, ast.Subscript) and _attribute_name(target.value) == "headers": + continue + attribute = target if isinstance(target, ast.Attribute) else None + if attribute is None and isinstance(target, ast.Subscript) and isinstance(target.value, ast.Attribute): + attribute = target.value + if attribute is None: + continue + if attribute.attr in {"auth", "hooks"} and change.value is not None: + self._handed(change, change.value, frame, strict=True) + elif attribute.attr not in _CLIENT_SETTINGS or attribute is not target: + self.limit( + frame, change, f"sets {_spelling(target) or 'an attribute'} on a client, which is not read" + ) + + def patches(self, module: PythonModule) -> None: + """A module that patches an HTTP library at import changes every request. + + `requests.get = logged_get`, `urllib.request.install_opener(...)` or a + `monkey.patch_all()` in a module this read follows is named, once + (#872 review 4). + """ + + if id(module) in self.patched: + return + self.patched.add(id(module)) + frame = self.module_frame(module) + for statement in module.tree.body: + if isinstance(statement, ast.Assign | ast.AugAssign | ast.AnnAssign): + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + for target in targets: + root = _root_name(target) if isinstance(target, ast.Attribute) else None + if root is not None and isinstance(self.module_name(module, root), Lib): + self.limit(frame, statement, f"patches {_spelling(target)} at import, which is not read") + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + callee = self.dotted_callee(statement.value.func, frame) + if callee is None and isinstance(statement.value.func, ast.Name): + callee = self.module_name(module, statement.value.func.id) + if isinstance(callee, Lib) and any( + word in callee.dotted.lower() for word in ("install", "monkey", "patch") + ): + self.limit( + frame, + statement, + f"calls {callee.dotted} at import, which may change every request; not read", + ) + + def _local_client(self, node: ast.AST, frame: _Frame) -> bool: + """Whether the client a call is made on was constructed in this function.""" + + if isinstance(node, ast.Call): + return self._built_here("value", node, frame) + if not isinstance(node, ast.Name) or node.id in frame.args: + return False + bindings = frame.local.get(node.id, []) + return bool(bindings) and all( + self._built_here(kind, value, frame) for kind, value, _ in bindings + ) + + def _built_here(self, kind: str, value: ast.AST, frame: _Frame) -> bool: + """A binding to a request, client or container this function constructs.""" + + if kind != "value": + return False + if isinstance(value, ast.List | ast.Dict | ast.Set | ast.ListComp | ast.DictComp | ast.SetComp): + return True + if not isinstance(value, ast.Call): + return False + # Only the library's own constructor: a factory's client may carry + # hooks set where this read does not look (#872 review 5). + callee = self.dotted_callee(value.func, frame) or self.value(value.func, frame) + return isinstance(callee, Lib) and (callee.dotted in _CLIENTS or callee.dotted in _REQUEST_CLASSES) + + def decorators(self, frame: _Frame) -> None: + """A decorator may replace the function: name any this read cannot see into.""" + + if frame.node is None: + return + outer = frame.enclosing or self.module_frame(frame.module) + for decorator in frame.node.decorator_list: + target = decorator.func if isinstance(decorator, ast.Call) else decorator + value = self.value(target, outer) + if isinstance(value, Lib) and value.dotted in _INERT_DECORATORS: + continue + if isinstance(value, _Builtin) and value.name in {"classmethod", "property", "staticmethod"}: + continue + self.limit( + frame, + decorator, + f"{frame.node.name} is decorated with {_spelling(target) or 'a computed decorator'}, " + "which is not read", + ) + + def _only_called(self, frame: _Frame, name: str) -> bool: + called = { + id(call.func) + for call in frame.calls + if isinstance(call.func, ast.Name) and call.func.id == name + } + assert frame.node is not None + return all( + id(node) in called + for node in ast.walk(frame.node) + if isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load) + ) + + def classify(self, call: ast.Call, frame: _Frame) -> None: + callee = self._classify(call, frame) + self.handed_on(call, frame, callee) + + def _classify(self, call: ast.Call, frame: _Frame) -> str | None: + """Follow, record or name one call; return the name of a callee passed over.""" + + func = call.func + spelling = _spelling(func) + dotted = self.dotted_callee(func, frame) + if dotted is not None: + callee: Any = dotted + elif isinstance(func, ast.Attribute): + receiver = self.value(func.value, frame) + method = func.attr + if isinstance(receiver, Lib): + return self.library_call(call, frame, f"{receiver.dotted}.{method}") + if isinstance(receiver, Client): + if method in _VERBS or method in {"request", "stream"}: + self.http(call, frame, receiver.library, method, receiver) + if not self._local_client(func.value, frame): + # A module-level, imported or factory-made client may be + # configured anywhere (#872 review 5). + self.limit( + frame, + call, + f"sends through {_spelling(func.value) or 'a client'}, built outside this " + "function; its configuration is not read", + ) + return _SENDS + if method == "mount" and all(_is_quiet(self.value(arg, frame)) for arg in call.args): + # A retry adapter: `s.mount("https://", HTTPAdapter(max_retries=3))`. + return None + if method not in {"close", "aclose"}: + self.limit(frame, call, f"calls {spelling}, which is not read") + return None + if isinstance(receiver, Func): + self.limit(frame, call, f"calls {spelling}, which is not read") + return None + local = isinstance(receiver, Rec | Seq) or self._container(func.value, receiver, frame) + replaced = None if local else ( + self.replaced_methods.get(method) or self.library.get(f"*.{method}") or self.library.get("*") + ) + if method in _PURE_METHODS and _is_quiet(receiver) and replaced is not None: + # `pathlib.Path.read_text = hook` elsewhere (#872 review 13). + if id(call) not in frame.raised: + self.limit(frame, call, f"calls {spelling}, which {replaced} replaces; not read") + return None + if ( + (method in _PURE_METHODS and (local or _is_quiet(receiver))) + or (method in _STRING_METHODS and _is_data(receiver)) + or (method in _CONTAINER_METHODS and (local or _is_data(receiver))) + ): + return method + if id(call) in frame.raised: + return None + if isinstance(func.value, ast.Call) and id(func.value) in self.limited: + return None + described = _described(receiver) + head = spelling.split(".", 1)[0] + self.limit( + frame, + call, + f"calls {spelling}" + + (f" ({described})" if described and described != head else "") + + ", which is not read", + ) + return None + else: + callee = self.value(func, frame) + if isinstance(callee, Lib): + return self.library_call(call, frame, callee.dotted) + if isinstance(callee, Func): + name = callee.node.name + if frame.depth >= MAX_DEPTH: + self.limit( + frame, + call, + f"calls {name}, more than {MAX_DEPTH} helper calls from the tool; not read", + ) + return None + args = self.bind(callee, call, frame) + self.walk( + self.frame( + callee, + args, + via=(*frame.via, f"{frame.module.ref}:{call.lineno} {name}"), + depth=frame.depth + 1, + ) + ) + return None + if isinstance(callee, _Builtin): + if callee.name in _PURE_BUILTINS: + return callee.name + if id(call) not in frame.raised: + self.limit(frame, call, f"calls {callee.name}, which is not read") + return None + if id(call) in frame.raised: + return None + if isinstance(callee, Op) and callee.what: + self.limit(frame, call, f"calls {spelling} ({callee.what}), which is not read") + else: + self.limit(frame, call, f"calls {spelling or 'a computed callee'}, which is not read") + return None + + def _container(self, node: ast.AST, receiver: Any, frame: _Frame) -> bool: + """Whether ``node`` is a list, dict or set this function built itself.""" + + if isinstance(receiver, Rec | Seq): + return True + if not isinstance(node, ast.Name) or node.id in frame.args: + return False + bindings = frame.local.get(node.id, []) + return bool(bindings) and all( + kind == "value" + and ( + isinstance( + value, + ast.List | ast.Dict | ast.Set | ast.ListComp | ast.DictComp | ast.SetComp, + ) + or ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id in _CONTAINER_BUILDERS + and not frame.binds(value.func.id) + ) + ) + for kind, value, _ in bindings + ) + + def handed_on(self, call: ast.Call, frame: _Frame, passed: str | None) -> None: + """A function passed to a call may run there: read it, or name it. + + ``sorted(items, key=helper)`` runs ``helper``; ``map(es.delete, ids)`` + runs a method this read never sees called (#872 review). Where a call + this read passed over takes a function — ``map``'s first argument, a + ``key=`` — whatever is passed must be read or named. Anywhere else a + repository function passed on is read as called. + """ + + positions = _CALLABLE_POSITIONS.get(passed or "", ()) + if passed == "iter" and len(call.args) == 2: + positions = (0,) # `iter(callable, sentinel)` calls it until the sentinel + for index, node in enumerate(call.args): + if isinstance(node, ast.Starred): + node = node.value + self._handed(call, node, frame, strict=passed is not None and index in positions) + for keyword in call.keywords: + if keyword.arg is None and passed is not None: + self._spread(call, keyword.value, frame) + continue + strict = passed is not None and keyword.arg in _CALLABLE_KEYWORDS + self._handed(call, keyword.value, frame, strict=strict) + + def _spread(self, call: ast.Call, node: ast.expr, frame: _Frame) -> None: + """`requests.get(url, **opts)`: each option that takes a function is read. + + A `hooks` entry in a spread dict runs as surely as one written out + (#872 review 4); a spread the read cannot see into is a limit. + """ + + value = self.value(node, frame) + records = _records(value) + if records is None: + self.limit(frame, call, f"passes **{_spelling(node) or 'a computed value'}, which is not read") + return + for record in records: + for key, item in record.fields: + if key == "**": + self.limit(frame, call, f"passes **{_spelling(node) or 'a computed value'}, which is not read") + elif key in _CALLABLE_KEYWORDS: + self._handed_value(call, item, frame, strict=True, label=f"{_spelling(node)}[{key!r}]") + + def _handed(self, call: ast.Call | ast.AST, node: ast.expr, frame: _Frame, *, strict: bool) -> None: + if isinstance(node, ast.Lambda): + return # its body is read as part of this function + if isinstance(node, ast.Dict | ast.List | ast.Tuple | ast.Set): + # `hooks={"response": [_audit]}`: each element may be a function. + elements = [*node.values] if isinstance(node, ast.Dict) else list(node.elts) + for element in elements: + if isinstance(element, ast.Starred): + element = element.value + self._handed(call, element, frame, strict=strict) + return + if not strict and not isinstance(node, ast.Name | ast.Attribute): + return + value = self.value(node, frame) + if isinstance(node, ast.Attribute) and isinstance(self.value(node.value, frame), Client): + self.limit(frame, call, f"hands on {_spelling(node)}, which is not read") + return + self._handed_value(call, value, frame, strict=strict, label=_spelling(node)) + + def _handed_value( + self, call: ast.Call | ast.AST, value: Any, frame: _Frame, *, strict: bool, label: str + ) -> None: + for option in value.options if isinstance(value, Alt) else (value,): + if strict and isinstance(option, Rec | Seq): + # `hooks=HOOKS`: a dict or list of functions held elsewhere. + items = [item for _, item in option.fields] if isinstance(option, Rec) else option.items + for item in items: + self._handed_value(call, item, frame, strict=True, label=label) + if isinstance(option, Rec) and option.open: + self.limit(frame, call, f"hands on {label or 'a computed value'}, which is not read") + continue + if isinstance(option, Func): + if frame.depth >= MAX_DEPTH: + self.limit( + frame, + call, + f"hands on {option.node.name}, more than {MAX_DEPTH} helper calls " + "from the tool; not read", + ) + continue + self.walk( + self.frame( + option, + self.bind(option, None, None), + via=(*frame.via, f"{frame.module.ref}:{call.lineno} {option.node.name}"), + depth=frame.depth + 1, + ) + ) + elif isinstance(option, Lib): + if "." in option.dotted and not _inert_library(option.dotted): + self.limit(frame, call, f"hands on {option.dotted}, which is not read") + elif isinstance(option, _Builtin): + if strict and option.name not in _PURE_BUILTINS: + self.limit(frame, call, f"hands on {option.name}, which is not read") + elif strict and not _is_quiet(option): + # Plain data (a replacement string, `None`) or an inert object + # (a retrying transport) is not a function. + self.limit(frame, call, f"hands on {label or 'a computed value'}, which is not read") + + def replaced(self, dotted: str) -> str | None: + """Where a library function the read takes as pure was replaced, if anywhere. + + Matched by any part of its path, from any module on it: `json.dumps`, + `sys.modules["json"].dumps = …` (recorded as `json.dumps`), + `datetime.date = …` for `datetime.date.today`, or `json.*` when the + module's namespace changed under names the scan cannot see. + """ + parts = dotted.split(".") + found = self.library.get("*") or self.library.get(f"*.{parts[-1]}") or self.whole.get("*") + for start in range(len(parts) - 1): + for end in range(start + 2, len(parts) + 1): + path = ".".join(parts[start:end]) + found = found or self.library.get(path) + if end < len(parts): + found = found or self.library.get(f"{path}.*") + found = found or self.library.get(f"{parts[start]}.*") + # A module kept where the scan does not follow it, and this + # attribute stored on an object the scan does not follow. + if found is None and f"kept:{parts[start]}" in self.library: + found = self.unseen_segments.get(parts[start + 1]) + return found + + def library_call(self, call: ast.Call, frame: _Frame, dotted: str) -> str | None: + where = self.replaced(dotted) + if where is not None and _inert_library(dotted): + # `json.dumps = audited_dumps` elsewhere in the scope. + if id(call) not in frame.raised: + self.limit(frame, call, f"calls {dotted}, which {where} replaces; not read") + return None + library, _, verb = dotted.rpartition(".") + if library in _HTTP_LIBRARIES and (verb in _VERBS or verb in {"request", "stream"}): + self.http(call, frame, library, verb, None) + return _SENDS + if dotted in _URLOPEN: + self.urlopen(call, frame) + return _SENDS + if dotted in _CLIENTS or dotted in _REQUEST_CLASSES: + return _SENDS + if _inert_library(dotted): + return dotted + elif id(call) not in frame.raised: + self.limit(frame, call, f"calls {dotted}, which is not read") + return None + + # -- outbound calls ---------------------------------------------------- + + def http( + self, + call: ast.Call, + frame: _Frame, + library: str, + verb: str, + client: Client | None, + ) -> None: + arguments = _Arguments(call) + if verb in {"request", "stream"}: + method = self.value(arguments.take(0, "method"), frame) + url = self.value(arguments.take(1, "url"), frame) + positionals: tuple[str, ...] = () + else: + method = Lit(_VERBS[verb]) + url = self.value(arguments.take(0, "url"), frame) + positionals = _VERB_POSITIONALS.get(verb, ()) + named: dict[str, Any] = {} + for index, name in enumerate(positionals): + node = arguments.take(index + 1, name) + if node is not None: + named[name] = self.value(node, frame) + for name in ("params", "data", "json", "content", "headers", "auth", "files"): + if name not in named: + node = arguments.take(None, name) + if node is not None: + named[name] = self.value(node, frame) + if client is not None: + if client.base_url is not None and not _absolute(url): + url = _join([client.base_url, url]) + if client.headers is not None: + named["headers"] = _merge_headers(client.headers, named.get("headers")) + if client.auth is not None and named.get("auth") is None: + named["auth"] = client.auth + if client.params is not None: + named["params"] = _merge_headers(client.params, named.get("params")) + if arguments.spread: + named.setdefault("headers", _UNKNOWN) + self.record(call, frame, library, method, url, named) + + def urlopen(self, call: ast.Call, frame: _Frame) -> None: + arguments = _Arguments(call) + target_node = arguments.take(0, "url") + target = self.value(target_node, frame) + if isinstance(target, Request) and target_node is not None and not self._local_client(target_node, frame): + # A module-level request may be changed anywhere (#872 review 6). + self.limit( + frame, + call, + f"sends {_spelling(target_node) or 'a request'}, built outside this function; " + "its configuration is not read", + ) + data_node = arguments.take(1, "data") + data = self.value(data_node, frame) if data_node is not None else None + headers: Any = None + if isinstance(target, Request): + url, headers = target.url, target.headers + data = target.data if data is None else data + method = target.method + elif _is_data(target): + url, method = target, None + else: + # Not a URL and not a request this read built: its method is + # whatever the object says. + url, method = _UNKNOWN, _UNKNOWN + if method is None: + method = Lit("GET") if data is None or data == Lit(None) else Lit("POST") + named = {"data": data} if data is not None else {} + if headers is not None: + named["headers"] = headers + self.record(call, frame, "urllib", method, url, named) + + def record( + self, + call: ast.Call, + frame: _Frame, + library: str, + method: Any, + url: Any, + named: dict[str, Any], + ) -> None: + if len(self.calls) >= MAX_CALLS: + if not self.truncated: + self.truncated = True + self.limit(frame, call, f"the tool makes more than {MAX_CALLS} outbound calls") + methods = _methods(method) + # Not listed, but its effect still counts: a later DELETE must + # not leave the claim at write. + self.dropped.append( + { + "effect": _call_effect(methods, None), + "method": "|".join(methods) if methods else None, + "url": _render_url(url, named.get("params")), + "at": f"{frame.module.ref}:{call.lineno}", + } + ) + return + entry: dict[str, Any] = {"library": library} + methods = _methods(method) + entry["method"] = "|".join(methods) if methods else None + entry["url"] = _render_url(url, named.get("params")) + payload = next( + ( + named[name] + for name in ("json", "data", "content") + if named.get(name) is not None and named[name] != Lit(None) + ), + None, + ) + graphql = None + document = ( + payload.get("query") if isinstance(payload, Rec) and not payload.shared else None + ) + kinds = ( + _graphql_operations(document.value) + if isinstance(document, Lit) and isinstance(document.value, str) + else None + ) + # Only a GraphQL endpoint: a LogQL or PromQL `query` parses as an + # anonymous GraphQL query, and a POST to a delete endpoint is not a read + # (#872 review 2). + if _graphql_endpoint(url): + graphql = ( + "mutation" + if kinds and "mutation" in kinds + else "query" + if kinds and kinds <= {"query", "subscription"} + else "unknown" + ) + entry["graphql"] = graphql + entry["effect"] = _call_effect(methods, graphql) + entry["at"] = f"{frame.module.ref}:{call.lineno}" + entry["via"] = list(frame.via) + if any( + (item["at"], item["via"], item["method"], item["url"]) + == (entry["at"], entry["via"], entry["method"], entry["url"]) + for item in self.calls + ): + # The same site reached again, as a function handed on as well as + # called: one call. + return + fields = _fields(payload) + if fields: + entry["fields"] = fields + credentials = _credentials( + named.get("headers"), named.get("auth"), named.get("params"), url, payload + ) + if credentials: + entry["credential_sources"] = credentials + supplied = _model_supplied(method, url, named, payload) + if supplied: + entry["model_supplied"] = supplied + self.calls.append(entry) + if not methods: + self.limit(frame, call, "the request method is not a literal") + elif graphql == "unknown": + self.limit(frame, call, "the GraphQL document is not a literal; its operation is not read") + + # -- values ------------------------------------------------------------ + + def value(self, node: ast.AST | None, frame: _Frame) -> Any: + if node is None: + return Lit(None) + try: + return self._value(node, frame) + except RecursionError: + return _UNKNOWN + + def _value(self, node: ast.AST, frame: _Frame) -> Any: + if isinstance(node, ast.Constant): + if isinstance(node.value, bytes): + return Op(literal=True) + return Lit(node.value) + if isinstance(node, ast.JoinedStr): + parts: list[Any] = [] + for part in node.values: + if isinstance(part, ast.FormattedValue): + inner = self.value(part.value, frame) + if part.format_spec is not None or part.conversion not in (-1, ord("s")): + inner = _derived(inner) + parts.append(inner) + else: + parts.append(self.value(part, frame)) + return _join(parts) + if isinstance(node, ast.BinOp): + left, right = self.value(node.left, frame), self.value(node.right, frame) + if isinstance(node.op, ast.Add) and (_is_text(left) or _is_text(right)): + return _join([left, right]) + return _derived(left, right) + if isinstance(node, ast.Name): + iterable = frame.comprehension_names.get(id(node)) + if iterable is not None: + return _derived(self.value(iterable, frame)) + return self.name(node.id, frame, node) + if isinstance(node, ast.Attribute): + return self.attribute(node, frame) + if isinstance(node, ast.Subscript): + base = self.value(node.value, frame) + key = self.value(node.slice, frame) + if isinstance(base, Lib) and base.dotted == "os.environ": + if isinstance(key, Lit) and isinstance(key.value, str): + return Op(envs=frozenset({key.value}), exact=True, data=True) + return _derived(key) + if isinstance(base, Rec) and base.shared: + return Op(what=f"{_spelling(node.value) or 'a module-level dict'}[…], a module-level dict") + if isinstance(base, Rec) and isinstance(key, Lit) and isinstance(key.value, str): + found = base.get(key.value) + if found is not None: + return found + if _is_data(base): + # An item or slice of plain data is plain data. + return _derived(base, key, data=True) + return _derived(base, key) + if isinstance(node, ast.Slice): + bounds = [self.value(part, frame) for part in (node.lower, node.upper, node.step) if part] + return _derived(*bounds) if bounds else Lit(None) + if isinstance(node, ast.Call): + return self.call_value(node, frame) + if isinstance(node, ast.Dict): + fields: dict[str, Any] = {} + open_ = False + shared = False + extra: list[Any] = [] + for key_node, value_node in zip(node.keys, node.values, strict=True): + value = self.value(value_node, frame) + key = self.value(key_node, frame) if key_node is not None else None + if isinstance(key, Lit) and isinstance(key.value, str): + fields.pop(key.value, None) + fields[key.value] = value + elif key_node is None and isinstance(value, Rec): + fields.update(value.fields) + open_ = open_ or value.open + # `{**DEFAULTS}` copies a module-level dict (#872 review 10). + shared = shared or value.shared + else: + open_ = True + extra.append(value) + if extra: + fields["**"] = _derived(*extra) + return Rec(tuple(fields.items()), open_, shared) + if isinstance(node, ast.List | ast.Tuple | ast.Set): + return Seq(tuple(self.value(item, frame) for item in node.elts)) + if isinstance(node, ast.IfExp): + test = self.value(node.test, frame) + return _alt( + [self.value(node.body, frame), self.value(node.orelse, frame)], + _sources(test)[0], + ) + if isinstance(node, ast.BoolOp): + return _alt([self.value(item, frame) for item in node.values]) + if isinstance(node, ast.Await): + return self.value(node.value, frame) + if isinstance(node, ast.NamedExpr): + return self.value(node.value, frame) + if isinstance(node, ast.Starred): + return _derived(self.value(node.value, frame)) + if isinstance(node, ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp): + # What the comprehension holds is its elements: `[Request(...) for + # i in ids]` holds requests, not the ids. + elements = [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt] + return _derived( + *(self.value(gen.iter, frame) for gen in node.generators), + *(self.value(element, frame) for element in elements), + ) + if isinstance(node, ast.Compare | ast.UnaryOp): + return _derived(*(self.value(child, frame) for child in ast.iter_child_nodes(node) if isinstance(child, ast.expr))) + return _UNKNOWN + + def dotted_callee(self, func: ast.AST, frame: _Frame) -> Func | Lib | None: + """``module.function`` or ``library.name`` spelled through a module-level name.""" + + if not isinstance(func, ast.Attribute): + return None + head: ast.AST = func + while isinstance(head, ast.Attribute): + head = head.value + if not isinstance(head, ast.Name) or self._local(head.id, frame): + return None + if head.id not in frame.module.bindings: + return None + value = self.attribute(func, frame) + return value if isinstance(value, Func | Lib) else None + + def attribute(self, node: ast.Attribute, frame: _Frame) -> Any: + head: ast.AST = node + while isinstance(head, ast.Attribute): + head = head.value + if isinstance(head, ast.Name) and not self._local(head.id, frame): + spelling = reference_spelling(node) + if spelling is not None and head.id in frame.module.bindings: + named = self.module_name(frame.module, head.id) + if isinstance(named, Lib): + return Lib(f"{named.dotted}{spelling[len(head.id):]}") + resolved = self._resolved(frame.module, self.resolver.resolve(frame.module, spelling)) + if resolved is not None: + return resolved + base = self.value(node.value, frame) + if isinstance(base, Lib): + return Lib(f"{base.dotted}.{node.attr}") + if isinstance(base, Client) and node.attr == "headers": + return base.headers if base.headers is not None else Rec(()) + if isinstance(base, Op) and base.inert and not base.data: + return _derived(base, data=node.attr in _DATA_ATTRIBUTES, inert=True) + return _derived(base) + + def _local(self, name: str, frame: _Frame | None) -> bool: + while frame is not None and frame.node is not None: + if frame.binds(name): + return not all(kind == "global" for kind, _, _ in frame.local.get(name, [])) or name in frame.args + frame = frame.enclosing + return False + + def name(self, name: str, frame: _Frame, node: ast.AST) -> Any: + scope: _Frame | None = frame + while scope is not None and scope.node is not None: + if scope.binds(name): + kinds = {kind for kind, _, _ in scope.local.get(name, [])} + if "global" in kinds: + return Op(what=f"module global {name}") + if "nonlocal" in kinds: + scope = scope.enclosing + continue + # A name a nested function rebinds (`nonlocal`) or stores + # into is shared state, like a module global (#872 review 11). + where = _closure_changes(scope.node).get(name) + if where is not None: + return Op(what=f"{name}, which {frame.module.ref}:{where} changes") + value = self._frame_name(name, scope) + if scope is not frame and isinstance(value, Rec): + # A dict an enclosing function holds outlives this call: + # any closure over it may change it. + value = replace(value, shared=True) + return value + scope = scope.enclosing + return self.module_name(frame.module, name) + + def _frame_name(self, name: str, frame: _Frame) -> Any: + if name in frame.cache: + return frame.cache[name] + if name in frame.evaluating: + # ``x = x.strip()``: the name read inside its own rebinding holds + # what the bindings read so far give. + earlier = frame.partial.get(name) or [] + return _derived(*earlier) if earlier else _UNKNOWN + frame.evaluating.add(name) + options: list[Any] = [] + frame.partial[name] = options + try: + bindings = frame.local.get(name, []) + replaced = self._replaced(name, bindings, frame) + if replaced is not None: + bindings = bindings[replaced:] + elif name in frame.args: + options.append(frame.args[name]) + for kind, value, statement in bindings: + options.append(self._binding(kind, value, statement, name, frame)) + deciders: set[str] = set() + if len(options) > 1: + for _, _, statement in bindings: + for test in frame.conditions.get(id(statement), []): + deciders.update(_sources(self.value(test, frame))[0]) + value = options[0] if len(options) == 1 else _alt(options, frozenset(deciders)) + value = self._mutated(name, value, frame) + finally: + frame.evaluating.discard(name) + frame.partial.pop(name, None) + frame.cache[name] = value + return value + + def _replaced( + self, name: str, bindings: list[tuple[str, ast.AST, ast.AST]], frame: _Frame + ) -> int | None: + """The binding that replaces ``name`` before anything reads it, if any. + + ``pr_number = int(os.environ["PR_NUMBER"])`` as the body's first use of + ``pr_number`` means the argument never reaches the request (#872 + review): flow order matters where a parameter is overwritten outright. + """ + + if frame.node is None: + return None + if any( + isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Load) + for nested in frame.nested + for node in ast.walk(nested) + ): + # A closure reads the name when it runs, which may be before the + # rebinding (#872 review 2). + return None + body = {id(statement) for statement in frame.node.body} + for index, (kind, value, statement) in enumerate(bindings): + if kind != "value" or id(statement) not in body: + continue + reads_itself = any( + isinstance(node, ast.Name) and node.id == name + for node in ast.walk(value) + ) + position = (getattr(statement, "lineno", 0), getattr(statement, "col_offset", 0)) + earlier = [read for read in frame.loads.get(name, []) if read < position] + if reads_itself or earlier: + return None + return index + return None + + def _binding( + self, kind: str, value: ast.AST, statement: ast.AST, name: str, frame: _Frame + ) -> Any: + if kind == "value": + assert isinstance(value, ast.expr) + return self.value(value, frame) + if kind == "aug": + assert isinstance(statement, ast.AugAssign) + increment = self.value(statement.value, frame) + previous = self._previous(name, frame) + if isinstance(statement.op, ast.Add) and (_is_text(increment) or _is_text(previous)): + return _join([previous, increment]) + return _derived(previous, increment) + if kind in {"unpacked", "iter"}: + assert isinstance(value, ast.expr) + return _derived(self.value(value, frame)) + if kind == "def": + assert isinstance(value, ast.FunctionDef | ast.AsyncFunctionDef) + return Func(frame.module, value, frame) + if kind == "import": + assert isinstance(value, ast.alias) and isinstance(statement, ast.Import | ast.ImportFrom) + resolution = self.resolver.resolve_local_import(frame.module, statement, value, name) + dotted = _import_dotted(value, statement) + if dotted is not None and resolution.reason == MODULE_NOT_FOUND: + return Lib(dotted) + return self._resolved(frame.module, resolution) or Op(what=name) + return Op(what=name) + + def _previous(self, name: str, frame: _Frame) -> Any: + options = [frame.args[name]] if name in frame.args else [] + options.extend( + self._binding(kind, value, statement, name, frame) + for kind, value, statement in frame.local.get(name, []) + if kind != "aug" + ) + return _alt(options) if options else _UNKNOWN + + def _mutated(self, name: str, value: Any, frame: _Frame) -> Any: + """Apply the frame's item stores and in-place calls on ``name``.""" + + changes = frame.mutations.get(name, []) + if changes and isinstance(value, Request | Client): + return self._reconfigured(value, changes, frame) + options = value.options if isinstance(value, Alt) else (value,) + if not changes or not any(isinstance(option, Rec | Seq | Op) for option in options): + # A client, library object, function or string is not a container + # a call on it can add to. + return value + added: list[tuple[str, Any]] = [] + extra: list[Any] = [] + open_ = False + shared = False + for change in changes: + if isinstance(change, ast.Call): + method = change.func.attr if isinstance(change.func, ast.Attribute) else "" + arguments = [self.value(arg, frame) for arg in change.args] + arguments += [self.value(keyword.value, frame) for keyword in change.keywords] + if method in {"append", "extend", "insert", "add"}: + extra.extend(arguments) + elif method == "update" and len(arguments) == 1 and isinstance(arguments[0], Rec): + added.extend(arguments[0].fields) + open_ = open_ or arguments[0].open + shared = shared or arguments[0].shared + elif method == "setdefault" and arguments and isinstance(arguments[0], Lit): + added.append((str(arguments[0].value), _alt(arguments[1:] or [Lit(None)]))) + elif method in _PURE_METHODS or method in {"copy", "sort"}: + continue + else: + open_ = True + extra.extend(arguments) + elif isinstance(change, ast.Assign | ast.AugAssign | ast.AnnAssign): + targets = change.targets if isinstance(change, ast.Assign) else [change.target] + stored = self.value(change.value, frame) if change.value is not None else _UNKNOWN + for target in targets: + if not (isinstance(target, ast.Subscript) and isinstance(target.value, ast.Name) and target.value.id == name): + continue + key = self.value(target.slice, frame) + if isinstance(key, Lit) and isinstance(key.value, str) and not isinstance(change, ast.AugAssign): + added.append((key.value, stored)) + else: + open_ = True + extra.append(stored) + else: + open_ = True + if not added and not extra and not open_: + return value + if isinstance(value, Rec): + value = replace(value, shared=value.shared or shared) + fields = list(value.fields) + for key, item in added: + previous = next((old for name_, old in fields if name_ == key), None) + fields = [pair for pair in fields if pair[0] != key] + fields.append((key, _alt([previous, item]) if previous is not None else item)) + if extra: + fields.append(("**", _derived(*extra))) + return Rec(tuple(fields), value.open or open_, value.shared) + if isinstance(value, Alt) and any(isinstance(option, Rec) for option in value.options): + return Alt( + tuple( + self._apply_to(option, added, extra, open_) for option in value.options + ), + value.deciders, + ) + return _derived(value, *extra, *(item for _, item in added)) + + def _reconfigured(self, value: Request | Client, changes: list[ast.AST], frame: _Frame) -> Any: + """A request or client changed after it was built. + + Only a change to what it sends counts: its headers (`s.headers.update`, + `s.headers["X"] = v`, `req.add_header`), its auth, a request's data or + URL. Setting a request's method, or any attribute this read does not + know, leaves the method unread. A client's own calls (`s.post(...)`) + change nothing about it (#872 review 2). + """ + + headers = value.headers + is_request = isinstance(value, Request) + method = value.method if is_request else None + data = value.data if is_request else None + url = value.url if is_request else None + auth = None if is_request else value.auth + params = None if is_request else value.params + for change in changes: + if isinstance(change, ast.Call): + func = change.func + if not isinstance(func, ast.Attribute): + continue + arguments = [self.value(arg, frame) for arg in change.args] + if func.attr == "update" and _attribute_name(func.value) == "headers" and len(arguments) == 1: + update = arguments[0] + headers = ( + _merge_headers(headers or Rec(()), update) + if isinstance(update, Rec) + else _derived(headers, update) + ) + elif is_request and func.attr in {"add_header", "add_unredirected_header"} and len(arguments) == 2: + key = arguments[0] + headers = ( + _merge_headers(headers or Rec(()), Rec(((key.value, arguments[1]),))) + if isinstance(key, Lit) and isinstance(key.value, str) + else _derived(headers, *arguments) + ) + continue + if not isinstance(change, ast.Assign | ast.AnnAssign | ast.AugAssign): + continue + stored = self.value(change.value, frame) if change.value is not None else _UNKNOWN + targets = change.targets if isinstance(change, ast.Assign) else [change.target] + for target in targets: + if isinstance(target, ast.Subscript) and _attribute_name(target.value) == "headers": + key = self.value(target.slice, frame) + headers = ( + _merge_headers(headers or Rec(()), Rec(((key.value, stored),))) + if isinstance(key, Lit) and isinstance(key.value, str) + else _derived(headers, key, stored) + ) + continue + if not isinstance(target, ast.Attribute): + continue + if target.attr == "headers": + headers = stored + elif target.attr == "auth": + auth = stored + elif not is_request and target.attr == "params": + params = stored + elif is_request and target.attr == "data": + data = stored + elif is_request and target.attr in {"full_url", "selector", "host", "type"}: + url = _UNKNOWN + elif is_request: + method = _UNKNOWN + if is_request: + return Request(url, data, headers, method) + assert isinstance(value, Client) + return Client(value.library, value.base_url, headers, auth, params) + + def _apply_to(self, value: Any, added: list[tuple[str, Any]], extra: list[Any], open_: bool) -> Any: + if not isinstance(value, Rec): + return _derived(value, *extra, *(item for _, item in added)) + fields = [pair for pair in value.fields if pair[0] not in {key for key, _ in added}] + fields.extend(added) + if extra: + fields.append(("**", _derived(*extra))) + return Rec(tuple(fields), value.open or open_, value.shared) + + def module_name(self, module: PythonModule, name: str) -> Any: + key = (id(module), name) + if key in self.names: + return self.names[key] + if name not in module.bindings: + patched = ( + self.mutated.get(f"builtins.{name}") + or self.library.get(f"builtins.{name}") + or self.library.get("builtins.*") + or self.whole.get("builtins") + or self.whole.get("*") + ) + value: Any = ( + Op(what=f"builtin {name}, which {patched} changes") + if patched is not None and name in _BUILTIN_NAMES + else _Builtin(name) + if name in _BUILTIN_NAMES and not module.star_import + else Op(what=name) + ) + else: + resolution = self.resolver.resolve(module, name) + library = _module_library(module, name) + if library is not None and resolution.reason == MODULE_NOT_FOUND: + # An import no file in the read scope provides: a library. + value = Lib(library) + else: + value = self._resolved(module, resolution) or Op(what=name) + self.names[key] = value + return value + + def _resolved(self, module: PythonModule, resolution: Resolution) -> Any | None: + if resolution.module is not None and ( + resolution.definition is not None or resolution.value is not None + ): + name = resolution.steps[-1].get("name") if resolution.steps else None + where = self.mutated.get(str(name)) + for step in resolution.steps: + # `agent_config.METHOD` after `setattr(agent_config, key, …)`: + # every module the chain reads may have been changed under + # unseen names. A namespace change is keyed by the module it + # changes, so it is matched by module, never by a bound name + # (`from .settings import OWNER` binds a value). + where = where or self.whole.get(_module_stem(str(step.get("path", "")))) + # A namespace changed through a name the scan could not tie to one + # module: every module-scope value is unknown. + where = where or self.whole.get("*") + if where is not None: + # Changed from outside the tool's path: what it holds when the + # tool runs is not what its definition says (#872 review 6). + return Op(what=f"{name}, which {where} changes") + if resolution.definition is not None and resolution.module is not None: + return Func(resolution.module, resolution.definition) + if resolution.value is not None and resolution.module is not None: + return self.constant(resolution.module, resolution.value) + if resolution.reason is not None: + return Op(what=resolution.detail or resolution.reason) + return None + + def constant(self, module: PythonModule, value: ast.expr) -> Any: + if self.constants >= MAX_CONSTANT_DEPTH: + return Op(what=f"a value more than {MAX_CONSTANT_DEPTH} constants deep") + self.constants += 1 + try: + frame = self.module_frame(module) + result = self.value(value, frame) + if isinstance(result, Client | Request) and isinstance(value, ast.Call): + # A module-level client's hooks, auth and transport run on + # every request it sends (#872 review 4). + key = (id(module), id(value)) + if key not in self.configured: + self.configured.add(key) + self.handed_on(value, frame, _SENDS) + name = _assigned_name(module, value) + if name is not None: + result = self._module_changes(module, name, result) + if isinstance(result, Rec): + result = replace(result, shared=True) + finally: + self.constants -= 1 + return result + + def _module_changes(self, module: PythonModule, name: str, value: Any) -> Any: + """A module-level value changed by the module's own later statements. + + ``session.headers.update({...})`` or ``CONFIG["key"] = ...`` at the top + level changes what every function reads. + """ + + changes: list[ast.AST] = [] + for statement in module.tree.body: + if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + func = statement.value.func + if isinstance(func, ast.Attribute) and _root_name(func.value) == name: + changes.append(statement.value) + elif isinstance(statement, ast.Assign | ast.AugAssign | ast.AnnAssign): + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + if any( + isinstance(target, ast.Attribute | ast.Subscript) and _root_name(target) == name + for target in targets + ): + changes.append(statement) + if not changes: + return value + frame = self.module_frame(module) + if isinstance(value, Request | Client): + key = (id(module), hash(name)) + if isinstance(value, Client) and key not in self.configured: + self.configured.add(key) + for change in changes: + self._client_statement(change, frame) + return self._reconfigured(value, changes, frame) + stored = [ + self.value(change.value, frame) + for change in changes + if isinstance(change, ast.Assign | ast.AugAssign | ast.AnnAssign) and change.value is not None + ] + if isinstance(value, Rec): + return Rec(value.fields + (("**", _derived(*stored)),), True) + return _derived(value, *stored) + + def call_value(self, call: ast.Call, frame: _Frame) -> Any: + """What a call returns, read without recording anything it does.""" + + func = call.func + receiver: Any = None + dotted = self.dotted_callee(func, frame) + if dotted is not None: + callee: Any = dotted + elif isinstance(func, ast.Attribute): + receiver = self.value(func.value, frame) + if isinstance(receiver, Lib): + callee = Lib(f"{receiver.dotted}.{func.attr}") + elif isinstance(receiver, Client): + return Op(what="an HTTP response", inert=True) + else: + return self._method_value(receiver, func.attr, call, frame) + else: + callee = self.value(func, frame) + arguments = [self.value(arg, frame) for arg in call.args] + keywords = { + keyword.arg: self.value(keyword.value, frame) + for keyword in call.keywords + if keyword.arg is not None + } + if isinstance(callee, Lib): + dotted = callee.dotted + library, _, verb = dotted.rpartition(".") + if dotted in _URLOPEN or ( + library in _HTTP_LIBRARIES and (verb in _VERBS or verb in {"request", "stream"}) + ): + # What the service answers is not made from the request. + return Op(what="an HTTP response", inert=True) + if dotted in _ENV_READERS: + name = arguments[0] if arguments else keywords.get("key") + default = arguments[1] if len(arguments) > 1 else keywords.get("default") + if isinstance(name, Lit) and isinstance(name.value, str): + read = Op(envs=frozenset({name.value}), exact=True, data=True) + if default is not None and default != Lit(None): + return _alt([read, default]) + return read + return _derived(*arguments, result=True, data=True) + if dotted in _CLIENTS: + return Client( + _CLIENTS[dotted], + keywords.get("base_url"), + keywords.get("headers"), + keywords.get("auth"), + keywords.get("params"), + ) + if dotted in _REQUEST_CLASSES: + return Request( + arguments[0] if arguments else keywords.get("url", _UNKNOWN), + arguments[1] if len(arguments) > 1 else keywords.get("data"), + arguments[2] if len(arguments) > 2 else keywords.get("headers"), + arguments[5] if len(arguments) > 5 else keywords.get("method"), + ) + if dotted == "json.dumps" and arguments: + return arguments[0] + if dotted == "logging.getLogger": + return Lib("logging.Logger") + if dotted == "typing.cast" and len(arguments) == 2: + return arguments[1] + if dotted in {"urllib.parse.urljoin", "os.path.join"} and len(arguments) == 2: + return _join([arguments[0], Lit("/") if dotted == "os.path.join" else Lit(""), arguments[1]]) + if dotted in _BASIC_AUTH: + return _derived(*arguments, *keywords.values()) + if dotted in _TRANSPORTS and all(_is_data(value) for value in [*arguments, *keywords.values()]): + # A retrying transport or adapter configured with plain values. + return Op(what=dotted, inert=True) + if dotted in _PURE_FUNCTIONS: + inputs = [*arguments, *keywords.values()] + text = ( + dotted in _TEXT_FUNCTIONS or dotted.startswith(_TEXT_FUNCTION_PREFIXES) + ) and not (set(keywords) & {"cls", "default", "object_hook", "object_pairs_hook"}) + object_ = dotted in _OBJECT_FUNCTIONS + return _derived( + *inputs, + result=True, + # `asyncio.sleep(0, result=client)` hands back the client. + data=text or (not object_ and all(_is_data(value) for value in inputs)), + inert=True, + # `base64.b64encode(b"user:pass")` is still written in the + # source; `Path("audit.log")` is an object, not text, so its + # `replace` moves a file (#872 review 3). + literal=not object_ + and bool(inputs) + and all(_literal_only(value) for value in inputs), + ) + return _derived(*arguments, *keywords.values(), result=True) + if isinstance(callee, _Builtin): + if callee.name in {"str", "int", "float"} and len(arguments) == 1: + inner = arguments[0] + if isinstance(inner, Op | Lit | Tpl): + return inner if callee.name == "str" or not isinstance(inner, Tpl) else _derived(inner) + if callee.name == "dict" and not arguments: + return Rec(tuple(keywords.items())) + if callee.name in _PURE_BUILTINS: + return _derived(*arguments, *keywords.values()) + return _derived(*arguments, *keywords.values(), result=True) + if isinstance(callee, Func): + return self.returned(callee, call, frame) + return _derived(*arguments, *keywords.values(), result=True) + + def _method_value(self, receiver: Any, method: str, call: ast.Call, frame: _Frame) -> Any: + arguments = [self.value(arg, frame) for arg in call.args] + if method in {"get", "pop", "setdefault"} and isinstance(receiver, Rec) and receiver.shared: + return Op(what=f"{_spelling(call.func) or 'a module-level dict'}(…), a module-level dict") + if method == "get" and isinstance(receiver, Rec) and arguments: + key = arguments[0] + if isinstance(key, Lit) and isinstance(key.value, str): + found = receiver.get(key.value) + default = arguments[1] if len(arguments) > 1 else Lit(None) + if found is None: + return default if not receiver.open else _derived(receiver, default) + return _alt([found, default]) + if method == "copy" and isinstance(receiver, Rec): + return receiver + if method == "format" and isinstance(receiver, Lit) and isinstance(receiver.value, str): + keywords = { + keyword.arg: self.value(keyword.value, frame) + for keyword in call.keywords + if keyword.arg is not None + } + formatted = _format(receiver.value, arguments, keywords) + if formatted is not None: + return formatted + if method == "join" and isinstance(receiver, Lit) and isinstance(receiver.value, str): + if len(arguments) == 1 and isinstance(arguments[0], Seq): + parts: list[Any] = [] + for index, item in enumerate(arguments[0].items): + if index: + parts.append(receiver) + parts.append(item) + return _join(parts) + if method in {"strip", "rstrip", "lstrip"} and isinstance(receiver, Op) and receiver.exact: + return receiver + # What a method returns is plain data only when what it reads is: a dict + # of clients hands back clients, and a default argument can be anything + # (#872 review 2). + arguments_data = all(_is_data(argument) for argument in arguments) + if (method in _PURE_METHODS or method in _STRING_METHODS) and _is_data(receiver): + return _derived( + receiver, *arguments, result=True, data=arguments_data, inert=arguments_data + ) + if method in _PURE_METHODS and _is_quiet(receiver): + object_ = method in _OBJECT_METHODS + return _derived( + receiver, + *arguments, + result=True, + data=arguments_data and not object_, + inert=arguments_data, + ) + return _derived(receiver, *arguments, result=True) + + def returned(self, func: Func, call: ast.Call, frame: _Frame) -> Any: + if frame.depth >= MAX_DEPTH or id(func.node) in self.stack: + return Op(what=f"the value {func.node.name} returns") + args = self.bind(func, call, frame) + key = (func, tuple(sorted(args.items(), key=repr))) + if key in self.returns: + return self.returns[key] + self.returns[key] = Op(what=f"the value {func.node.name} returns") + inner = self.frame(func, args, via=frame.via, depth=frame.depth + 1) + self.stack.append(id(func.node)) + try: + values = [ + self.value(node.value, inner) + for node in _returns(func.node) + if node.value is not None + ] + finally: + self.stack.pop() + result = _alt(values) if values else Lit(None) + self.returns[key] = result + return result + + +@dataclass(frozen=True) +class _Builtin: + name: str + + +class _Arguments: + """A call's arguments, taken by position or keyword at most once each.""" + + def __init__(self, call: ast.Call) -> None: + self.positional = [arg for arg in call.args if not isinstance(arg, ast.Starred)] + self.keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg} + self.spread = len(self.positional) != len(call.args) or any( + keyword.arg is None for keyword in call.keywords + ) + + def take(self, index: int | None, name: str) -> ast.expr | None: + if name in self.keywords: + return self.keywords[name] + if index is not None and index < len(self.positional): + return self.positional[index] + return None + + +def _described(value: Any) -> str | None: + """What an unread receiver is, when the read knows.""" + + if isinstance(value, Op): + return value.what + if isinstance(value, Alt): + for option in value.options: + if not _is_quiet(option): + return _described(option) + return None + + +def _assigned_name(module: PythonModule, value: ast.expr) -> str | None: + """The name a module-level ``name = value`` statement binds ``value`` to.""" + + for statement in module.tree.body: + if ( + isinstance(statement, ast.Assign | ast.AnnAssign) + and statement.value is value + ): + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + if len(targets) == 1 and isinstance(targets[0], ast.Name): + return targets[0].id + return None + + +def _name_words(name: str) -> list[str]: + return [word.lower() for word in re.findall(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+", name)] + + +def _secret_name(name: str) -> bool: + """A field, header or query name that carries a secret, by whole word.""" + + if is_credential_key(name): + return True + words = _name_words(name) + joined = "".join(words) + if joined in _SECRET_WORDS or any(word in _SECRET_WORDS for word in words): + return True + return bool(words) and words[-1] == "key" and (len(words) == 1 or words[-2] in _KEY_QUALIFIERS) + + +def _inert_library(dotted: str) -> bool: + """A library call with no effect outside the process, or a constructor.""" + + return ( + dotted in _PURE_FUNCTIONS + or dotted in _TRANSPORTS + or dotted in _CLIENTS + or dotted in _REQUEST_CLASSES + or dotted in _BASIC_AUTH + or dotted.startswith("logging.") + ) + + +def _returns(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.Return]: + found: list[ast.Return] = [] + stack: list[ast.AST] = list(node.body) + while stack: + item = stack.pop() + if isinstance(item, ast.Return): + found.append(item) + if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): + continue + stack.extend(ast.iter_child_nodes(item)) + return sorted(found, key=lambda item: (item.lineno, item.col_offset)) + + +def _spelling(node: ast.AST) -> str: + spelling = reference_spelling(node) + if spelling is not None: + return spelling + if isinstance(node, ast.Attribute): + if isinstance(node.value, ast.Call): + inner = _spelling(node.value.func) + return f"{inner or '…'}(…).{node.attr}" + return f"….{node.attr}" + return "" + + +def _format(template: str, arguments: list[Any], keywords: dict[str, Any]) -> Any | None: + """``"{}/{name}".format(...)`` as a template; None when it cannot be read.""" + + try: + parsed = list(string.Formatter().parse(template)) + except ValueError: + return None + parts: list[Any] = [] + automatic = 0 + for literal, field_name, spec, conversion in parsed: + if literal: + parts.append(Lit(literal)) + if field_name is None: + continue + if spec or conversion: + return None + if field_name == "": + if automatic >= len(arguments): + return None + parts.append(arguments[automatic]) + automatic += 1 + elif field_name.isdigit() and int(field_name) < len(arguments): + parts.append(arguments[int(field_name)]) + elif field_name in keywords: + parts.append(keywords[field_name]) + else: + return None + return _join(parts) + + +def _absolute(url: Any) -> bool: + text = _leading_literal(url) + return "://" in text + + +def _leading_literal(value: Any) -> str: + if isinstance(value, Lit) and isinstance(value.value, str): + return value.value + if isinstance(value, Tpl) and value.parts and isinstance(value.parts[0], Lit): + return str(value.parts[0].value) + return "" + + +def _graphql_endpoint(url: Any) -> bool: + """Whether a URL names a GraphQL endpoint: in its literal text or its variable.""" + + names = _literal_text(url) + params, envs = _sources(url) + return "graphql" in f"{names} {' '.join(envs)}".lower() + + +def _literal_text(value: Any) -> str: + if isinstance(value, Lit) and isinstance(value.value, str): + return value.value + if isinstance(value, Tpl): + return "".join(str(part.value) for part in value.parts if isinstance(part, Lit)) + if isinstance(value, Alt): + return " ".join(_literal_text(option) for option in value.options) + return "" + + +def _merge_headers(defaults: Any, given: Any) -> Any: + if given is None: + return defaults + if isinstance(defaults, Rec) and isinstance(given, Rec): + keys = {key for key, _ in given.fields} + return Rec( + tuple([pair for pair in defaults.fields if pair[0] not in keys] + list(given.fields)), + defaults.open or given.open, + ) + return _derived(defaults, given) + + +def _methods(value: Any) -> list[str]: + options = value.options if isinstance(value, Alt) else (value,) + methods: list[str] = [] + for option in options: + if not (isinstance(option, Lit) and isinstance(option.value, str)): + return [] + method = option.value.upper() + if method not in _METHOD_EFFECT: + return [] + if method not in methods: + methods.append(method) + return sorted(methods) + + +def _call_effect(methods: list[str], graphql: str | None) -> str | None: + if not methods: + return None + if graphql == "query": + effects = ["read" if method == "POST" else _METHOD_EFFECT[method] for method in methods] + elif graphql == "mutation": + effects = ["write" if method in {"POST", "GET"} else _METHOD_EFFECT[method] for method in methods] + elif graphql == "unknown" and "POST" in methods: + return None + else: + effects = [_METHOD_EFFECT[method] for method in methods] + for effect in ("destructive", "write", "read"): + if effect in effects: + return effect + return None + + +def _graphql_operations(document: str) -> set[str] | None: + """The operation kinds a GraphQL document defines; None when unreadable.""" + + kinds: set[str] = set() + depth = 0 + pending: str | None = None + index, size = 0, len(document) + while index < size: + char = document[index] + if char == "#": + newline = document.find("\n", index) + index = size if newline == -1 else newline + 1 + continue + if char == '"': + if document.startswith('"""', index): + end = index + 3 + while True: + end = document.find('"""', end) + if end == -1: + return None + if document[end - 1] == "\\": + # `\"""` is an escaped quote inside a block string. + end += 3 + continue + break + index = end + 3 + continue + index += 1 + while index < size and document[index] != '"': + index += 2 if document[index] == "\\" else 1 + index += 1 + continue + if char == "{": + if depth == 0: + if pending is None: + kinds.add("query") + elif pending != "fragment": + kinds.add(pending) + pending = None + depth += 1 + elif char == "}": + depth -= 1 + if depth < 0: + return None + elif (char.isalpha() or char == "_") and depth == 0: + end = index + while end < size and (document[end].isalnum() or document[end] == "_"): + end += 1 + word = document[index:end] + if pending is None: + if word not in {"query", "mutation", "subscription", "fragment"}: + return None + pending = word + index = end + continue + index += 1 + if depth != 0 or pending is not None or not kinds: + return None + return kinds + + +# -- rendering ----------------------------------------------------------------- + + +def _part(value: Any) -> str: + if isinstance(value, Lit): + return str(value.value) + if isinstance(value, Op): + if value.exact and len(value.params) == 1 and not value.envs: + return "{" + next(iter(value.params)) + "}" + if value.exact and len(value.envs) == 1 and not value.params: + return "{env " + next(iter(value.envs)) + "}" + return "{…}" + if isinstance(value, Alt): + labels = [_label(option) or "…" for option in value.options] + if any(label != "…" for label in labels) and len(labels) <= 4: + return "{" + "|".join(sorted(set(labels))) + "}" + return "{…}" + if isinstance(value, Tpl): + return "".join(_part(part) for part in value.parts) + return "{…}" + + +def _label(value: Any) -> str | None: + """One option of a choice, as a URL placeholder shows it, or None.""" + + if isinstance(value, Lit): + shown = _shown(value) + return None if shown is None else str(shown) + if isinstance(value, Op) and value.exact: + if len(value.params) == 1 and not value.envs: + return next(iter(value.params)) + if len(value.envs) == 1 and not value.params: + return "env " + next(iter(value.envs)) + return None + + +def _redact_url(rendered: str) -> str: + """Withhold what in a URL could be a secret: a query value, a token in the path. + + A query value prints only when it is a short lowercase word or a number; + a path piece is withheld when it looks like a key (``hooks.slack.com/ + services/T…/B…/``, a bot token after ``:``). Over-withholding costs + an ID a reviewer could have read; under-withholding publishes a secret. + """ + + rendered, _ = redact_url_credentials(rendered) + head, question, query = rendered.partition("?") + scheme, separator, rest = head.partition("://") + # The host is a name, not a secret; only what follows it is checked. + if separator: + host, slash, path = rest.partition("/") + else: + host, slash, path = "", "", head + capability = host in _CAPABILITY_HOSTS or host.endswith(_CAPABILITY_HOST_SUFFIXES) + if host.endswith(_CAPABILITY_HOST_SUFFIXES): + label, dot, domain = host.partition(".") + host = f"{_REDACTED}{dot}{domain}" + pieces: list[str] = [] + segments = path.split("/") + for position, segment in enumerate(segments): + parts = [] + for piece in segment.split(":"): + plain = not piece or piece.startswith("{") or piece.startswith("[REDACTED") + # On a webhook or bot URL every piece is the credential, except its + # fixed words and a final method name (`sendMessage`). + method_name = position == len(segments) - 1 and bool(_METHOD_NAME.fullmatch(piece)) + if not plain and ( + (capability and piece not in _CAPABILITY_WORDS and not method_name) + or _secret_piece(piece) + ): + piece = _REDACTED + parts.append(piece) + pieces.append(":".join(parts)) + rendered = "/".join(pieces) + if separator: + rendered = f"{scheme}{separator}{host}{slash}{rendered}" + if question: + pairs = [] + for pair in query.split("&"): + key, equals, value = pair.partition("=") + if not equals: + # A bare token: `?0123abcd…`. + if not (key.startswith("{") or _PLAIN_QUERY_VALUE.fullmatch(key)): + key = _REDACTED + elif ( + not value.startswith("{") + and not value.startswith("[REDACTED") + and (_secret_name(key) or not _PLAIN_QUERY_VALUE.fullmatch(value)) + ): + value = _REDACTED + pairs.append(f"{key}{equals}{value}") + rendered += "?" + "&".join(pairs) + return redact_text(rendered) or rendered + + +def _secret_piece(piece: str) -> bool: + """A path piece shaped like a key: a long run, or letters and digits mixed. + + Checked per run between `-`, `.` and `~`, so `gemini-1.5-flash` stays a + name while `hunter2`, a commit SHA or a UUID's runs are withheld. + """ + + for run in re.split(r"[-.~]", piece): + letters = any(char.isalpha() for char in run) + digits = sum(char.isdigit() for char in run) + # Two digits or more: `us-central1` and `v1beta` are names, `a1b2c3d4` is not. + if len(run) >= 20 or (len(run) >= 7 and letters and digits >= 2): + return True + return False + + +def _records(value: Any) -> list[Rec] | None: + """The dicts a value may be, without the absent ones; None when not all dicts.""" + + records: list[Rec] = [] + for option in value.options if isinstance(value, Alt) else (value,): + if option is None or (isinstance(option, Lit) and option.value is None): + continue + if not isinstance(option, Rec): + return None + if option.fields or option.open: + records.append(option) + return records + + +def _render_url(url: Any, params: Any) -> str: + rendered = _part(url) if isinstance(url, Lit | Tpl | Op | Alt) else "{…}" + records = _records(params) + if records is None or len(records) > 1 or (records and records[0].open): + rendered += ("&" if "?" in rendered else "?") + "{…}" + elif records: + query = "&".join( + f"{key}={_part(value) if isinstance(value, Lit | Tpl | Op | Alt) else '{…}'}" + for key, value in records[0].fields + if key != "**" + ) + rendered += ("&" if "?" in rendered else "?") + query + return _redact_url(rendered) + + +def _fields(payload: Any) -> list[dict[str, Any]]: + if payload is None or (isinstance(payload, Lit) and payload.value is None): + return [] + if not isinstance(payload, Rec): + params, envs = _sources(payload) + entry: dict[str, Any] = {"field": None} + _describe(entry, payload, params, envs, secret=False) + return [entry] + fields: list[dict[str, Any]] = [] + for key, value in payload.fields: + entry = {"field": None if key == "**" else key} + params, envs = _sources(value) + _describe(entry, value, params, envs, secret=key != "**" and _secret_name(key)) + fields.append(entry) + if payload.open and not any(item["field"] is None for item in fields): + fields.append({"field": None, "computed": True}) + return fields + + +def _describe( + entry: dict[str, Any], + value: Any, + params: frozenset[str], + envs: frozenset[str], + *, + secret: bool, +) -> None: + if isinstance(value, Lit): + if secret: + entry["literal"] = True + else: + shown = _shown(value) + if shown is None: + entry["literal"] = True + else: + entry["value"] = shown + return + if isinstance(value, Alt) and all(isinstance(option, Lit) for option in value.options): + shown_values = [_shown(option) for option in value.options] + if secret or any(item is None for item in shown_values): + entry["literal"] = True + else: + entry["values"] = sorted({str(item) for item in shown_values}) + if value.deciders: + entry["decided_by"] = sorted(value.deciders) + return + if params: + entry["from"] = sorted(params) + if envs: + entry["env"] = sorted(envs) + if not params and not envs: + if _literal_only(value): + entry["literal"] = True + else: + entry["computed"] = True + + +def _shown(value: Lit) -> Any | None: + """A literal plain enough to print: a number, a boolean, or a word without digits.""" + + item = value.value + if isinstance(item, bool) or item is None: + return item + if isinstance(item, int | float): + return item if abs(item) < 10**6 else None + if not isinstance(item, str) or not _PLAIN_FIELD_VALUE.fullmatch(item): + return None + if looks_like_secret_value(item) or redact_text(item) != item: + return None + return item + + +def _credentials( + headers: Any, auth: Any, params: Any, url: Any, payload: Any = None +) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + + def add(kind: str, name: str | None, value: Any) -> None: + params_, envs = _sources(value) + entry: dict[str, Any] = {kind: name} + if envs: + entry["env"] = sorted(envs) + if params_: + entry["from"] = sorted(params_) + if _literal_fallback(value) or (not envs and not params_ and _literal_only(value)): + entry["literal"] = True + elif not envs and not params_: + entry["computed"] = True + if entry not in found: + found.append(entry) + + header_records = _records(headers) + if header_records is None: + if _sources(headers)[1]: + add("header", None, headers) + else: + for record in header_records: + for key, value in record.fields: + if key != "**" and _credential_header(key): + add("header", key, value) + if record.open: + add("header", None, Rec(tuple(pair for pair in record.fields if pair[0] == "**"))) + if auth is not None and not (isinstance(auth, Lit) and auth.value is None): + add("auth", None, auth) + for record in _records(params) or []: + for key, value in record.fields: + if key != "**" and _secret_name(key): + add("query", key, value) + for record in _records(payload) or []: + for key, value in record.fields: + if key != "**" and _secret_name(key) and value != Lit(None): + add("field", key, value) + if isinstance(url, Tpl): + text = "" + for part in url.parts: + if isinstance(part, Lit): + text += str(part.value) + continue + key = text.rsplit("?", 1)[-1].rsplit("&", 1)[-1] + if "?" in text and key.endswith("=") and _secret_name(key[:-1]): + add("query", key[:-1], part) + text += "{}" + # A value spliced into the URL's userinfo: `https://user:{env PW}@host`. + rendered = "".join( + str(part.value) if isinstance(part, Lit) else "\0" for part in url.parts + ) + scheme, separator, rest = rendered.partition("://") + authority = rest.split("/", 1)[0].split("?", 1)[0] + if separator and "@" in authority and "\0" in authority.rpartition("@")[0]: + count = authority.rpartition("@")[0].count("\0") + scheme.count("\0") + spliced = [part for part in url.parts if not isinstance(part, Lit)][:count] + add("userinfo", None, _derived(*spliced)) + return found + + +def _credential_header(name: str) -> bool: + return _secret_name(name) + + +def _literal_fallback(value: Any) -> bool: + """Whether a literal is one of the values: ``os.getenv("KEY", "sk-test")``.""" + + if isinstance(value, Alt): + return any( + (isinstance(option, Lit) and option.value not in (None, "")) + or _literal_fallback(option) + for option in value.options + ) + if isinstance(value, Tpl): + return any(_literal_fallback(part) for part in value.parts if not isinstance(part, Lit)) + return False + + +def _model_supplied(method: Any, url: Any, named: dict[str, Any], payload: Any) -> list[dict[str, str]]: + into: dict[str, list[str]] = {} + + def note(value: Any, place: str) -> None: + for param in sorted(_sources(value)[0]): + places = into.setdefault(param, []) + if place not in places: + places.append(place) + + note(method, "method") + note(url, "url") + params = _records(named.get("params")) + if params is None: + note(named.get("params"), "query") + for record in params or []: + for key, value in record.fields: + note(value, f"query {key}" if key != "**" else "query") + if isinstance(payload, Rec): + for key, value in payload.fields: + note(value, f"field {key}" if key != "**" else "body") + elif payload is not None: + note(payload, "body") + if named.get("headers") is not None: + note(named["headers"], "headers") + return [ + {"param": param, "into": place} + for param, places in sorted(into.items()) + for place in places + ] + + +# -- entry point --------------------------------------------------------------- + + +def read_tool_reach( + resolver: ImportResolver, + module: PythonModule, + function: ast.FunctionDef | ast.AsyncFunctionDef, + *, + model_params: frozenset[str], + enclosing: ast.FunctionDef | ast.AsyncFunctionDef | None = None, + is_test: Callable[[str], bool] | None = None, +) -> dict[str, Any]: + """What one tool function reaches, as JSON-safe evidence. + + Every patch to the HTTP libraries anywhere in the resolver's scope, outside + the files ``is_test`` names, is a limit on a tool that sends + (:func:`scope_patches`): it changes what every request does. + + ``model_params`` are the parameters the model supplies; any other + parameter (a framework context, ``self``) is named but not model input. + ``enclosing`` is the function that defines ``function``, when it is + nested: its parameters are values this read cannot see. + """ + + test_rule = is_test or _looks_like_test + names, whole = scope_mutations(resolver.scope_root, test_rule) + reach = _Reach(resolver, names, whole, scope_library_patches(resolver.scope_root, test_rule)) + outer: _Frame | None = None + if enclosing is not None: + outer_func = Func(module, enclosing) + outer = reach.frame( + outer_func, + { + param.arg: Op(what=f"parameter {param.arg} of {enclosing.name}") + for param in [ + *enclosing.args.posonlyargs, + *enclosing.args.args, + *enclosing.args.kwonlyargs, + *(item for item in (enclosing.args.vararg, enclosing.args.kwarg) if item), + ] + }, + via=(), + depth=0, + ) + func = Func(module, function, outer) + args: dict[str, Any] = {} + arguments = function.args + for param in [ + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + *(item for item in (arguments.vararg, arguments.kwarg) if item), + ]: + if param.arg in model_params: + args[param.arg] = Op( + params=frozenset({param.arg}), + exact=True, + data=_json_annotation(param.annotation), + ) + else: + args[param.arg] = Op(what=f"parameter {param.arg}, not supplied by the model") + reach.walk(reach.frame(func, args, via=(), depth=0)) + if reach.calls or reach.dropped: + patches = scope_patches(resolver.scope_root, test_rule) + named = {patch["at"] for patch in patches} + kept_stack = sorted( + key[5:] for key in reach.library if key.startswith("kept:") and key[5:].split(".")[0] in _HTTP_STACK + ) + holders = _holders(reach.library) + for key, where in sorted(reach.library.items()): + if not kept_stack or not key.startswith("unseen:") or where in named: + continue + if _held_store(key[7:], holders): + # `self.http = requests` … `self.http.get = …`, or + # `h.module.Session.prepare_request = …`: the HTTP stack kept on + # an object, and a store through it (#872 reviews 18-20). + named.add(where) + patches.append( + { + "at": where, + "why": f"stores {key[7:].lstrip('^')} on an object that may hold {kept_stack[0]}, " + "which may change every request; not read", + } + ) + for key, where in reach.library.items(): + # `sys.modules["requests"].get = send`, `r = requests; + # r.Session.request = …`: a patch to the HTTP stack however the + # module was reached (#872 review 14). + head = key.split(".")[0] + if key.startswith("class:") and where not in named: + # `request.__class__.method = "DELETE"`: a class reached + # through its instance or its bases may be the stack's. + named.add(where) + patches.append( + { + "at": where, + "why": f"replaces {key[6:]} on a class reached through an object or its bases, " + "which may change every request; not read", + } + ) + elif (head in _HTTP_STACK or (head == "*" and key.rsplit(".", 1)[-1] in _STACK_NAMES)) and where not in named: + # `M = import_module(name); M.get = …`: a module chosen at run + # time may be the stack. + named.add(where) + patches.append({"at": where, "why": f"replaces {key}, which changes every request; not read"}) + for patch in patches: + reach.limit_count += 1 + if patch not in reach.limits and len(reach.limits) < MAX_LIMITS: + reach.limits.append(dict(patch)) + return summarize( + reach.calls + reach.dropped, + reach.limits, + reach.limit_count, + reach.truncated, + module, + function, + listed=len(reach.calls), + ) + + +#: Libraries whose behaviour a patch can change for every request. +_HTTP_STACK = ("aiohttp", "http", "httpx", "requests", "socket", "ssl", "urllib", "urllib3") +#: Names an HTTP stack module or class sends through. +_STACK_NAMES = frozenset( + { + "AsyncClient", "Client", "ClientSession", "HTTPAdapter", "OpenerDirector", "PoolManager", "Request", + "Session", "delete", "get", "head", "method", "options", "patch", "post", "put", "request", "send", + "stream", "urlopen", + # What a request passes through on its way (#872 review 19). An + # instance's everyday attributes (`headers`, `data`) are not: a path + # through a holder the scan saw is matched by the holder's name. + "HTTPConnection", "HTTPSConnection", "HTTPTransport", "AsyncHTTPTransport", "_request", + "_send_single_request", "build_request", "endheaders", "full_url", "get_method", + "handle_async_request", "handle_request", "merge_environment_settings", "prepare_request", + "putheader", "putrequest", "rebuild_auth", "rebuild_method", "request_encode_body", + "request_encode_url", "resolve_redirects", "send_request", + } +) +#: Classes a request passes through, matched on any attribute a store goes +#: through (#872 review 20). +_STACK_CLASSES = frozenset( + {name for name in _STACK_NAMES if name[:1].isupper()} + | { + "AbstractHTTPHandler", "BaseHandler", "ClientRequest", "HTTPConnectionPool", "HTTPHandler", + "HTTPSConnectionPool", "HTTPSHandler", "OpenerDirector", "PreparedRequest", "TCPConnector", + } +) +#: The stack's exceptions not named like one (`httpx.StreamClosed`, +#: `http.client.RemoteDisconnected`). +_STACK_EXCEPTIONS = frozenset( + { + "BadStatusLine", "CannotSendHeader", "CannotSendRequest", "CookieConflict", "ImproperConnectionState", + "IncompleteRead", "InvalidURL", "LineTooLong", "NotConnected", "RemoteDisconnected", "RequestNotRead", + "ResponseNotRead", "ResponseNotReady", "StreamClosed", "StreamConsumed", "TooManyRedirects", + "UnimplementedFileMode", "UnknownProtocol", "UnknownTransferEncoding", "UnsupportedProtocol", + } +) +#: The stack's modules a request passes through, besides the top-level ones. +_STACK_MODULES = frozenset( + { + "aiohttp.client", "aiohttp.client_reqrep", "aiohttp.connector", "http.client", "http.cookiejar", + "httpx._api", "httpx._client", "httpx._transports", "httpx._transports.default", "requests.adapters", + "requests.api", "requests.models", "requests.sessions", "urllib.request", "urllib3.connection", + "urllib3.connectionpool", "urllib3.poolmanager", "urllib3.util", "urllib3.util.connection", + "urllib3.util.ssl_", + } +) +#: Calls that install or instrument something in the HTTP stack. +_STACK_INSTALLERS = ("install_cache", "install_opener", "instrument", "monkey", "patch_all") + + +def http_patches(tree: ast.Module, ref: str) -> list[dict[str, str]]: + """Places in one module that patch an HTTP library, anywhere in the file. + + A store into an attribute of `requests`, `httpx`, `urllib3`, `http`, + `socket` and the like, a `setattr` on one, or a call that installs or + instruments the stack (`install_opener`, `patch_all`, + `RequestsInstrumentor().instrument()`) changes what every request in the + process does. It is read wherever it is in the scope, not only on the path + a tool takes (#872 review 5). + """ + + stack: dict[str, str] = {} + #: Other imported names: `ddtrace.patch(...)` patches, `requests.patch(...)` + #: and `session.patch(...)` send. + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + local = alias.asname or alias.name.split(".", 1)[0] + if alias.name.split(".", 1)[0] in _HTTP_STACK: + stack[local] = alias.name + else: + modules.add(local) + elif isinstance(node, ast.ImportFrom) and node.module and not node.level: + if node.module.split(".", 1)[0] in _HTTP_STACK: + for alias in node.names: + stack[alias.asname or alias.name] = f"{node.module}.{alias.name}" + else: + modules.update(alias.asname or alias.name for alias in node.names) + found: list[dict[str, str]] = [] + + def add(node: ast.AST, why: str) -> None: + entry = {"at": f"{ref}:{getattr(node, 'lineno', 0)}", "why": why} + if entry not in found: + found.append(entry) + + for node in ast.walk(tree): + targets: list[ast.expr] = [] + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, ast.AugAssign | ast.AnnAssign): + targets = [node.target] + stored = getattr(node, "value", None) + for target in targets: + # `requests.adapters.DEFAULT_RETRIES = 3` tunes the stack; a + # function or object stored there, or a class's default method, + # replaces what it does. + if ( + isinstance(target, ast.Attribute) + and _root_name(target) in stack + and not _setting(_spelling(target).split("."), stored) + ): + add(node, f"patches {_spelling(target)}, which changes every request; not read") + if isinstance(node, ast.Call): + spelling = _spelling(node.func) + if ( + spelling == "setattr" + and node.args + and _root_name(node.args[0]) in stack + ): + add(node, f"patches {_spelling(node.args[0])} with setattr, which changes every request; not read") + elif isinstance(node.func, ast.Attribute | ast.Name) and ( + any(word in spelling for word in _STACK_INSTALLERS) + or ( + isinstance(node.func, ast.Attribute) + and (node.func.attr == "patch" or node.func.attr.startswith("patch_")) + and _root_name(node.func) in modules + ) + ): + add(node, f"calls {spelling or 'an installer'}, which may change every request; not read") + return found + + +#: Constructors whose result is a new object the function owns. +_FRESH_CALLS = frozenset( + {"AsyncClient", "Client", "ClientSession", "Request", "Session", "copy", "deepcopy", "dict", "list", "set"} +) + + +def _fresh(value: ast.AST | None) -> bool: + """A value that is a new object: a display, a comprehension, a constructor.""" + + if isinstance( + value, ast.Dict | ast.List | ast.Set | ast.Tuple | ast.ListComp | ast.DictComp | ast.SetComp + ): + return True + if isinstance(value, ast.Call): + func = value.func + name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else "" + return name in _FRESH_CALLS + return False + + +def _stored_name(target: ast.AST) -> str | None: + """The name a store writes under: `x.METHOD`, `x["method"]`, else the container's.""" + + if isinstance(target, ast.Attribute): + return target.attr + if isinstance(target, ast.Subscript): + key = target.slice + if isinstance(key, ast.Constant) and isinstance(key.value, str): + return key.value + return _stored_name(target.value) if isinstance(target.value, ast.Attribute | ast.Subscript) else ( + target.value.id if isinstance(target.value, ast.Name) else None + ) + if isinstance(target, ast.Name): + return target.id + return None + + +@functools.lru_cache(maxsize=256) +def _closure_changes_cached(node: ast.AST) -> tuple[tuple[str, int], ...]: + found: dict[str, int] = {} + for inner in ast.walk(node): + if inner is node or not isinstance(inner, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda): + continue + arguments = inner.args + own = { + argument.arg + for argument in [ + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + *(item for item in (arguments.vararg, arguments.kwarg) if item), + ] + } + declared: set[str] = set() + for child in ast.walk(inner): + if isinstance(child, ast.Nonlocal): + declared.update(child.names) + for name in child.names: + found.setdefault(name, child.lineno) + elif isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store): + own.add(child.id) + own -= declared + for child in ast.walk(inner): + changed: ast.AST | None = None + if isinstance(child, ast.Assign | ast.AugAssign | ast.AnnAssign | ast.Delete): + targets = child.targets if isinstance(child, ast.Assign | ast.Delete) else [child.target] + for target in targets: + if isinstance(target, ast.Attribute | ast.Subscript): + changed = target.value + elif isinstance(child, ast.Call): + func = child.func + if isinstance(func, ast.Attribute) and func.attr in ( + _CONTAINER_METHODS | _DICT_CHANGES | _STORING_METHODS + ): + changed = func.value + elif _spelling(func) in {"setattr", "delattr", "setitem", "operator.setitem"} and child.args: + changed = child.args[0] + root = None if changed is None else _root_name(changed) + if root is not None and root not in own: + found.setdefault(root, child.lineno) # type: ignore[attr-defined] + return tuple(sorted(found.items())) + + +def _closure_changes(node: ast.AST | None) -> dict[str, int]: + """Names of `node`'s scope a function nested in it changes, with the line. + + A `nonlocal` rebinding, or a store into what the name holds (an item, an + attribute, a container method), made by a function nested in `node` + changes state every closure over it shares (#872 review 11). + """ + return {} if node is None else dict(_closure_changes_cached(node)) + + +def _module_stem(ref: str) -> str: + parts = ref.split("/") + stem = parts[-1].removesuffix(".py") + return parts[-2] if stem == "__init__" and len(parts) > 1 else stem + + +#: Calls that return a module object (#872 review 10). +_MODULE_LOOKUPS = frozenset({"__import__", "getmodule", "import_module", "reload"}) +#: Attributes that are some module's namespace the scan cannot name. +_FOREIGN_NAMESPACES = frozenset({"__builtins__", "__globals__", "f_globals", "f_locals"}) +#: Calls whose result holds the elements of their (last) argument. +_ELEMENT_CALLS = frozenset({"filter", "frozenset", "iter", "list", "next", "reversed", "set", "sorted", "tuple"}) +#: Methods whose result holds what their container holds. +_ELEMENT_METHODS = frozenset({"get", "items", "pop", "setdefault", "values"}) +#: Methods that store their arguments into a container. +_STORING_METHODS = frozenset({"__setitem__", "add", "append", "extend", "insert", "setdefault", "update"}) +#: Dict methods that change the dict under keys they are given. +_DICT_CHANGES = frozenset({"__setitem__", "clear", "pop", "popitem", "setdefault", "update"}) +#: Attributes whose change changes every attribute a module reads as. +_MODULE_HOOKS = frozenset({"__class__", "__dict__", "__getattr__", "__getattribute__"}) +#: Calls that only read a namespace passed to them; any other may change it. +_NAMESPACE_READERS = frozenset( + { + "_eval_type", "_evaluate", "all", "any", "critical", "debug", "deepcopy", "dict", "dir", "dump", + "dumps", "enumerate", "error", "evaluate", "exception", "filter", "format", "frozenset", "get", + "get_type_hints", "getattr", "hasattr", "hash", + "id", "info", "isinstance", "items", "iter", "keys", "len", "list", "log", "map", "pformat", + "pprint", "print", "repr", "set", "sorted", "str", "sum", "tuple", "values", "warning", "zip", + } +) +#: Calls a module object handed to is only read by, or followed through. +_MODULE_READERS = _ELEMENT_CALLS | frozenset( + { + "callable", "critical", "debug", "delattr", "dir", "error", "exception", "format", "getattr", + "getdoc", "getfile", "getmembers", "getmodule", "getsource", "getsourcefile", "hasattr", "hash", + "id", "import_module", "info", "isclass", "isfunction", "isinstance", "ismodule", "issubclass", + "len", "log", "print", "reload", "repr", "setattr", "signature", "str", "type", "vars", "warning", + } +) +#: Methods the import system calls with a module. +_IMPORT_HOOK_METHODS = frozenset({"create_module", "exec_module", "load_module", "module_repr"}) +#: Calls that register a function the import system calls with a module. +_IMPORT_HOOK_REGISTRARS = frozenset( + {"add_import_hook", "post_import_hook", "register_import_hook", "register_post_import_hook", "when_imported"} +) +#: How many steps a module object is followed through names and calls. +MAX_OBJECT_DEPTH = 64 +#: Bounds on following an imported name through the modules that bind it; +#: past either, it may be any module. +MAX_EXPANSIONS = 1024 +MAX_EXPANSION_HOPS = 16 + +#: A parameter a change or a store reaches: ``("param", function, position, +#: parameter, is_method)``. ``function`` is None for a lambda; ``position`` is +#: -1 for keyword-only, and ``parameter`` is spelled ``*args``/``**kwargs`` +#: for the rest. +_Parameter = tuple[str, str | None, int, str, bool] +#: What an expression may be, as a module (#872 review 10): +#: - a module stem or name, or ``*`` for any module; +#: - ``=name``: a name only read, a module-level object or one the file does +#: not bind; +#: - ``~name``: a name imported ``from`` a module, or an attribute of a +#: module (a submodule or a value in it); +#: - a :data:`_Parameter`; +#: - ``("call", function, positional, keywords)``: what a call returns; +#: - ``("ns", key)``: the namespace dict of what ``key`` is (`vars(m)`). +_Key = Any +_OWN = "" +_ANY = "*" +#: An object the scan does not follow: a change to it counts as unclassified. +_UNSEEN = "?" +#: A class reached by introspection (`type(x)`, `Sub.__mro__[1]`), which may +#: be any class, an HTTP stack's among them (#872 review 18); and an +#: instance's own class (`type(self)`), which is not. +_CLASS = "@class" +_OWN_CLASS = "@own" + + +def _is_parameter(key: _Key) -> bool: + return isinstance(key, tuple) and key[0] == "param" + + +def _is_namespace(key: _Key) -> bool: + return isinstance(key, tuple) and key[0] == "ns" + + +def _is_call(key: _Key) -> bool: + return isinstance(key, tuple) and key[0] == "call" + + +def _unwrap(key: _Key) -> _Key: + return key[1] if _is_namespace(key) else key + + +def _strong(key: _Key) -> bool: + """A key the scan knows is a module: imported with `import`, looked up.""" + key = _unwrap(key) + return isinstance(key, str) and not key.startswith(("=", "~", "@")) and key != _UNSEEN + + +def _named_module(key: _Key) -> str | None: + """The module file name (or `*`) a key names, imported either way; else None. + + A key keeps the dotted path it was reached by (`urllib.request`, + `~pkg.agent_config`); a module of the scope is matched by its last part. + """ + path = _module_path(key) + return None if path is None else path.rsplit(".", 1)[-1] if path != _ANY else path + + +def _module_path(key: _Key) -> str | None: + """The dotted path a key names (`urllib.request.Request`); else None.""" + key = _unwrap(key) + if not isinstance(key, str) or key.startswith(("=", "@")) or key == _UNSEEN: + return None + return key.lstrip("~") or key + + +def _module_ish(key: _Key) -> bool: + """Anything but a name only read: it may be a module.""" + return not (isinstance(key, str) and (key.startswith("=") or key in {_UNSEEN, _OWN_CLASS})) + + +def _module_level(body: list[ast.stmt]) -> Iterator[ast.stmt]: + """The statements that run at module level: inside `if`, `try` and + `with` blocks too, not inside a function or class.""" + for statement in body: + yield statement + if isinstance(statement, ast.If | ast.While | ast.For | ast.AsyncFor | ast.With | ast.AsyncWith): + yield from _module_level([*statement.body, *getattr(statement, "orelse", [])]) + elif isinstance(statement, ast.Try | ast.TryStar): + yield from _module_level( + [ + *statement.body, + *(item for handler in statement.handlers for item in handler.body), + *statement.orelse, + *statement.finalbody, + ] + ) + + +def _unpacked(target: ast.AST, value: ast.AST | None) -> Iterator[tuple[ast.AST, ast.AST | None]]: + """Each target an assignment stores into, with what it stores there: + `self.http, self.x = requests, 1` pairs them; an unpacked call gives each + target the whole value.""" + if isinstance(target, ast.Starred): + yield from _unpacked(target.value, value) + elif isinstance(target, ast.Tuple | ast.List): + values = ( + value.elts + if isinstance(value, ast.Tuple | ast.List) + and len(value.elts) == len(target.elts) + and not any(isinstance(item, ast.Starred) for item in [*target.elts, *value.elts]) + else [value] * len(target.elts) + ) + for item, item_value in zip(target.elts, values, strict=True): + yield from _unpacked(item, item_value) + else: + yield target, value + + +def _trail(owner: ast.AST) -> tuple[list[str], ast.AST]: + """The names an attribute is stored through, and where they start: + `self.libs["http"]` is `libs.http` from `self`; `self.__dict__["http"]` + and `getattr(self, "http")` are `http`; any other item is `[]`.""" + trail: list[str] = [] + while True: + if isinstance(owner, ast.Attribute): + if owner.attr != "__dict__": + trail.insert(0, owner.attr) + owner = owner.value + elif isinstance(owner, ast.Subscript): + key = _literal(owner.slice) + trail.insert(0, key if isinstance(key, str) and key.isidentifier() else "[]") + owner = owner.value + elif ( + isinstance(owner, ast.Call) + and _spelling(owner.func) in {"getattr", "vars"} + and owner.args + and (len(owner.args) == 1 or _literal(owner.args[1]) is not None) + ): + if len(owner.args) > 1: + trail.insert(0, _literal(owner.args[1])) # type: ignore[arg-type] + owner = owner.args[0] + else: + return trail, owner + + +def _holder_of(target: ast.AST) -> str | None: + """The attribute a stored value is kept under: `self.http = …`, + `self.__dict__["http"] = …`, or the container's for `self.libs["x"] = …`.""" + if isinstance(target, ast.Attribute): + return target.attr + if isinstance(target, ast.Subscript): + key = _literal(target.slice) + if key is not None and _namespace_of(target.value) is not None: + return key + if isinstance(target.value, ast.Attribute): + return target.value.attr + return None + + +def _holders(library: dict[str, str]) -> set[str]: + """The attributes seen holding part of the HTTP stack (`self.http`).""" + return {key[4:] for key in library if key.startswith("via:")} + + +def _held_store(path: str, holders: set[str]) -> bool: + """An attribute path stored through an object the scan does not follow + that may patch a kept HTTP stack: one through an attribute seen holding it + (`self.http.get = …`) or through one of the stack's classes; or, but + through the method's own object (`^`), one ending in a name the stack + sends through (`box.module.get = …`, `cast(type, Session).request`).""" + own = path.startswith("^") + segments = path.lstrip("^").split(".") + if segments[-1] in _TUNING_ATTRIBUTES: + return False + return ( + bool(set(segments[:-1]) & holders) + # A stack class stored into however it is reached + # (`c.models.PreparedRequest.prepare_method = …`, #872 review 20). + or bool(set(segments[1:] if own else segments) & _STACK_CLASSES) + # A module or class kept somewhere, and a name it sends through + # stored on an object that may be it (`box.module.get = …`). + or (not own and segments[-1] in _STACK_NAMES) + ) + + +def _stack_object(path: str) -> bool: + """A part of the HTTP stack a request passes through that can be kept and + patched later: a module or a class, not a function + (`asyncio.to_thread(requests.get, url)`), a constant or an exception.""" + parts = path.split(".") + if parts[0] not in _HTTP_STACK or _inert(path): + return False + return len(parts) == 1 or path in _STACK_MODULES or parts[-1][:1].isupper() or path == "socket.socket" + + +def _inert(path: str) -> bool: + """A part of the HTTP stack no request passes through: a constant + (`socket.AF_INET`) or an exception (`requests.exceptions.Timeout`).""" + parts = path.split(".") + last = parts[-1] + return ( + last.isupper() + or last.endswith(("Error", "Exception", "Warning", "Timeout")) + or last in {"timeout", "error", "herror", "gaierror"} + or last in _STACK_EXCEPTIONS + or any(part in {"exceptions", "error"} for part in parts[1:]) + # The server side (`aiohttp.web.HTTPBadGateway`). + or parts[:2] in (["aiohttp", "web"], ["aiohttp", "web_exceptions"]) + ) + + +def _is_self(key: _Key) -> bool: + """A method's own first parameter (`self`, `cls`).""" + return _is_parameter(key) and key[4] and key[2] == -1 + + +def _namespaces(keys: frozenset[_Key] | set[_Key]) -> frozenset[_Key]: + return frozenset(key if _is_namespace(key) else ("ns", key) for key in keys) + + +def _elements(keys: frozenset[_Key] | set[_Key]) -> frozenset[_Key]: + """What an element of a container with these keys may be.""" + found: set[_Key] = set() + for key in keys: + if _is_namespace(key): + # A value in a module may be a module imported there; a value in + # an object's `__dict__` is an object the scan does not follow. + if _named_module(key[1]) is not None: + found.add(_ANY) + elif _module_ish(key[1]): + found.add(_UNSEEN) + elif _module_ish(key): + found.add(key) + if isinstance(key, str) and key.startswith("~"): + # An imported name read as a container (`from registry + # import MODULES; MODULES[0]`): its items are objects the + # scan does not follow (#872 review 12). + found.add(_UNSEEN) + return frozenset(found) + + +@dataclass +class FileMutations: + """What one file changes in shared state (#872 reviews 6-10). + + - ``names``: names stored under: an attribute, a literal dict key, a + ``setattr`` name. + - ``whole``: namespaces changed under names the read cannot see + (``setattr(config, name, v)``, ``vars(config).update(...)``, + ``globals().update(...)``), keyed as :data:`_Key`. + - ``dicts``: dicts changed under unseen keys that may be a namespace + (``ns = vars(config); ns[k] = v``). + - ``through``: a change, dict change or store into a container or + attribute made to a function's parameter. What its callers pass there + is then changed or stored. + - ``calls``: every call's callee and what its arguments may be. + - ``returns``: what a function may return or yield. + - ``referenced``: names used as a value rather than called. + - ``escapes``: module objects stored into a container or attribute. + - ``unclassified``: a namespace changed on an object the scan takes as not + a module. + - ``passed``: what each call is handed; ``defined``: the functions and + constructors the file defines. + """ + + names: list[tuple[str, str]] = field(default_factory=list) + whole: list[tuple[_Key, str]] = field(default_factory=list) + dicts: list[tuple[_Key, str]] = field(default_factory=list) + through: list[tuple[_Parameter, str, str]] = field(default_factory=list) + calls: list[ + tuple[str, tuple[tuple[int | None, frozenset[_Key]], ...], tuple[tuple[str | None, frozenset[_Key]], ...]] + ] = field(default_factory=list) + #: ``((module, function), keys, where, in_class)``. + returns: list[tuple[tuple[str, str], frozenset[_Key], str, bool]] = field(default_factory=list) + referenced: set[str] = field(default_factory=set) + escapes: list[tuple[frozenset[_Key], str]] = field(default_factory=list) + unclassified: list[str] = field(default_factory=list) + #: ``(callee, argument keys, where)``: what a call is handed. A module + #: handed to a call the scope does not define is kept where it is not + #: followed (`SimpleNamespace(m=config)`, `partial(apply, config)`). + passed: list[tuple[str | None, frozenset[_Key], str]] = field(default_factory=list) + #: Functions and constructors this file defines, by name. + defined: set[str] = field(default_factory=set) + #: Classes whose own `__init__`/`__new__` this file defines. + constructors: set[str] = field(default_factory=set) + #: ``(name, function)``: `return name` inside `function` (a decorator + #: factory handing on its wrapper). + returned: list[tuple[str, str]] = field(default_factory=list) + #: Names called anywhere but as a decorator (`@name(...)`). + called: set[str] = field(default_factory=set) + #: Functions registered as import hooks, which get any module. + hooks: set[str] = field(default_factory=set) + #: ``(dotted, where)``: an attribute of an imported module stored into + #: (`json.dumps = audited`, `builtins.print = send`); ``module.*`` when + #: under a name the scan cannot see (#872 review 12). + library: list[tuple[str, str]] = field(default_factory=list) + #: ``(owner keys, attribute, where)``: an attribute stored into on what + #: may be a module however it was reached (#872 review 13). + stores: list[tuple[frozenset[_Key], str, str]] = field(default_factory=list) + #: ``(attribute path, where)``: stored on an object the scan does not follow. + unseen: list[tuple[str, str]] = field(default_factory=list) + #: ``(keys, attribute, where)``: what is kept under an attribute + #: (`self.http = requests`). + holders: list[tuple[frozenset[_Key], str, str]] = field(default_factory=list) + #: ``(attribute, attribute)``: the first holds what the second holds + #: (`self.client = self.http`, a property returning `self._http`). + holder_aliases: list[tuple[str, str]] = field(default_factory=list) + #: ``(name, keys)``: a module-level name and the modules it may hold, as + #: another file importing it gets them (`import config as settings`). + exports: list[tuple[str, frozenset[_Key]]] = field(default_factory=list) + #: The module's dotted path under the scope (`pkg.tools`), the scope's + #: own name for its `__init__.py`. + module: str = "" + #: ``~pkg.mod.name -> 2``: how much of an export an import bound is a + #: module the import system found (`pkg.mod`), not attributes. + export_locks: dict[str, int] = field(default_factory=dict) + + +def _namespace_of(node: ast.AST) -> ast.AST | str | None: + """The object `node` is the namespace of: `x.__dict__`, `vars(x)`, `globals()`.""" + + if isinstance(node, ast.Attribute): + if node.attr == "__dict__": + return node.value + if node.attr in _FOREIGN_NAMESPACES: + return _ANY + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id == "vars" and node.args: + return node.args[0] + if node.func.id in {"globals", "locals", "vars"} and not node.args: + return _OWN + return None + + +def _is_module_table(node: ast.AST, imported_from: dict[str, str] | None = None) -> bool: + """`sys.modules`, or `modules` imported `from sys`.""" + if isinstance(node, ast.Name): + return node.id == "modules" and (imported_from or {}).get("modules") == "sys" + return _spelling(node) == "sys.modules" + + +def _answers_by_name(value: ast.AST, function: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """A lazy export that answers the name asked for, not a module in its place. + + `getattr(import_module(path), name)` hands on an attribute, and + `import_module(f".{name}", __name__)` the submodule of that name, which + an importer reaches by its own file. + """ + arguments = function.args + asked = {argument.arg for argument in [*arguments.posonlyargs, *arguments.args]} + if not isinstance(value, ast.Call) or not asked: + return False + callee = _spelling(value.func).rsplit(".", 1)[-1] + if callee == "getattr" and len(value.args) >= 2: + return any(isinstance(node, ast.Name) and node.id in asked for node in ast.walk(value.args[1])) + if callee in {"import_module", "__import__"} and value.args: + return any(isinstance(node, ast.Name) and node.id in asked for node in ast.walk(value.args[0])) + return False + + +#: Attributes that tune the HTTP stack without changing what a request sends. +_TUNING_ATTRIBUTES = frozenset( + { + "cert", "debuglevel", "http_version", "keep_alive", "max_redirects", "max_retries", "pool_block", + "pool_connections", "pool_maxsize", "retries", "timeout", "timeouts", "trust_env", "verify", + } +) + + +def _setting(path: list[str], value: ast.AST | None) -> bool: + """A plain value that only tunes: a module's constant + (`requests.adapters.DEFAULT_RETRIES = 3`) or a class's tuning attribute + (`HTTPConnection.debuglevel = 1`). A class's other attributes, its + default `method` among them, change what a request sends (#872 review 15). + """ + return value is not None and _plain(value) and _tuning(path) + + +def _tuning(path: list[str]) -> bool: + """Whether a plain value stored at `path` only tunes (see :func:`_setting`).""" + if not path: + return False + on_class = any(part[:1].isupper() for part in path[:-1]) + return not on_class or path[-1] in _TUNING_ATTRIBUTES + + +def _plain(node: ast.AST) -> bool: + """A value written out in the source: a constant, or a display of them.""" + if isinstance(node, ast.Constant): + return True + if isinstance(node, ast.UnaryOp): + return _plain(node.operand) + if isinstance(node, ast.Tuple | ast.List | ast.Set): + return all(_plain(item) for item in node.elts) + if isinstance(node, ast.Dict): + return all(key is not None and _plain(key) and _plain(item) for key, item in zip(node.keys, node.values, strict=True)) + return False + + +def _literal(node: ast.AST | None) -> str | None: + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +class _Fixpoint: + """Sets computed over a graph with cycles, each node once (#872 reviews 10-11). + + Tarjan's components: a node finished while its component is still open + keeps its partial set and is not computed again. When the component's + root finishes, every member holds the root's set, since each reaches the + same nodes. Past ``limit`` nested nodes the answer is any module. + """ + + def __init__(self, limit: int) -> None: + self.limit = limit + self.memo: dict[Any, frozenset[_Key]] = {} + self._index: dict[Any, int] = {} + self._partial: dict[Any, frozenset[_Key]] = {} + self._open: list[Any] = [] + self._low: list[int] = [] + self._visited = 0 + + def get(self, node: Any, compute: Callable[[], set[_Key] | frozenset[_Key]]) -> frozenset[_Key]: + if node in self.memo: + return self.memo[node] + index = self._index.get(node) + if index is not None: + # In an open component: its set is gathered at the component's root. + self._low[-1] = min(self._low[-1], index) + return self._partial.get(node, frozenset()) + if len(self._low) >= self.limit: + return frozenset({_ANY}) + index = self._visited + self._visited += 1 + self._index[node] = index + self._open.append(node) + self._low.append(index) + try: + found = frozenset(compute()) + finally: + low = self._low.pop() + if low < index: + self._partial[node] = found + self._low[-1] = min(self._low[-1], low) + return found + while True: + member = self._open.pop() + del self._index[member] + self._partial.pop(member, None) + self.memo[member] = found + if member is node or member == node: + return found + + +class _Scopes: + """The names each function of one file binds, to follow a module object. + + A module object is followed through names (closures, `global`, + `nonlocal`), parameters and their defaults, what a function returns or + yields, displays, loops and element methods (#872 review 10). Any other + object, such as the result of a call the scope's own functions do not return a + module from or an instance attribute, is taken as not a module. + """ + + def __init__(self, tree: ast.Module, own: str) -> None: + self.own = own + self.owner: dict[int, ast.AST | None] = {} + self.parent: dict[int, ast.AST | None] = {} + self.parameters: dict[int, dict[str, _Parameter]] = {} + self.defaults: dict[tuple[int, str], ast.AST] = {} + self.bindings: dict[int | None, dict[str, list[tuple[str, Any]]]] = {None: {}} + #: `global X` / `nonlocal X`: the scopes that declare each. + self.declared: dict[int, set[str]] = {} + self.nonlocals: dict[int, set[str]] = {} + self.writers: dict[tuple[str, str], list[int]] = {} + #: `from helpers import set_all as apply`: apply -> set_all. + self.aliases: dict[str, str] = {} + #: `from pkg.helpers import fetch`: fetch -> helpers, and -> pkg.helpers. + self.imported_from: dict[str, str] = {} + self.imported_path: dict[str, str] = {} + #: Whether a star import may bind names the file does not. + self.star = False + #: `(value, statement)` bound in a class body: a class attribute. + self.class_values: list[tuple[ast.AST, ast.AST]] = [] + self.methods: dict[int, bool] = {} + #: Functions (and classes with their own `__init__`) the file defines. + self.defined: set[str] = set() + self.constructors: set[str] = set() + #: Functions defined directly in a class body. + self.in_class: set[int] = set() + self._owned: dict[int, frozenset[str]] = {} + self._copies: dict[int, frozenset[str]] = {} + self._fixpoint = _Fixpoint(MAX_OBJECT_DEPTH) + stack: list[tuple[ast.AST, ast.AST | None]] = [(tree, None)] + while stack: + node, scope = stack.pop() + for child in ast.iter_child_nodes(node): + self._child(child, scope, node, stack) + + def _child( + self, + child: ast.AST, + scope: ast.AST | None, + node: ast.AST | None, + stack: list[tuple[ast.AST, ast.AST | None]], + ) -> None: + self.owner[id(child)] = scope + if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda): + if not isinstance(child, ast.Lambda): + self._bind(scope, child.name, ("def", None)) + outer = [ + *getattr(child, "decorator_list", []), + *child.args.defaults, + *[default for default in child.args.kw_defaults if default is not None], + ] + for item in outer: + self._child(item, scope, None, stack) + self.parent[id(child)] = scope + self.bindings[id(child)] = {} + # A method's first parameter is its own instance, a staticmethod's is not. + is_method = isinstance(node, ast.ClassDef) and not any( + _spelling(decorator) == "staticmethod" for decorator in getattr(child, "decorator_list", []) + ) + name = None if isinstance(child, ast.Lambda) else child.name + if isinstance(node, ast.ClassDef): + self.in_class.add(id(child)) + if is_method and name in {"__init__", "__new__"}: + # `Holder(module)` calls `Holder.__init__`: keyed by the class. + name = node.name # type: ignore[union-attr] + self.constructors.add(name) + if name is not None: + self.defined.add(name) + self._parameters(child, is_method, name) + for item in child.body if isinstance(child.body, list) else [child.body]: + self._child(item, child, None, stack) + return + self._record(child, scope) + if isinstance(node, ast.ClassDef) and isinstance(child, ast.Assign | ast.AnnAssign) and child.value is not None: + self.class_values.append((child.value, child)) + stack.append((child, scope)) + + def single_call(self, expr: ast.AST | None) -> ast.Call | None: + """The one call `expr` is, or a name is only ever bound to.""" + if isinstance(expr, ast.Call): + return expr + if not isinstance(expr, ast.Name): + return None + scope = self.owner.get(id(expr)) + while scope is not None and expr.id not in self.bindings.get(id(scope), {}): + scope = self.parent.get(id(scope)) + markers = self.bindings.get(None if scope is None else id(scope), {}).get(expr.id, []) + if len(markers) == 1 and markers[0][0] == "value" and isinstance(markers[0][1], ast.Call): + return markers[0][1] + return None + + def self_name(self, function: ast.AST | None) -> str | None: + """A method's own `self` (or `cls`): stores through it change an instance.""" + if function is None or not self.methods.get(id(function)): + return None + arguments = function.args # type: ignore[attr-defined] + positional = [*arguments.posonlyargs, *arguments.args] + return positional[0].arg if positional else None + + def _copies_itself(self, value: ast.AST) -> bool: + """`copy.copy(requests.Session)`: a class or module copies as itself, + so the copy is not a new object (#872 review 22).""" + if not (isinstance(value, ast.Call) and value.args): + return False + func = value.func + name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else "" + return name in {"copy", "deepcopy"} and any( + _module_ish(key) and not _is_parameter(key) for key in self.keys(value.args[0]) + ) + + def copies(self, function: ast.AST | None) -> frozenset[str]: + """Names `function` binds to a copy of what it does not follow (a + parameter, `self.session_class`): a class copies as itself, so an + attribute stored through one may patch it (#872 review 23).""" + if function is None: + return frozenset() + if id(function) in self._copies: + return self._copies[id(function)] + self._copies[id(function)] = found = frozenset( + name + for name, markers in self.bindings.get(id(function), {}).items() + if any( + kind == "value" + and isinstance(value, ast.Call) + and value.args + and (value.func.attr if isinstance(value.func, ast.Attribute) else getattr(value.func, "id", "")) + in {"copy", "deepcopy"} + and any(_is_parameter(key) or key == _UNSEEN for key in self.keys(value.args[0])) + for kind, value in markers + ) + ) + return found + + def owned(self, function: ast.AST | None) -> frozenset[str]: + """Names `function` only ever binds to an object it builds (`x = {}`).""" + if function is None: + return frozenset() + found = self._owned.get(id(function)) + if found is None: + excluded = ( + set(self.parameters.get(id(function), {})) + | self.declared.get(id(function), set()) + | self.nonlocals.get(id(function), set()) + ) + found = frozenset( + name + for name, markers in self.bindings.get(id(function), {}).items() + if name not in excluded + and markers + and all(kind == "value" and _fresh(value) and not self._copies_itself(value) for kind, value in markers) + ) + self._owned[id(function)] = found + return found + + def _parameters(self, function: ast.AST, is_method: bool, name: str | None) -> None: + self.methods[id(function)] = is_method + arguments = function.args # type: ignore[attr-defined] + offset = 1 if is_method else 0 + positional = [*arguments.posonlyargs, *arguments.args] + found: dict[str, _Parameter] = {} + for index, argument in enumerate(positional): + found[argument.arg] = ("param", name, index - offset, argument.arg, is_method) + if arguments.vararg is not None: + found[arguments.vararg.arg] = ( + "param", name, len(positional) - offset, f"*{arguments.vararg.arg}", is_method + ) + for argument in arguments.kwonlyargs: + found[argument.arg] = ("param", name, -1, argument.arg, is_method) + if arguments.kwarg is not None: + found[arguments.kwarg.arg] = ("param", name, -1, f"**{arguments.kwarg.arg}", is_method) + self.parameters[id(function)] = found + defaulted = positional[len(positional) - len(arguments.defaults):] + for argument, default in zip(defaulted, arguments.defaults, strict=True): + self.defaults[(id(function), argument.arg)] = default + for argument, default in zip(arguments.kwonlyargs, arguments.kw_defaults, strict=True): + if default is not None: + self.defaults[(id(function), argument.arg)] = default + + def _bind(self, scope: ast.AST | None, name: str, marker: tuple[str, Any]) -> None: + self.bindings[None if scope is None else id(scope)].setdefault(name, []).append(marker) + + def _target(self, scope: ast.AST | None, target: ast.AST, marker: tuple[str, Any]) -> None: + if isinstance(target, ast.Name): + self._bind(scope, target.id, marker) + elif isinstance(target, ast.Starred): + self._target(scope, target.value, ("in", marker[1]) if marker[0] == "value" else marker) + elif isinstance(target, ast.Tuple | ast.List): + value = marker[1] if marker[0] == "value" else None + if ( + isinstance(value, ast.Tuple | ast.List) + and len(value.elts) == len(target.elts) + and not any(isinstance(item, ast.Starred) for item in (*value.elts, *target.elts)) + ): + for item, part in zip(target.elts, value.elts, strict=True): + self._target(scope, item, ("value", part)) + else: + for item in target.elts: + self._target(scope, item, ("in", marker[1]) if marker[0] != "none" else marker) + + def _record(self, node: ast.AST, scope: ast.AST | None) -> None: + if isinstance(node, ast.Assign): + for target in node.targets: + self._target(scope, target, ("value", node.value)) + elif isinstance(node, ast.AnnAssign | ast.AugAssign) and node.value is not None: + self._target(scope, node.target, ("value", node.value)) + elif isinstance(node, ast.NamedExpr): + self._target(scope, node.target, ("value", node.value)) + elif isinstance(node, ast.For | ast.AsyncFor | ast.comprehension): + self._target(scope, node.target, ("in", node.iter)) + elif isinstance(node, ast.withitem) and node.optional_vars is not None: + self._target(scope, node.optional_vars, ("none", None)) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + self._bind(scope, alias.asname, ("import", frozenset({alias.name}))) + else: + first = alias.name.split(".")[0] + self._bind(scope, first, ("import", frozenset({first}))) + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name == "*": + self.star = True + continue + origin = f"{node.module}.{alias.name}" if node.module and not node.level else alias.name + self._bind(scope, alias.asname or alias.name, ("from", frozenset({f"~{origin}"}))) + if node.module: + self.imported_from[alias.asname or alias.name] = node.module.rsplit(".", 1)[-1] + if not node.level: + self.imported_path[alias.asname or alias.name] = node.module + if alias.asname: + self.aliases[alias.asname] = alias.name + elif isinstance(node, ast.Global | ast.Nonlocal) and scope is not None: + kind = "global" if isinstance(node, ast.Global) else "nonlocal" + (self.declared if kind == "global" else self.nonlocals).setdefault(id(scope), set()).update(node.names) + for name in node.names: + self.writers.setdefault((kind, name), []).append(id(scope)) + elif isinstance(node, ast.ExceptHandler) and node.name: + self._bind(scope, node.name, ("none", None)) + elif isinstance(node, ast.ClassDef): + self._bind(scope, node.name, ("class", None)) + + # -- what an expression may be, as a module --------------------------------- + + def keys(self, expr: ast.AST) -> frozenset[_Key]: + return self._fixpoint.get(id(expr), lambda: self._keys(expr)) + + def _keys(self, expr: ast.AST) -> set[_Key]: + inner = self.keys + if isinstance(expr, ast.Name): + found = set(self.name(expr.id, self.owner.get(id(expr)))) + if expr.id == "__builtins__" and found == {"=__builtins__"}: + # The builtins module, or its dict. + return {"builtins", ("ns", "builtins")} + return found + if isinstance(expr, ast.Attribute): + namespace = _namespace_of(expr) + if namespace == _ANY: + return {("ns", _ANY)} + if namespace is not None: + return set(_namespaces(inner(namespace))) # type: ignore[arg-type] + if expr.attr == "__class__": + return self._class_of(inner(expr.value)) + if expr.attr in {"__base__", "__bases__", "__mro__"}: + return {_CLASS} + return self._member(inner(expr.value), expr.attr) + if isinstance(expr, ast.Subscript): + if _is_module_table(expr.value, self.imported_from): + return self.lookup(expr.slice) + namespace = _namespace_of(expr.value) + if isinstance(namespace, ast.AST) and _literal(expr.slice) is not None: + # `vars(urllib.request)["Request"]` is `urllib.request.Request`. + return self._member(inner(namespace), _literal(expr.slice)) # type: ignore[arg-type] + item = self._tuple_item(expr) + if item is not None: + # `(type(exc), exc, tb)[1]` is `exc`, not a class. + return set(inner(item)) + return set(_elements(inner(expr.value))) + if isinstance(expr, ast.Call): + return self._call(expr, inner) + if isinstance(expr, ast.Tuple | ast.List | ast.Set): + return {key for item in expr.elts for key in inner(item)} + if isinstance(expr, ast.Dict): + return {key for item in expr.values for key in inner(item)} + if isinstance(expr, ast.ListComp | ast.SetComp | ast.GeneratorExp): + return set(inner(expr.elt)) + if isinstance(expr, ast.DictComp): + return set(inner(expr.value)) + if isinstance(expr, ast.IfExp): + return {*inner(expr.body), *inner(expr.orelse)} + if isinstance(expr, ast.BoolOp): + return {key for item in expr.values for key in inner(item)} + if isinstance(expr, ast.NamedExpr | ast.Await | ast.Starred): + return set(inner(expr.value)) + return set() + + def _tuple_item(self, expr: ast.Subscript) -> ast.AST | None: + """The item a constant index reads from a tuple written out here, or + from a name bound once to one.""" + index = expr.slice.value if isinstance(expr.slice, ast.Constant) else None + if type(index) is not int: + return None + literal: ast.AST | None = expr.value + if isinstance(literal, ast.Name): + scope = self.binder(literal.id, self.owner.get(id(expr))) + markers = self.bindings.get(None if scope is None else id(scope), {}).get(literal.id, []) + parameter = scope is not None and literal.id in self.parameters.get(id(scope), {}) + # `global PAIR; PAIR = …` in a function, or `nonlocal`, writes it + # from elsewhere (#872 review 21). + written = ("global" if scope is None else "nonlocal", literal.id) in self.writers + once = len(markers) == 1 and markers[0][0] == "value" and not parameter and not written + literal = markers[0][1] if once else None + if not isinstance(literal, ast.Tuple) or any(isinstance(item, ast.Starred) for item in literal.elts): + return None + return literal.elts[index] if -len(literal.elts) <= index < len(literal.elts) else None + + def binder(self, name: str, scope: ast.AST | None) -> ast.AST | None: + """The function whose scope binds `name` read in `scope`; None for the module.""" + while scope is not None: + if name in self.declared.get(id(scope), set()): + return None + if name not in self.nonlocals.get(id(scope), set()) and ( + name in self.parameters.get(id(scope), {}) or name in self.bindings.get(id(scope), {}) + ): + return scope + scope = self.parent.get(id(scope)) + return None + + def kinds(self, name: str, scope: ast.AST | None) -> tuple[ast.AST | None, set[str]]: + """Where `name` is bound, and how: `param`, `def`, `class`, `import`, `value`, …""" + binder = self.binder(name, scope) + kinds = {kind for kind, _ in self.bindings.get(None if binder is None else id(binder), {}).get(name, [])} + if binder is not None and name in self.parameters.get(id(binder), {}): + kinds.add("param") + return binder, kinds + + def name(self, name: str, scope: ast.AST | None) -> frozenset[_Key]: + """What `name`, read in `scope`, may be: from the scope that binds it.""" + scope = self.binder(name, scope) + key = ("name", None if scope is None else id(scope), name) + return self._fixpoint.get(key, lambda: self._name(name, scope)) + + def _name(self, name: str, scope: ast.AST | None) -> set[_Key]: + found: set[_Key] = set() + if scope is None: + found.add(f"={name}") + markers = list(self.bindings[None].get(name, [])) + if self.star and not markers and name not in _BUILTIN_NAMES: + # `from registry import *` may bind it: what `registry` + # exports under that name (#872 review 14). + found.add(f"~{name}") + writers = self.writers.get(("global", name), []) + else: + parameter = self.parameters.get(id(scope), {}).get(name) + if parameter is not None: + found.add(parameter) + default = self.defaults.get((id(scope), name)) + if default is not None: + found |= self.keys(default) + markers = list(self.bindings.get(id(scope), {}).get(name, [])) + writers = self.writers.get(("nonlocal", name), []) + for writer in writers: + # `global X; X = ...` in a function binds the module's X. + markers.extend(self.bindings.get(writer, {}).get(name, [])) + for marker in markers: + found |= self._marker(marker) + return found + + def _marker(self, marker: tuple[str, Any]) -> frozenset[_Key]: + kind, value = marker + if kind in {"import", "from"}: + return value + if kind == "value": + return self.keys(value) + if kind == "in": + keys = self.keys(value) + return keys if isinstance(value, ast.Tuple | ast.List | ast.Set | ast.Dict) else _elements(keys) + return frozenset() + + def _class_of(self, instance: frozenset[_Key]) -> set[_Key]: + """`x.__class__`, `type(x)`: a method's own class, or any class.""" + return {_OWN_CLASS} if instance and all(_is_self(key) for key in instance) else {_CLASS} + + def _member(self, base: frozenset[_Key], attr: str) -> set[_Key]: + """`base.attr`: a submodule or a value of a module, else an object's attribute. + + An attribute of what may not be a module (a call's result, a + parameter) may be an object the scan does not follow: ``?``. A + dunder (`__version__`, `__mro__`) is never a submodule. + """ + if attr.startswith("__") and attr.endswith("__"): + return {f"={attr}"} + if _ANY in base: + return {_ANY} + if _CLASS in base: + # What a class reached by introspection holds: `type(req).headers`. + return {_CLASS} + modules = [ + key + for key in base + if _module_ish(key) and not (_is_call(key) or _is_parameter(key) or key == _CLASS) + ] + if modules: + # Kept with the path it was reached by: `urllib.request.Request`. + return {f"~{_module_path(key) or attr}.{attr}" if _module_path(key) else f"~{attr}" for key in modules} + if _elements(base): + # An attribute of an object the scan does not follow + # (`usage.requests`) is not the module of that name. + return {f"={attr}", _UNSEEN} + return {f"={attr}"} + + def lookup(self, key: ast.AST | None) -> set[_Key]: + """`sys.modules[key]`, `import_module(key)`. + + A name built with a literal last part (`f"pkg.{provider}.chat"`) names + that module, whatever the package. + """ + literal = _literal(key) + if literal is None and isinstance(key, ast.JoinedStr) and key.values: + literal = _literal(key.values[-1]) + literal = literal if literal is not None and "." in literal else None + elif literal is None and isinstance(key, ast.BinOp) and isinstance(key.op, ast.Add): + literal = _literal(key.right) + literal = literal if literal is not None and "." in literal else None + if literal is not None and literal.strip("."): + # The whole path when it is written out (`"urllib.request"`); the + # last part when only that is (`f"pkg.{provider}.chat"`). + exact = _literal(key) is not None + return {literal.strip(".") if exact else literal.strip(".").rsplit(".", 1)[-1]} + if isinstance(key, ast.Name) and key.id == "__name__": + return {self.own} + return {_ANY} + + def _call(self, call: ast.Call, inner: Callable[[ast.AST], frozenset[_Key]]) -> set[_Key]: + func = call.func + name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None + arguments = call.args + namespace = _namespace_of(call) + if namespace == _OWN: + # `locals()` or `vars()` in a function is that function's own. + if name != "globals" and self.owner.get(id(call)) is not None: + return set() + return {("ns", self.own)} + if namespace is not None: + return set(_namespaces(inner(namespace))) # type: ignore[arg-type] + if isinstance(func, ast.Attribute) and _is_module_table(func.value, self.imported_from): + return self.lookup(arguments[0]) if arguments else {_ANY} + if name in _MODULE_LOOKUPS: + if name == "reload" and arguments: + return set(inner(arguments[0])) + if name == "__import__" and _literal(arguments[0] if arguments else None) is not None: + return {_literal(arguments[0]).split(".")[0], *self.lookup(arguments[0])} # type: ignore[union-attr] + return self.lookup(arguments[0]) if arguments and name != "getmodule" else {_ANY} + if isinstance(func, ast.Name) and name == "getattr" and len(arguments) >= 2: + base = inner(arguments[0]) + attr = _literal(arguments[1]) + if attr is not None: + return self._member(base, attr) + # An attribute read under a computed name is taken as not a + # module, unless what it is read from may be any module. + return {_ANY} if _ANY in base else set() + if ( + isinstance(func, ast.Call) + and _spelling(func.func).rsplit(".", 1)[-1] == "attrgetter" + and len(func.args) == 1 + and isinstance(_literal(func.args[0]), str) + and arguments + ): + # `operator.attrgetter("transport.adapters")(net)` is + # `net.transport.adapters` (#872 review 24). + found: set[_Key] = set(inner(arguments[0])) + for attr in _literal(func.args[0]).split("."): # type: ignore[union-attr] + found = self._member(frozenset(found), attr) + return found + if isinstance(func, ast.Name) and name == "type" and len(arguments) == 1: + return self._class_of(inner(arguments[0])) + if name in {"__subclasses__", "getmro", "mro"}: + return {_CLASS} + if isinstance(func, ast.Name) and name in _ELEMENT_CALLS and arguments: + return set(inner(arguments[-1])) + if name in {"copy", "deepcopy"} and arguments and ( + (isinstance(func, ast.Attribute) and "copy" in inner(func.value)) + or (isinstance(func, ast.Name) and self.imported_path.get(func.id) == "copy") + ): + # `copy.copy(requests.Session)` is that class: a class or module + # copies as itself (#872 review 21). + return set(inner(arguments[0])) + if isinstance(func, ast.Attribute) and name == "copy": + # A copy of a namespace is a plain dict; of a list, the same items. + return {key for key in inner(func.value) if not _is_namespace(key)} + if isinstance(func, ast.Attribute) and name in _ELEMENT_METHODS: + base = inner(func.value) + # `session.get(id)` on an imported name is that module's (or + # object's) function, not an element of a container. + if not any(_named_module(key) is not None for key in base if not _is_namespace(key)): + return set(_elements(base)) + if name is None: + return set() + # What a call returns is followed into the scope's own functions + # (#872 review 11): one the file defines or imports by name, a + # module's function, or the class's own method. What a function held + # in a variable, or another object's method, returns is an object the + # scan does not follow. + home: str | None + if isinstance(func, ast.Name): + _, kinds = self.kinds(func.id, self.owner.get(id(call))) + if kinds - {"def", "class", "from"}: + return {_UNSEEN} + home = self.own if kinds & {"def", "class"} else self.imported_from.get(func.id) + elif isinstance(func.value, ast.Name) and func.value.id in {"self", "cls"}: + home = self.own + else: + modules = {_named_module(key) for key in inner(func.value) if not _is_namespace(key)} - {None} + if not modules: + return {_UNSEEN} + home = next(iter(modules)) if len(modules) == 1 and _ANY not in modules else None + return {("call", self.aliases.get(name, name), *self.arguments(call), home)} + + def arguments( + self, call: ast.Call + ) -> tuple[tuple[tuple[int | None, frozenset[_Key]], ...], tuple[tuple[str | None, frozenset[_Key]], ...]]: + """What each argument of `call` may be; a starred one at no fixed index.""" + positional: list[tuple[int | None, frozenset[_Key]]] = [] + starred = False + for index, argument in enumerate(call.args): + starred = starred or isinstance(argument, ast.Starred) + keys = self.keys(argument) + if keys: + positional.append((None if starred else index, keys)) + keywords = tuple( + (keyword.arg, keys) for keyword in call.keywords if (keys := self.keys(keyword.value)) + ) + return tuple(positional), keywords + + +def module_mutations(tree: ast.Module, ref: str) -> FileMutations: + """What this file stores into, on anything but an object it just built. + + A store is keyed by the name it writes (#872 review 7), whatever it goes + through: `agent_config.METHOD = ...`, `helpers.fetch = purge`, + `cfg["method"] = ...` through an accessor or a parameter. A store under + a name the read cannot see changes the whole namespace. That covers a + computed `setattr`, `vars(x)`/`x.__dict__` (#872 review 8), and + `globals()`, `sys.modules[...]`, `exec` and a namespace passed to a call + (#872 review 10). :class:`_Scopes` follows what that namespace may be. + Two cases are skipped: + - stores into a dict, list, client or request the same function just built; + - stores through a method's own `self`, which change an instance, not a + module. + """ + + found = FileMutations() + own_module = _module_stem(ref) + scopes = _Scopes(tree, own_module) + + def where(node: ast.AST) -> str: + return f"{ref}:{getattr(node, 'lineno', 0)}" + + def skipped(root: str | None, node: ast.AST) -> bool: + if root is None: + return False + function = scopes.owner.get(id(node)) + return root == scopes.self_name(function) or root in scopes.owned(function) + + def named(name: str | None, root: str | None, node: ast.AST, *, key: bool = False) -> None: + # A dict key needs no record: a value read out of a module-level dict + # is never taken as written (#872 review 9). + if name is not None and not key and not skipped(root, node): + found.names.append((name, where(node))) + + def record(keys: frozenset[_Key] | set[_Key], node: ast.AST, kind: str = "change") -> None: + """A namespace changed (``change``), or a dict that may be one (``dict``).""" + if kind == "change" and not keys: + found.unclassified.append(where(node)) + for key in keys: + key = _unwrap(key) if kind == "change" else key + if _is_parameter(key): + found.through.append((key, kind, where(node))) + elif kind == "change": + found.whole.append((key, where(node))) + elif _is_namespace(key) or _is_call(key) or key == _UNSEEN: + found.dicts.append((key, where(node))) + + def changed(container: ast.AST, node: ast.AST, *, namespace: bool = False) -> None: + """What `node` changes under names the read cannot see. + + A namespace: `setattr(m, name, v)`, `vars(m)`, `m.__dict__`, + `globals()`. Otherwise a dict: one read out of a module is never taken + as written (#872 review 9), so only a namespace reached another way + counts (`ns = vars(m); ns[k] = v`). + """ + target = _namespace_of(container) + if target == _OWN: + if scopes.keys(container): + record({own_module}, node) + return + if target == _ANY: + record({_ANY}, node) + return + if target is not None: + container = target # type: ignore[assignment] + namespace = True + root = _root_name(container) + if skipped(root, node): + if namespace and not isinstance(container, ast.Name): + # `setattr(self.module, k, v)`: an attribute of an instance + # the scan does not follow. + found.unclassified.append(where(node)) + return + if namespace: + patched(container, None, node) + record(scopes.keys(container), node, "change" if namespace else "dict") + + #: Each name an import binds, as the dotted path it imports. + imported: dict[str, str] = {"builtins": "builtins", "__builtins__": "builtins"} + for statement in ast.walk(tree): + if isinstance(statement, ast.Import): + for alias in statement.names: + if alias.asname: + imported[alias.asname] = alias.name + else: + imported.setdefault(alias.name.split(".")[0], alias.name.split(".")[0]) + elif isinstance(statement, ast.ImportFrom) and statement.module and not statement.level: + for alias in statement.names: + if alias.name != "*": + imported[alias.asname or alias.name] = f"{statement.module}.{alias.name}" + + def patched(owner: ast.AST | None, attr: str | None, node: ast.AST) -> None: + """A store into what an import binds: `json.dumps = f`, `setattr(time, k, v)`.""" + spelling = reference_spelling(owner) if owner is not None else None + if spelling is None: + return + root, _, rest = spelling.partition(".") + _, kinds = scopes.kinds(root, scopes.owner.get(id(node))) + if root in imported and (kinds & {"import", "from"} or (not kinds and root in {"builtins", "__builtins__"})): + dotted = imported[root] + (f".{rest}" if rest else "") + found.library.append((f"{dotted}.{attr or '*'}", where(node))) + + def stored_into(owner: ast.AST | None, attr: str | None, node: ast.AST, value: ast.AST | None = None) -> None: + """An attribute stored on what may be a module, however reached: + `sys.modules["json"].dumps = f`, `j = json; j.dumps = f`, a parameter. + + Recorded under the path from the module it starts at + (`requests.Session.request`, `urllib.request.Request.method`). A + setting stored as a plain value (`adapters.DEFAULT_RETRIES = 3`) + replaces no code. + """ + if owner is None: + return + base, chain = owner, [] + #: The class of an object, or a class's base, reached by + #: introspection: `x.__class__`, `type(x)`, `Sub.__mro__[1]`. + introspected = False + while True: + if isinstance(base, ast.Attribute) and base.attr in {"__class__", "__base__"}: + introspected = True + base = base.value + elif isinstance(base, ast.Subscript) and ( + (isinstance(base.value, ast.Attribute) and base.value.attr in {"__bases__", "__mro__"}) + or ( + isinstance(base.value, ast.Call) + and _spelling(base.value.func).rsplit(".", 1)[-1] in {"__subclasses__", "getmro", "mro"} + ) + ): + introspected = True + inner = base.value + base = ( + inner.value + if isinstance(inner, ast.Attribute) + else inner.func.value + if isinstance(inner.func, ast.Attribute) and inner.func.attr in {"__subclasses__", "mro"} # type: ignore[union-attr] + else (inner.args[0] if inner.args else inner) # type: ignore[union-attr] + ) + elif isinstance(base, ast.Call) and _spelling(base.func) == "type" and len(base.args) == 1: + introspected = True + base = base.args[0] + elif isinstance(base, ast.Attribute): + chain.insert(0, base.attr) + base = base.value + elif ( + isinstance(base, ast.Call) + and _spelling(base.func) == "getattr" + and len(base.args) >= 2 + and _literal(base.args[1]) is not None + ): + # `getattr(urllib.request, "Request").method = …` + chain.insert(0, _literal(base.args[1])) # type: ignore[arg-type] + base = base.args[0] + else: + break + path = ".".join([*chain, attr or "*"]) + owner_keys = scopes.keys(owner) + + def own_object(root: str | None) -> bool: + """The method's own object, or one the function built: not an + item of a container it built (`pair = (json, 1)`, `pair[0].get`), + which may be anything put there (#872 review 21).""" + function = scopes.owner.get(id(node)) + if isinstance(base, ast.Subscript) and root != scopes.self_name(function): + return False + if root in scopes.copies(function): + return False + return skipped(root, node) + + if (attr or "*") not in _TUNING_ATTRIBUTES and ( + _CLASS in owner_keys or (introspected and not skipped(_root_name(base), node)) + ): + # Any class, an HTTP stack's among them (#872 reviews 17-18). + found.library.append((f"class:{attr or '*'}", where(node))) + plain = value is not None and _plain(value) + if plain and _setting([*([base.id] if isinstance(base, ast.Name) else []), *path.split(".")], value): + if isinstance(base, ast.Name) and base.id in imported: + # A known module's setting (`adapters.DEFAULT_RETRIES = 3`). + return + else: + patched(owner, attr, node) + direct = frozenset(key for key in owner_keys if _module_ish(key) and key != _CLASS) + if (not direct or _UNSEEN in owner_keys) and not (isinstance(owner, ast.Name) and skipped(owner.id, node)): + # An object the scan does not follow, `self.http` included, may + # hold a module kept there: record the whole path it is stored + # through (`http.get`, `libs.http.get` for `self.libs["http"]`, + # `module.Session.prepare_request`), `^` marking one through the + # method's own object. + trail, root = _trail(owner) + own = "^" if isinstance(root, ast.Name) and own_object(root.id) else "" + found.unseen.append((f"{own}{'.'.join([*trail, attr or '*'])}", where(node))) + if own_object(_root_name(base)): + return + keys = frozenset(key for key in scopes.keys(base) if not (isinstance(key, str) and key.startswith("="))) + if keys: + # A plain value's owner is decided once it is resolved: a + # class's `method` is a patch, a module's constant a setting. + found.stores.append((keys, f"={path}" if plain else path, where(node))) + # The owner itself, however it was reached: another module's name for + # a library (`agent.requests.get = …`) (#872 review 18). + if direct: + found.stores.append((direct, f"={attr or '*'}" if plain else attr or "*", where(node))) + + def builtins_of(expr: ast.AST) -> bool: + """`builtins`, however imported, or `__builtins__`.""" + return _root_name(expr) == "__builtins__" or any( + _named_module(key) == "builtins" for key in scopes.keys(expr) + ) + + def reloads_itself(value: ast.AST | None, key: ast.AST) -> bool: + """The module itself, loaded lazily, not another in its place. + + `sys.modules[name] = module_from_spec(find_spec(name))`, or a lazy + proxy built from that name and its spec (`_LazyModule(name, spec)`). + """ + def own_spec(argument: ast.AST) -> bool: + spec = scopes.single_call(argument) + return ( + spec is not None + and _spelling(spec.func).endswith("find_spec") + and bool(spec.args) + and ast.dump(spec.args[0]) == ast.dump(key) + ) + + module = scopes.single_call(value) + if module is None or not module.args: + return False + if _spelling(module.func).endswith("module_from_spec"): + return own_spec(module.args[0]) + return ast.dump(module.args[0]) == ast.dump(key) and any(own_spec(arg) for arg in module.args[1:]) + + def stored(value: ast.AST, node: ast.AST, holder: str | None = None) -> None: + """`value` kept in a container or an attribute, where it is not + followed; ``holder`` is the attribute it is kept under (`self.http`).""" + keys = scopes.keys(value) + kind = f"escape:{holder}" if holder else "escape" + for key in keys: + if _is_parameter(key): + found.through.append((key, kind, where(node))) + if any(not _is_parameter(key) and _module_ish(key) for key in keys): + found.escapes.append((keys, where(node))) + if holder: + found.holders.append((keys, holder, where(node))) + + for value, statement in scopes.class_values: + stored( + value, + statement, + next( + (target.id for target in getattr(statement, "targets", [getattr(statement, "target", None)]) + if isinstance(target, ast.Name)), + None, + ), + ) + + called: set[int] = set() + #: Decorator expressions, and `return` values with their function. + decorating: set[int] = set() + returning: dict[int, str] = {} + #: What a name may be bound from (an assignment's or `:=`'s value, a + #: `for` iterable, a `with` item), and classes, read again once + #: everything is recorded. + bound_from: list[ast.AST] = [] + classes: list[ast.ClassDef] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Assign | ast.AnnAssign | ast.AugAssign | ast.NamedExpr) and node.value is not None: + bound_from.append(node.value) + elif isinstance(node, ast.For | ast.AsyncFor | ast.comprehension): + bound_from.append(node.iter) + elif isinstance(node, ast.withitem): + bound_from.append(node.context_expr) + if isinstance(node, ast.ClassDef): + classes.append(node) + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + decorating.update(id(decorator) for decorator in node.decorator_list) + elif isinstance(node, ast.Return) and node.value is not None: + function = scopes.owner.get(id(node)) + if isinstance(function, ast.FunctionDef | ast.AsyncFunctionDef): + returning[id(node.value)] = function.name + targets: list[ast.expr] = [] + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, ast.AnnAssign): + targets = [node.target] + elif isinstance(node, ast.AugAssign): + targets = [node.target] + if isinstance(node.target, ast.Name): + changed(node.target, node) # `cfg |= overrides` + elif isinstance(node, ast.Delete): + targets = node.targets + elif isinstance(node, ast.Lambda): + # What a lambda returns is not followed: a module it returns + # (`http = lambda: requests`) is kept there (#872 review 18). + stored(node.body, node) + elif isinstance(node, ast.Return | ast.Yield | ast.YieldFrom) and node.value is not None: + function = scopes.owner.get(id(node)) + if ( + isinstance(function, ast.FunctionDef | ast.AsyncFunctionDef) + and isinstance(node.value, ast.Attribute) + and isinstance(node.value.value, ast.Name) + and node.value.value.id == scopes.self_name(function) + ): + # `@property def http(self): return self._http`. + found.holder_aliases.append((function.name, node.value.attr)) + if isinstance(function, ast.FunctionDef | ast.AsyncFunctionDef): + keys = scopes.keys(node.value) + if keys: + found.returns.append( + ((own_module, function.name), keys, where(node), id(function) in scopes.in_class) + ) + if ( + function.name == "__getattr__" + and scopes.parent.get(id(function)) is None + and not _answers_by_name(node.value, function) + ): + # A module's `__getattr__` (PEP 562) answers any name + # another file imports from it (#872 review 14). + stored(node.value, node) + value = getattr(node, "value", None) if isinstance(node, ast.Assign | ast.AnnAssign | ast.AugAssign) else None + if ( + value is not None + and scopes.owner.get(id(node)) is None + and any(isinstance(target, ast.Name | ast.Tuple | ast.List) for target in targets) + ): + # A module-level name another file can import. An alias of a + # module (`CFG = config`, `a, b = x, y`) is an import by another + # name, followed through ``exports``. A module held in a + # container or built value (`SETTINGS_MODULES = [config]`) is + # kept there too, for routes that are not by name. + aliases = all( + isinstance(part, ast.Name | ast.Attribute) + # `requests = importlib.import_module("requests")`: an import + # by another spelling. + or (isinstance(part, ast.Subscript) and _is_module_table(part.value, scopes.imported_from)) + or ( + isinstance(part, ast.Call) + and _spelling(part.func).rsplit(".", 1)[-1] in {"__import__", "import_module"} + ) + for part in ( + value.elts + if isinstance(value, ast.Tuple | ast.List) + and all(isinstance(target, ast.Tuple | ast.List) for target in targets) + else [value] + ) + ) + if not aliases: + stored(value, node) + unpacked = [pair for whole_target in targets for pair in _unpacked(whole_target, value)] + for target, value in unpacked: + if isinstance(target, ast.Attribute | ast.Subscript) and value is not None: + stored(value, node, _holder_of(target)) + if isinstance(target, ast.Attribute) and isinstance(value, ast.Attribute): + # `self.client = self.http`: whatever `http` holds. + found.holder_aliases.append((target.attr, value.attr)) + if isinstance(target, ast.Attribute): + named(target.attr, _root_name(target), node) + stored_into(target.value, target.attr, node, value if isinstance(node, ast.Assign | ast.AnnAssign) else None) + if builtins_of(target.value): + # `builtins.print = send_log`: every module's `print`. + found.names.append((f"builtins.{target.attr}", where(node))) + if target.attr in _MODULE_HOOKS: + # `sys.modules[__name__].__class__ = Lazy` changes every read. + changed(target.value, node, namespace=True) + elif isinstance(target, ast.Subscript): + key = _literal(target.slice) + if _is_module_table(target.value, scopes.imported_from): + # `sys.modules["agent_config"] = stub` replaces a module; + # `del sys.modules[name]` makes the next import read it again. + if not isinstance(node, ast.Delete) and not reloads_itself(value, target.slice): + record(scopes.lookup(target.slice), node) + elif key is not None and _namespace_of(target.value) is not None: + # `vars(m)["X"]`, `m.__dict__["X"]`, `globals()["X"]` name + # a module attribute. + named(key, _root_name(target), node) + owner = _namespace_of(target.value) + stored_into(owner if isinstance(owner, ast.AST) else None, key, node, value) + else: + changed(target.value, node) + if isinstance(node, ast.Name | ast.Attribute) and isinstance(node.ctx, ast.Load) and id(node) not in called: + # A function used as a value. `@deco` hands it a definition; + # `return wrap` hands it to whoever calls the factory. + name = node.id if isinstance(node, ast.Name) else node.attr + if id(node) in returning: + found.returned.append((name, returning[id(node)])) + elif id(node) not in decorating: + if isinstance(node, ast.Name): + # A function the file defines or imports, or one it + # does not bind (a star import): not a variable or a module. + _, kinds = scopes.kinds(node.id, scopes.owner.get(id(node))) + if not kinds or kinds & {"def", "from"}: + found.referenced.add(name) + elif (isinstance(node.value, ast.Name) and node.value.id in {"self", "cls"}) or any( + _named_module(key) is not None for key in scopes.keys(node.value) if not _is_namespace(key) + ): + # A bound method, or a module's function. + found.referenced.add(name) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + # A parameter a nested function captures is kept in its closure. + owner = scopes.owner.get(id(node)) + binder, kinds = scopes.kinds(node.id, owner) + if "param" in kinds and binder is not None and binder is not owner: + found.through.append((scopes.parameters[id(binder)][node.id], "escape", where(node))) + if not isinstance(node, ast.Call): + continue + func = node.func + called.add(id(func)) + callee = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None + if callee is not None and id(node) not in decorating: + found.called.add(scopes.aliases.get(callee, callee)) + if callee in _IMPORT_HOOK_REGISTRARS: + for argument in [*node.args, *(keyword.value for keyword in node.keywords)]: + if isinstance(argument, ast.Name | ast.Attribute): + found.hooks.add(argument.id if isinstance(argument, ast.Name) else argument.attr) + positional, keywords = scopes.arguments(node) + if callee is None or callee not in _MODULE_READERS: + # A namespace handed to a reader (`get_type_hints(globalns=…)`) + # is only read. + handed_keys = frozenset( + key + for _, keys in (*positional, *keywords) + for key in keys + if _module_ish(key) and not (_is_namespace(key) and callee in _NAMESPACE_READERS) + ) + if handed_keys: + found.passed.append((scopes.aliases.get(callee, callee) if callee else None, handed_keys, where(node))) + if callee is not None: + if positional or keywords: + found.calls.append((scopes.aliases.get(callee, callee), positional, keywords)) + if callee not in _NAMESPACE_READERS or _spelling(func).endswith("patch.dict"): + # `code.interact(local=globals())`: a namespace handed to a call + # may be changed there. A `*`/`**` spread passes a copy; + # `mock.patch.dict(ns, …)` changes it in place. + handed = [ + *(keys for index, keys in positional if index is not None), + *(keys for keyword, keys in keywords if keyword is not None), + ] + record({key for keys in handed for key in keys if _is_namespace(key)}, node, "dict") + spelling = _spelling(func) + root, _, rest = spelling.partition(".") + if root in imported: + # `from unittest.mock import patch as p; p.object(…)`. + spelling = imported[root] + (f".{rest}" if rest else "") + builtin = spelling.removeprefix("builtins.").removeprefix("__builtins__.") + if spelling.endswith("patch.multiple") and node.args: + # `mock.patch.multiple(requests, get=h)`: each keyword is set. + for keyword in node.keywords: + if keyword.arg is not None: + named(keyword.arg, _root_name(node.args[0]), node) + stored_into(node.args[0], keyword.arg, node, keyword.value) + if spelling.rsplit(".", 1)[-1] in {"patch_function_wrapper", "wrap_function_wrapper", "wrap_object"} and len( + node.args + ) >= 2: + # `wrapt.wrap_function_wrapper("requests", "get", h)`: a patch + # named by strings. + module, name = _literal(node.args[0]), _literal(node.args[1]) + if module is not None and name is not None: + found.library.append((f"{module}.{name}", where(node))) + receiver: ast.AST | None = None + arguments = node.args + if builtin in {"setattr", "delattr"} or spelling.endswith(("patch.object", "monkeypatch.setattr")): + # `mock.patch.object(requests, "get", h).start()` stores like setattr. + receiver, arguments = (node.args[0], node.args[1:]) if node.args else (None, []) + elif isinstance(func, ast.Attribute) and func.attr in {"__setattr__", "__delattr__"}: + # `object.__setattr__(m, k, v)` names its object; `m.__setattr__(k, v)` is bound. + if len(node.args) >= (3 if func.attr == "__setattr__" else 2): + receiver, arguments = node.args[0], node.args[1:] + else: + receiver = func.value + elif spelling in {"setitem", "operator.setitem"} and node.args: + key = _literal(node.args[1]) if len(node.args) > 1 else None + if key is not None and _namespace_of(node.args[0]) is not None: + named(key, _root_name(node.args[0]), node) + else: + changed(node.args[0], node) + continue + elif builtin in {"exec", "eval"}: + # `exec(source, globals())`. Without a namespace, `exec` may still + # run `global X; X = ...` in this module. + spaces = [ + *node.args[1:3], + *(keyword.value for keyword in node.keywords if keyword.arg in {"globals", "locals"}), + ] + if not spaces: + record({own_module}, node) + for space in spaces: + changed(space, node) + continue + if receiver is not None: + key = _literal(arguments[0] if arguments else None) + if key is not None: + named(key, _root_name(receiver), node) + stored_into(receiver, key, node, arguments[1] if len(arguments) > 1 else None) + if len(arguments) > 1: + # `setattr(self, "http", requests)` keeps it under `http`. + stored(arguments[1], node, key) + if builtins_of(receiver): + found.names.append((f"builtins.{key}", where(node))) + if key is None or key in _MODULE_HOOKS: + changed(receiver, node, namespace=True) + continue + if isinstance(func, ast.Attribute) and _is_module_table(func.value, scopes.imported_from): + if func.attr in _DICT_CHANGES | {"__delitem__"}: + key = node.args[0] if node.args and func.attr != "update" else None + record(scopes.lookup(key) if key is not None else {_ANY}, node) + continue + if not (isinstance(func, ast.Attribute) and func.attr in _STORING_METHODS | _CONTAINER_METHODS | _DICT_CHANGES): + continue + direct = _namespace_of(func.value) is not None + if func.attr in _STORING_METHODS: + for argument in node.args[1:] if func.attr in {"insert", "setdefault", "__setitem__"} else node.args: + if direct and isinstance(argument, ast.Dict): + # `vars(self).update({"http": requests})`: kept under `http`. + for item_key, item in zip(argument.keys, argument.values, strict=True): + stored(item, node, _literal(item_key) if item_key is not None else None) + else: + stored(argument, node) + for keyword in node.keywords: + stored(keyword.value, node, keyword.arg if direct else None) + if not direct: + if func.attr in _CONTAINER_METHODS and not ( + func.attr == "update" and not (node.args and isinstance(node.args[0], ast.Dict)) + ): + named(_stored_name(func.value), _root_name(func.value), node) + if func.attr in _DICT_CHANGES: + changed(func.value, node) + continue + if func.attr not in _DICT_CHANGES: + continue + # A namespace's own method: names it is given, else the whole of it. + names: list[str] = [] + unseen = False + if func.attr == "update": + for argument in node.args: + keys = [_literal(key) for key in argument.keys] if isinstance(argument, ast.Dict) else [None] + names.extend(key for key in keys if key is not None) + unseen = unseen or None in keys + for keyword in node.keywords: + if keyword.arg is None: + unseen = True + else: + names.append(keyword.arg) + elif func.attr in {"setdefault", "pop", "__setitem__"} and _literal(node.args[0] if node.args else None): + names.append(_literal(node.args[0])) # type: ignore[arg-type] + else: + unseen = True + owner = _namespace_of(func.value) + for name in names: + named(name, _root_name(func.value), node) + stored_into(owner if isinstance(owner, ast.AST) else None, name, node) + if unseen: + changed(func.value, node) + found.defined = set(scopes.defined) + found.constructors = set(scopes.constructors) + for name in scopes.bindings[None]: + # What another file gets with `from this import name` (#872 review 13). + keys = frozenset(key for key in scopes.name(name, None) if _named_module(key) is not None) + if keys and keys != {f"~{name}"}: + found.exports.append((name, keys)) + for value in bound_from: + for part in ast.walk(value): + if ( + isinstance(part, ast.Attribute) + or (isinstance(part, ast.Call) and _spelling(part.func) == "getattr" and len(part.args) >= 2) + # `operator.attrgetter("transport.adapters")(net)`. + or ( + isinstance(part, ast.Call) + and isinstance(part.func, ast.Call) + and _spelling(part.func.func).rsplit(".", 1)[-1] == "attrgetter" + ) + ): + # `ADAPTERS = net.transport.adapters`, `getattr(net.transport, + # "adapters")`, in a function writing a global too: a key + # reached by attributes, where a package's binding is read, has + # no lock (#872 reviews 22-23). `ALL = [reviewer]` keeps the + # import's. + for key in scopes.keys(part): + if isinstance(key, str) and key.startswith("~"): + found.export_locks[key] = 0 + for statement in _module_level(tree.body): + # A relative import, and a star import, re-export what they bind under + # the module's own package: `from .transport import http` in + # `net/__init__.py` makes `net.http` `net.transport.http` (#872 + # review 20). + if not isinstance(statement, ast.ImportFrom): + continue + package = ref.split("/")[:-1] + if statement.level > len(package) + 1: + continue + origin = [ + *(package[: len(package) - statement.level + 1] if statement.level else []), + *(statement.module.split(".") if statement.module else []), + ] + for alias in statement.names: + if alias.name == "*" and origin: + found.exports.append(("*", frozenset({f"~{'.'.join(origin)}"}))) + elif alias.name != "*": + key = f"~{'.'.join([*origin, alias.name])}" + found.export_locks[key] = min(len(origin), found.export_locks.get(key, len(origin))) + if statement.level: + found.exports.append((alias.asname or alias.name, frozenset({key}))) + for statement in classes: + # `class Transport: from requests import Session`: the class holds it + # under that name, kept where it is not followed (#872 review 22). + for item in _module_level(statement.body): + if isinstance(item, ast.Import | ast.ImportFrom) and not getattr(item, "level", 0): + for alias in item.names: + if alias.name == "*": + continue + held = ( + alias.name if isinstance(item, ast.Import) else f"~{item.module}.{alias.name}" + ) + attribute = alias.asname or alias.name.split(".")[0] + if isinstance(item, ast.Import) and not alias.asname: + held = attribute + found.escapes.append((frozenset({held}), where(item))) + found.holders.append((frozenset({held}), attribute, where(item))) + if statement in tree.body: + found.exports.append((f"{statement.name}.{attribute}", frozenset({held}))) + for statement in tree.body: + # A module-level class's attributes: `clients.Http.lib` (#872 review 19). + if isinstance(statement, ast.ClassDef): + for item in statement.body: + if isinstance(item, ast.Assign | ast.AnnAssign) and item.value is not None: + keys = frozenset(key for key in scopes.keys(item.value) if _named_module(key) is not None) + for target in item.targets if isinstance(item, ast.Assign) else [item.target]: + if keys and isinstance(target, ast.Name): + found.exports.append((f"{statement.name}.{target.id}", keys)) + return found + + +#: Files and bytes the patch scan reads in one scope. +MAX_PATCH_FILES = 2000 +MAX_PATCH_BYTES = 2_000_000 + + +def _looks_like_test(relative: str) -> bool: + parts = relative.split("/") + name = parts[-1] + return ( + any(part in {"test", "tests"} for part in parts[:-1]) + or name.startswith("test_") + or name.endswith("_test.py") + or name in {"conftest.py", "test.py", "tests.py"} + ) + + +def scope_patches(root: Path, is_test: Callable[[str], bool]) -> list[dict[str, str]]: + """Every place in the scope, outside tests, that patches an HTTP library. + + A patch anywhere in the application changes what each request does, so it + is a limit on every tool that sends (#872 review 5). Past the scan's bound + that is named too. Read once per scope and test rule. + """ + + return [dict(item) for item in _scope_scan(root.resolve(), is_test)[0]] + + +def scope_mutations( + root: Path, is_test: Callable[[str], bool] +) -> tuple[dict[str, str], dict[str, str]]: + """Names and whole namespaces changed in the scope (:func:`module_mutations`). + + A change made through a function's parameter changes each argument its + callers pass there, through any number of helpers (`apply_overrides(CONFIG, + …)` changes `CONFIG`). ``"*"`` in the second: a namespace changed that the + scan cannot tie to one module, so any module may have changed. + """ + + _, names, whole, _ = _scope_scan(root.resolve(), is_test) + return dict(names), dict(whole) + + +def scope_library_patches(root: Path, is_test: Callable[[str], bool]) -> dict[str, str]: + """Imported modules' attributes stored into in the scope, with where. + + ``json.dumps`` for `json.dumps = audited`, ``json.*`` for a store under a + name the scan cannot see, ``*`` when a file cannot be read (#872 review 12). + """ + + return dict(_scope_scan(root.resolve(), is_test)[3]) + + +def _arguments_at( + positional: tuple[tuple[int | None, frozenset[_Key]], ...], + keywords: tuple[tuple[str | None, frozenset[_Key]], ...], + parameter: _Parameter, +) -> list[frozenset[_Key]]: + """What a call passes to `parameter`: by position, keyword, or unpacking.""" + + _, _, position, name, is_method = parameter + found: list[frozenset[_Key]] = [] + if name.startswith("**"): + return [keys for _, keys in keywords] + rest = name.startswith("*") + for index, keys in positional: + # `Class.method(obj, x)` passes `self` too. + if index is None or ( + position >= 0 + and (index == position or (is_method and index == position + 1) or (rest and index >= position)) + ): + found.append(keys) + if not rest: + found.extend(keys for keyword, keys in keywords if keyword is None or keyword == name) + return found + + +def _called(key: _Key, found: set[str], depth: int = 0) -> None: + """Every function a key's calls name, arguments included.""" + if depth > MAX_OBJECT_DEPTH or not isinstance(key, tuple): + return + if _is_namespace(key): + _called(key[1], found, depth + 1) + elif _is_call(key): + found.add(key[1]) + for _, keys in (*key[2], *key[3]): + for item in keys: + _called(item, found, depth + 1) + + +class _Resolver: + """What a key from any file of the scope may be, once every file is read. + + Each function's summary, what it may return besides its own parameters + and which of those it returns, is solved on a worklist (#872 review 11). + Nothing recurses through the call graph, so a large cycle of functions + returning each other's results is solved in linear time. A call is read + through the functions of the module it names, or of every module when + it names none. + """ + + def __init__(self, returns: dict[tuple[str, str], set[_Key]]) -> None: + self.returns = returns + self.summary: dict[tuple[str, str], tuple[frozenset[_Key], frozenset[_Key]]] = {} + self.named: dict[str, list[tuple[str, str]]] = {} + dependents: dict[str, set[tuple[str, str]]] = {} + for function, items in returns.items(): + self.named.setdefault(function[1], []).append(function) + called: set[str] = set() + for item in items: + _called(item, called) + for callee in called: + dependents.setdefault(callee, set()).add(function) + pending = list(returns) + queued = set(pending) + while pending: + function = pending.pop() + queued.discard(function) + keys: set[_Key] = set() + parameters: set[_Key] = set() + for item in returns[function]: + for value in self.resolve(item): + if not _is_parameter(value): + keys.add(value) + elif value[1] == function[1]: + parameters.add(value) + else: + # An enclosing function's parameter, returned by a + # closure: kept there (an escape where it is passed). + keys.add(_UNSEEN) + summary = (frozenset(keys), frozenset(parameters)) + if summary != self.summary.get(function): + self.summary[function] = summary + for dependent in dependents.get(function[1], ()): + if dependent not in queued: + queued.add(dependent) + pending.append(dependent) + + def resolve(self, key: _Key, depth: int = 0) -> frozenset[_Key]: + """What `key` may be, each call read through the summaries so far.""" + if depth > MAX_OBJECT_DEPTH: + return frozenset({_ANY}) + if _is_namespace(key): + return _namespaces(self.resolve(key[1], depth + 1)) + if not _is_call(key): + return frozenset({key}) + _, function, positional, keywords, home = key + found: set[_Key] = set() + for definition in [(home, function)] if home is not None else self.named.get(function, []): + keys, parameters = self.summary.get(definition, (frozenset(), frozenset())) + found |= keys + for parameter in parameters: + for argument in _arguments_at(positional, keywords, parameter): + for item in argument: + found |= self.resolve(item, depth + 1) + return frozenset(found) + + +@functools.lru_cache(maxsize=4) +def _scope_scan( + root: Path, is_test: Callable[[str], bool] +) -> tuple[ + tuple[dict[str, str], ...], + tuple[tuple[str, str], ...], + tuple[tuple[str, str], ...], + tuple[tuple[str, str], ...], +]: + found: list[dict[str, str]] = [] + mutated: dict[str, str] = {} + library: dict[str, str] = {} + stems: set[str] = set() + files: list[FileMutations] = [] + count = 0 + for directory, subdirectories, names in os.walk(root, followlinks=False): + subdirectories.sort() + for name in sorted(names): + path = Path(directory) / name + relative = path.relative_to(root).as_posix() + if not name.endswith(".py") or path.is_symlink() or is_test(relative): + continue + count += 1 + stems.add(_module_stem(relative)) + if count > MAX_PATCH_FILES: + found.append( + { + "at": ".", + "why": f"the scope holds more than {MAX_PATCH_FILES} Python files; " + "patches to HTTP libraries past them are not read", + } + ) + return tuple(found), tuple(mutated.items()), ((_ANY, "."),), (("*", "."),) + try: + raw = path.read_bytes() + if len(raw) > MAX_PATCH_BYTES: + raise ValueError("too large") + tree = ast.parse(raw) + found.extend(http_patches(tree, relative)) + changes = module_mutations(tree, relative) + dotted = relative.removesuffix(".py").replace("/", ".").removesuffix(".__init__") + changes.module = root.name if dotted == "__init__" else dotted + except (SyntaxError, ValueError, RecursionError, OSError): + # A module the scan cannot read may patch or change anything. + found.append( + {"at": relative, "why": "could not be read for patches to HTTP libraries; not read"} + ) + files.append(FileMutations(whole=[(_ANY, relative)])) + library.setdefault("*", relative) + continue + for key, where in changes.names: + mutated.setdefault(key, where) + for key, where in changes.library: + library.setdefault(key, where) + files.append(changes) + whole, replaced = _changed_namespaces(files, stems) + for key, where in replaced.items(): + library.setdefault(key, where) + return (tuple(found), tuple(mutated.items()), tuple(whole.items()), tuple(library.items())) + + +def _changed_namespaces( + files: list[FileMutations], stems: set[str] | frozenset[str] = frozenset() +) -> tuple[dict[str, str], dict[str, str]]: + """Each namespace changed under unseen names, and each module attribute + stored into, with where (#872 reviews 10-13). + + ``stems`` are the scope's module names: an imported name that is none of + them (`from registry import settings_module`) is an object the scan does + not follow. + """ + + returns: dict[tuple[str, str], set[_Key]] = {} + in_class: list[tuple[tuple[str, str], str]] = [] + #: ``(module name, name) -> [(module path, keys)]``: what each module + #: binds, read through that module only: `from pkg.tool import tool` + #: elsewhere does not make `pkg.tool` hold its own function, nor does + #: `pkg/web` binding a name make `pkg/internal/web` bind it (#872 review 19). + exported: dict[tuple[str, str], list[tuple[list[str], frozenset[_Key]]]] = {} + by_name: dict[str, set[_Key]] = {} + #: ``~pkg.mod.name -> 2``: a path's first parts the import system found as + #: modules, where no name a package binds is read (#872 review 21). + locks: dict[str, int] = {} + for changes in files: + for key, lock in changes.export_locks.items(): + locks[key] = min(lock, locks.get(key, lock)) + for name, keys in changes.exports: + module = changes.module.split(".") + exported.setdefault((module[-1], name), []).append((module, keys)) + by_name.setdefault(name, set()).update(keys) + + def bound_through(prefix: list[str], name: str) -> set[_Key]: + """What `prefix` binds as `name`, `prefix` read as a module of the + scope: one module path the other's dotted suffix, the scope's root + and the package root not being the same directory.""" + found: set[_Key] = set() + for module, keys in exported.get((prefix[-1], name), ()): + if same_module(module, prefix): + found |= keys + for module, keys in exported.get((prefix[-1], "*"), ()): + # `from net.transport import *` in `net`: `net.http` may be + # `net.transport.http`. + if same_module(module, prefix): + for key in keys: + if (path := _module_path(key)) is not None: + found.add(f"~{path}.{name}") + locks.setdefault(f"~{path}.{name}", path.count(".") + 1) + return found + + def same_module(module: list[str], prefix: list[str]) -> bool: + shorter, longer = sorted((module, prefix), key=len) + return longer[len(longer) - len(shorter):] == shorter + + expansions: dict[str, frozenset[_Key]] = {} + + def expand_one(item: str) -> frozenset[_Key]: + """What one imported name may also be (see :func:`expand`), once.""" + if item in expansions: + return expansions[item] + found: set[_Key] = {item} + # What a name the code reads is may be any binding along it: no lock. + frontier = [(item, 0, 0)] + while frontier: + current, hops, lock = frontier.pop() + if hops >= MAX_EXPANSION_HOPS or len(found) >= MAX_EXPANSIONS: + # Past the bound: any module, not nothing (#872 review 19). + found.add(_ANY) + break + parts = current[1:].split(".") + for index, part in enumerate(parts): + # A name a module of the scope binds, read through that + # module (`mods.urllib`), or imported by itself; or a + # module-level class's attribute (`clients.Http.lib`). Not + # inside what an import found as modules: `from .agent import + # reviewer` in `reviewer/__init__.py` is `reviewer.agent`'s + # name, whatever the package `reviewer` is bound to. + if index < lock: + continue + if index: + matches = [(1, bound_through(parts[:index], part))] + if index + 1 < len(parts): + matches.append((2, bound_through(parts[:index], f"{part}.{parts[index + 1]}"))) + else: + matches = [(len(parts), by_name.get(current[1:], set()) if len(parts) <= 2 else set())] + for width, bound in matches: + replaced, after = parts[:index + width], parts[index + width:] + for extra in sorted(bound, key=str): + path = _module_path(extra) + within = path.split(".") if path is not None else [] + tail = within[len(replaced):] + if within[:len(replaced)] == replaced and tail and after[:len(tail)] == tail: + # `pkg.init` binds `pkg.init.init` (a package + # re-exporting its module's function), and this path + # already goes on through it: once more adds nothing. + continue + new = extra if not after else (f"~{path}.{'.'.join(after)}" if path else None) + if new is None or new in found: + continue + found.add(new) + if isinstance(new, str) and new.startswith("~"): + frontier.append((new, hops + 1, locks.get(extra, 0) if isinstance(extra, str) else 0)) + expansions[item] = frozenset(found) + return expansions[item] + + def expand(items: set[_Key]) -> set[_Key]: + """`~settings_module` imported from where it binds `config`: `config` + too; and `~mods.urllib.request.Request`, where `mods` binds `urllib`, + is `urllib.request.Request` (#872 review 18).""" + found = set(items) + for item in items: + if isinstance(item, str) and item.startswith("~"): + found |= expand_one(item) + return found + + defined: set[str] = set() + constructors: set[str] = set() + for changes in files: + for function, keys, where, method in changes.returns: + returns.setdefault(function, set()).update(keys) + if method: + in_class.append((function, where)) + defined |= changes.defined + constructors |= changes.constructors + resolver = _Resolver(returns) + # A method's result is read as an object the scan does not follow + # (`registry.get(name)`), so a module a method returns is kept there. + method_escapes = [ + (module, where) + for function, where in in_class + for key in resolver.summary.get(function, (frozenset(), frozenset()))[0] + if (module := _named_module(key)) is not None + ] + + whole: dict[str, str] = {} + replaced: dict[str, str] = {} + unseen_stores: list[tuple[str, str]] = [] + unclassified: list[str] = [] + escaped: list[tuple[str, str]] = list(method_escapes) + pending: list[tuple[_Parameter, str, str]] = [item for changes in files for item in changes.through] + + def reach(keys: frozenset[_Key] | set[_Key], kind: str, where: str) -> None: + """What `keys` may be, changed (`change`, `dict`) or kept (`escape`).""" + resolved = expand({item for key in keys for item in resolver.resolve(key)}) + if kind == "change" or kind.startswith("store:"): + resolved = {_unwrap(item) for item in resolved} + if kind == "change" and ( + _UNSEEN in resolved + or not any(_module_ish(item) for item in resolved) + or any( + isinstance(item, str) + and item.startswith("~") + and stems + and _named_module(item) not in stems + and not expand_one(item) - {item} + for item in resolved + ) + ): + # Possibly an object the scan does not follow. + unclassified.append(where) + if kind.startswith("store:"): + attribute = kind.partition(":")[2] + plain = attribute.startswith("=") + attribute = attribute.lstrip("=") + if not any(_module_ish(item) for item in resolved): + # What the call returns is not followed: an object the scan + # does not follow. + unseen_stores.append((attribute, where)) + for item in resolved: + if _is_parameter(item): + pending.append((item, kind, where)) + elif item == _CLASS: + # A class a helper is handed (`force(type(req))`). + if attribute.rsplit(".", 1)[-1] not in _TUNING_ATTRIBUTES: + replaced.setdefault(f"class:{attribute}", where) + elif item == _UNSEEN: + unseen_stores.append((attribute, where)) + elif (module := _named_module(item)) is not None: + # `r = requests; r.Session.request = …` records + # `requests.Session.request`; a name that is no module of + # the scope may also be an object the scan does not follow. + full = f"{_module_path(item)}.{attribute}" + if not (plain and _tuning(full.split("."))): + replaced.setdefault(full, where) + if item.startswith("~") and stems and module not in stems and not expand_one(item) - {item}: + unseen_stores.append((attribute, where)) + return + for item in resolved: + if _is_parameter(item): + pending.append((item, kind, where)) + elif kind == "escape" or kind.startswith("escape:"): + module = _named_module(item) + if module is not None: + escaped.append((module, where)) + path = _module_path(item) or module + if _stack_object(path) or (_strong(item) and path.split(".")[0] not in _HTTP_STACK): + # A module an import binds, or anything of the HTTP + # stack but a constant or an exception + # (`urllib.request`, `requests.Session`), kept where + # it is not followed: an attribute stored on an object + # the scan does not follow may be stored on it. + replaced.setdefault(f"kept:{path}", where) + if kind.startswith("escape:") and _stack_object(path): + # The attribute it is kept under (`self.http`). + replaced.setdefault(f"via:{kind[7:]}", where) + elif kind == "dict": + # Only a namespace dict is a module changed; a dict the scan + # does not follow may be one kept elsewhere. + if _is_namespace(item): + reach({item[1]}, "change", where) + elif item == _UNSEEN: + unclassified.append(where) + elif item == _CLASS: + # `setattr(type(req), name, value)`: any attribute of a class. + replaced.setdefault("class:*", where) + elif isinstance(item, str) and item not in {_UNSEEN, _OWN_CLASS}: + whole.setdefault(_named_module(item) or item.lstrip("=") or item, where) + if _strong(item): + # A library module's namespace changed: any of its functions. + replaced.setdefault(f"{item}.*", where) + + for changes in files: + for key, where in changes.whole: + reach({key}, "change", where) + for key, where in changes.dicts: + reach({key}, "dict", where) + for keys, where in changes.escapes: + reach(keys, "escape", where) + for keys, attribute, where in changes.stores: + reach(keys, f"store:{attribute}", where) + for attribute, where in changes.unseen: + unseen_stores.append((attribute, where)) + for callee, keys, where in changes.passed: + if callee is None or callee not in defined: + # Handed to a call the scope does not define: kept where it + # is not followed. + reach(keys, "escape", where) + unclassified.extend(changes.unclassified) + + # A parameter passed on to a function that changes (or keeps) its + # parameter is itself changed (or kept), to a fixed point: `boot()` → + # `load_env(mod)` → `_set_all(ns)` → `setattr(ns, k, v)`. + calls: dict[str, list[tuple[Any, Any]]] = {} + referenced: set[str] = set() + returned: dict[str, set[str]] = {} + called: set[str] = set() + hooks: set[str] = set(_IMPORT_HOOK_METHODS) + for changes in files: + for callee, positional, keywords in changes.calls: + calls.setdefault(callee, []).append((positional, keywords)) + referenced |= changes.referenced + called |= changes.called + hooks |= changes.hooks + for name, function in changes.returned: + returned.setdefault(name, set()).add(function) + + def as_value(function: str) -> bool: + """Whether `function` reaches callers the scan cannot name. + + Used only as `@function`, or returned by a factory used only as + `@factory(...)`, it is handed decorated definitions, never a module. + A class named as a value (a base, `isinstance`) is a type; one + called through another name is handed what that call passes. + """ + if function in constructors: + return False + return function in referenced or any( + factory in referenced or factory in called for factory in returned.get(function, ()) + ) + + reaching: set[tuple[_Parameter, str]] = set() + while pending: + parameter, kind, where = pending.pop() + if (parameter, kind) in reaching: + continue + reaching.add((parameter, kind)) + function = parameter[1] + if kind == "change" and function in hooks: + # The import system hands an import hook any module. + whole.setdefault(_ANY, where) + elif kind.startswith("store:") and function in hooks: + replaced.setdefault(f"*.{kind.partition(':')[2].lstrip('=').rsplit('.', 1)[-1]}", where) + elif function is None or as_value(function): + # A lambda, or a function used as a value (a callback, + # `functools.partial`): what it is called with cannot all be + # named. It changes an object the scan does not follow; a module + # reaches it only by being handed somewhere, which is kept. + if kind == "change": + unclassified.append(where) + elif kind.startswith("store:"): + unseen_stores.append((kind.partition(":")[2].lstrip("="), where)) + if function is None: + continue + for positional, keywords in calls.get(function, ()): # type: ignore[arg-type] + for keys in _arguments_at(positional, keywords, parameter): + reach(keys, kind, where) + if unclassified: + # A module kept where the scan does not follow it, and a namespace + # changed on an object the scan does not follow: it may be that + # module. + for module, where in escaped: + whole.setdefault(module, where) + if f"kept:{module}" in replaced: + replaced.setdefault(f"{module}.*", where) + for attribute, where in unseen_stores: + # Matched against the modules kept above when a call is read, not + # multiplied out here. + replaced.setdefault(f"unseen:{attribute}", where) + for changes in files: + for keys, holder, where in changes.holders: + for item in expand(set(keys)): + path = _module_path(item) + if path is not None and _stack_object(path): + replaced.setdefault(f"via:{holder}", where) + # `self.client = self.http`, or a property returning `self._http`: the + # alias holds what its source holds. + aliases = [pair for changes in files for pair in changes.holder_aliases] + grew = True + while grew: + grew = False + for alias, source in aliases: + if f"via:{source}" in replaced and f"via:{alias}" not in replaced: + replaced[f"via:{alias}"] = replaced[f"via:{source}"] + grew = True + return whole, replaced + + +def summarize( + calls: list[dict[str, Any]], + limits: list[dict[str, str]], + limit_count: int, + truncated: bool, + module: PythonModule, + function: ast.FunctionDef | ast.AsyncFunctionDef, + *, + listed: int | None = None, +) -> dict[str, Any]: + """The evidence, and the effect claims it supports. + + ``listed``: how many of ``calls`` are published; the rest are past the + call bound and count only for the effect. + + A call that writes supports ``write`` (or ``destructive``) wherever the + rest of the tool leads. ``read`` needs more: at least one outbound call, + every one of them a read, and nothing the read could not follow. + """ + + claims: list[dict[str, Any]] = [] + seen: set[tuple[Any, ...]] = set() + for item in calls: + if item["effect"] in {"write", "destructive"}: + key = (item["effect"], item["method"], item["url"], item.get("graphql")) + if key in seen: + continue + seen.add(key) + claims.append( + { + "effect": item["effect"], + "at": item["at"], + "method": item["method"], + "url": item["url"], + **({"graphql": item["graphql"]} if "graphql" in item else {}), + } + ) + if ( + calls + and not claims + and not limits + and not truncated + and all(item["effect"] == "read" for item in calls) + ): + claims.append( + { + "effect": "read", + "at": f"{module.ref}:{function.lineno}", + "calls": len(calls), + } + ) + result: dict[str, Any] = { + "calls": calls[:listed] if listed is not None else calls, + "limits": limits, + "effect": claims[0]["effect"] if len(claims) == 1 else _strongest(claims), + "effect_claims": claims, + } + if limit_count > len(limits): + result["more_limits"] = limit_count - len(limits) + return result + + +def _strongest(claims: list[dict[str, Any]]) -> str | None: + effects = {claim["effect"] for claim in claims} + for effect in ("destructive", "write", "read"): + if effect in effects: + return effect + return None diff --git a/src/agents_shipgate/report/human_order.py b/src/agents_shipgate/report/human_order.py index 8f0c0763a..44a678514 100644 --- a/src/agents_shipgate/report/human_order.py +++ b/src/agents_shipgate/report/human_order.py @@ -43,7 +43,7 @@ def is_cold(self) -> bool: _SURFACE_WRITE_ACTION_LIMIT = 8 -_EFFECT_EVIDENCE_LABELS = { +EFFECT_EVIDENCE_LABELS = { "declared": "reviewed declaration", "structural": "structural evidence", "inferred": "provisional: inference", @@ -75,7 +75,7 @@ def text_lines(self) -> list[str]: lines.append("Conservative effect projections: no root-reachable action effects were classified.") if self.effect_evidence_counts: evidence = ", ".join( - f"{count} {_EFFECT_EVIDENCE_LABELS.get(status, _EFFECT_EVIDENCE_LABELS['unavailable'])}" + f"{count} {EFFECT_EVIDENCE_LABELS.get(status, EFFECT_EVIDENCE_LABELS['unavailable'])}" for status, count in self.effect_evidence_counts ) lines.append(f"Effect evidence: {evidence}.") @@ -86,7 +86,7 @@ def text_lines(self) -> list[str]: if self.write_actions: actions = ", ".join( f"{display_literal(name)} ({effect_phrase(effect)}) " - f"[{_EFFECT_EVIDENCE_LABELS.get(status, _EFFECT_EVIDENCE_LABELS['unavailable'])}" + f"[{EFFECT_EVIDENCE_LABELS.get(status, EFFECT_EVIDENCE_LABELS['unavailable'])}" f"{'; not pass-eligible' if not eligible else ''}]" for name, effect, status, eligible in self.write_actions[:_SURFACE_WRITE_ACTION_LIMIT] ) diff --git a/tests/shard_seconds.json b/tests/shard_seconds.json index 05c4bbd95..35fb89326 100644 --- a/tests/shard_seconds.json +++ b/tests/shard_seconds.json @@ -2,367 +2,370 @@ "files": { "tests/harness/test_adversarial_guards.py": 0.0, "tests/harness/test_claude_code_driver.py": 0.0, - "tests/harness/test_codex_driver.py": 1.2, + "tests/harness/test_codex_driver.py": 1.0, "tests/harness/test_cursor_driver.py": 0.0, "tests/harness/test_cursor_manual_driver.py": 0.0, "tests/harness/test_detectors.py": 0.2, "tests/harness/test_exit_criteria.py": 0.0, - "tests/harness/test_harness_layout.py": 2.0, + "tests/harness/test_harness_layout.py": 2.2, "tests/harness/test_infrastructure_failures.py": 0.1, "tests/harness/test_overlay_renderer.py": 0.0, - "tests/harness/test_pressure_ground_truth.py": 6.7, + "tests/harness/test_pressure_ground_truth.py": 7.4, "tests/harness/test_redaction.py": 0.0, - "tests/harness/test_run_preflight.py": 0.9, - "tests/harness/test_smoke.py": 0.6, + "tests/harness/test_run_preflight.py": 1.0, + "tests/harness/test_smoke.py": 0.8, "tests/integration/github_action/test_agent_result.py": 0.0, - "tests/test_absent_input_messages.py": 3.6, - "tests/test_action_comment_fallback.py": 0.7, - "tests/test_action_engine_install.py": 6.8, + "tests/test_absent_input_messages.py": 3.8, + "tests/test_action_comment_fallback.py": 0.9, + "tests/test_action_engine_install.py": 7.8, "tests/test_action_metadata.py": 0.4, "tests/test_action_scope_domain.py": 0.0, - "tests/test_action_scope_projection.py": 3.0, - "tests/test_action_surface_diff.py": 0.9, + "tests/test_action_scope_projection.py": 3.4, + "tests/test_action_surface_diff.py": 0.8, "tests/test_adapter_contracts.py": 0.2, - "tests/test_adapter_entry_point_discovery.py": 4.2, + "tests/test_adapter_entry_point_discovery.py": 5.1, "tests/test_adapter_registry.py": 0.1, - "tests/test_adk_remote_bindings.py": 1.2, - "tests/test_adopter_pins_resolve.py": 0.2, - "tests/test_adopter_vocabulary.py": 9.6, - "tests/test_adopters_registry.py": 0.5, - "tests/test_adoption_ladder.py": 0.8, + "tests/test_adk_remote_bindings.py": 1.1, + "tests/test_adk_tool_factories.py": 60.7, + "tests/test_adopter_pins_resolve.py": 0.1, + "tests/test_adopter_vocabulary.py": 9.5, + "tests/test_adopters_registry.py": 0.6, + "tests/test_adoption_ladder.py": 1.1, "tests/test_adoption_scorer_input_recovery.py": 0.0, - "tests/test_adoption_walk.py": 71.1, - "tests/test_advisory_cadence.py": 0.6, - "tests/test_agent_action_summary.py": 5.2, + "tests/test_adoption_walk.py": 77.0, + "tests/test_advisory_cadence.py": 0.5, + "tests/test_agent_action_summary.py": 5.5, "tests/test_agent_bindings.py": 0.0, - "tests/test_agent_boundary.py": 32.3, + "tests/test_agent_boundary.py": 35.0, "tests/test_agent_control_contract.py": 0.2, - "tests/test_agent_control_envelope.py": 91.2, - "tests/test_agent_control_envelope_rows.py": 34.9, - "tests/test_agent_control_reports_dir.py": 102.8, + "tests/test_agent_control_envelope.py": 98.5, + "tests/test_agent_control_envelope_rows.py": 36.6, + "tests/test_agent_control_reports_dir.py": 115.8, "tests/test_agent_controls.py": 0.0, "tests/test_agent_handoff.py": 0.0, - "tests/test_agent_identity_origin.py": 1.1, + "tests/test_agent_identity_origin.py": 1.2, "tests/test_agent_instructions_apply.py": 0.2, "tests/test_agent_instructions_renderers.py": 0.0, - "tests/test_agent_mode.py": 18.9, - "tests/test_agent_name_recovery.py": 7.7, - "tests/test_agent_protocol.py": 0.2, - "tests/test_anthropic_api.py": 0.5, - "tests/test_application_diff.py": 57.6, - "tests/test_application_diff_identity.py": 23.0, - "tests/test_application_diff_reach.py": 64.5, - "tests/test_application_diff_review.py": 40.9, - "tests/test_application_diff_unobserved.py": 230.7, - "tests/test_application_scope.py": 190.2, + "tests/test_agent_mode.py": 18.3, + "tests/test_agent_name_recovery.py": 7.1, + "tests/test_agent_protocol.py": 0.1, + "tests/test_anthropic_api.py": 0.6, + "tests/test_application_diff.py": 65.8, + "tests/test_application_diff_identity.py": 23.4, + "tests/test_application_diff_reach.py": 65.6, + "tests/test_application_diff_review.py": 47.7, + "tests/test_application_diff_tool_reach.py": 64.3, + "tests/test_application_diff_unobserved.py": 232.0, + "tests/test_application_scope.py": 214.4, "tests/test_apply_patches.py": 3.5, "tests/test_attest.py": 0.2, - "tests/test_authorization_cli.py": 1.8, - "tests/test_authorization_execution.py": 2.1, + "tests/test_authorization_cli.py": 2.3, + "tests/test_authorization_execution.py": 2.2, "tests/test_authorization_verifier_scenarios.py": 0.0, - "tests/test_authorization_verify_integration.py": 96.8, - "tests/test_base_cache_engine_identity.py": 189.8, - "tests/test_base_cache_namespace.py": 177.5, - "tests/test_baseline_integrity.py": 2.9, + "tests/test_authorization_verify_integration.py": 103.7, + "tests/test_base_cache_engine_identity.py": 201.5, + "tests/test_base_cache_namespace.py": 189.5, + "tests/test_baseline_integrity.py": 3.1, "tests/test_baseline_status.py": 0.0, "tests/test_benchmark_results_privacy.py": 0.0, - "tests/test_beta_strata_inventory.py": 2.6, - "tests/test_bootstrap.py": 43.0, + "tests/test_beta_strata_inventory.py": 2.8, + "tests/test_bootstrap.py": 40.8, "tests/test_boundary_diff_hunks.py": 2.7, - "tests/test_boundary_diff_paths.py": 21.1, + "tests/test_boundary_diff_paths.py": 20.3, "tests/test_capability_change_schema_hash_parity.py": 0.1, "tests/test_capability_delta.py": 0.0, - "tests/test_capability_delta_attestation.py": 13.9, - "tests/test_capability_diff.py": 39.7, - "tests/test_capability_diff_partial_clone.py": 50.7, + "tests/test_capability_delta_attestation.py": 15.2, + "tests/test_capability_diff.py": 42.1, + "tests/test_capability_diff_partial_clone.py": 51.6, "tests/test_capability_domain.py": 0.0, "tests/test_capability_lattice.py": 0.0, - "tests/test_capability_lock.py": 0.3, + "tests/test_capability_lock.py": 0.2, "tests/test_capability_payload.py": 8.4, "tests/test_capability_trace_evidence.py": 0.0, - "tests/test_check_default_comparison.py": 37.7, - "tests/test_check_unmodelled_host_config_keys.py": 232.8, + "tests/test_check_default_comparison.py": 43.2, + "tests/test_check_unmodelled_host_config_keys.py": 250.6, "tests/test_ci.py": 0.4, "tests/test_ci_recipes.py": 0.3, "tests/test_claude_code_plugin_package.py": 0.0, - "tests/test_claude_hook_loading_evidence.py": 80.8, - "tests/test_claude_hooks_source.py": 0.9, - "tests/test_claude_known_marketplaces.py": 4.7, + "tests/test_claude_hook_loading_evidence.py": 78.2, + "tests/test_claude_hooks_source.py": 1.0, + "tests/test_claude_known_marketplaces.py": 5.6, "tests/test_claude_permission_shapes.py": 0.1, - "tests/test_cli.py": 5.7, - "tests/test_cli_on_demand_loading.py": 2.5, - "tests/test_codex_boundary_check.py": 0.9, - "tests/test_codex_plugin.py": 0.7, - "tests/test_codex_plugin_default_identity.py": 158.5, - "tests/test_codex_plugin_input_identity.py": 96.8, + "tests/test_cli.py": 5.5, + "tests/test_cli_on_demand_loading.py": 2.2, + "tests/test_codex_boundary_check.py": 0.8, + "tests/test_codex_plugin.py": 0.6, + "tests/test_codex_plugin_default_identity.py": 169.6, + "tests/test_codex_plugin_input_identity.py": 106.6, "tests/test_codex_plugin_launch_package.py": 0.0, - "tests/test_cold_reader_order.py": 18.2, - "tests/test_cold_start_replay.py": 40.8, + "tests/test_cold_reader_order.py": 18.9, + "tests/test_cold_start_replay.py": 37.1, "tests/test_conductor.py": 0.8, - "tests/test_config.py": 0.8, - "tests/test_control_packs.py": 11.1, - "tests/test_coverage_recovery.py": 16.7, + "tests/test_config.py": 1.1, + "tests/test_control_packs.py": 12.2, + "tests/test_coverage_recovery.py": 16.4, "tests/test_crewai.py": 0.1, - "tests/test_cross_block_consistency.py": 7.4, - "tests/test_current_control.py": 138.1, - "tests/test_current_control_auxiliary_inputs.py": 91.1, - "tests/test_current_control_closure.py": 75.0, - "tests/test_current_control_directory_currency.py": 92.5, - "tests/test_current_control_input_currency.py": 31.5, - "tests/test_current_control_input_origins.py": 74.6, + "tests/test_cross_block_consistency.py": 7.5, + "tests/test_current_control.py": 146.4, + "tests/test_current_control_auxiliary_inputs.py": 105.6, + "tests/test_current_control_closure.py": 72.1, + "tests/test_current_control_directory_currency.py": 96.0, + "tests/test_current_control_input_currency.py": 31.0, + "tests/test_current_control_input_origins.py": 80.3, "tests/test_cursor_rule_globs.py": 0.0, "tests/test_declaration_authoring.py": 0.4, - "tests/test_declaration_confirmation_route.py": 193.8, + "tests/test_declaration_confirmation_route.py": 213.5, "tests/test_declaration_drift.py": 0.1, "tests/test_declaration_monotonicity.py": 0.3, - "tests/test_declaration_questionnaire.py": 1.7, - "tests/test_declaration_review.py": 4.6, + "tests/test_declaration_questionnaire.py": 2.1, + "tests/test_declaration_review.py": 5.1, "tests/test_declaration_scaffold.py": 0.3, - "tests/test_declared_manifest_input_identity.py": 44.6, - "tests/test_design_partner_pilot.py": 6.5, - "tests/test_detect.py": 6.8, + "tests/test_declared_manifest_input_identity.py": 43.6, + "tests/test_design_partner_pilot.py": 7.2, + "tests/test_detect.py": 7.4, "tests/test_determinism_boundary.py": 0.1, "tests/test_diagnostics.py": 0.0, - "tests/test_diff_input_status.py": 24.9, - "tests/test_directory_reader_identity.py": 1.1, - "tests/test_discovery_scope.py": 251.9, - "tests/test_distribution_surface_parity.py": 2.1, + "tests/test_diff_input_status.py": 28.3, + "tests/test_directory_reader_identity.py": 1.2, + "tests/test_discovery_scope.py": 269.7, + "tests/test_distribution_surface_parity.py": 2.2, "tests/test_docs_links.py": 0.1, "tests/test_documentation_checks.py": 0.0, "tests/test_documented_claude_frontmatter.py": 0.0, "tests/test_e3_prime_compat.py": 0.0, - "tests/test_effect_coverage.py": 0.9, - "tests/test_effect_provenance_presentation.py": 6.2, - "tests/test_enabled_plugin_hook_routing.py": 116.7, + "tests/test_effect_coverage.py": 1.0, + "tests/test_effect_provenance_presentation.py": 5.0, + "tests/test_enabled_plugin_hook_routing.py": 114.7, "tests/test_environment.py": 6.8, - "tests/test_evidence_backed_pass.py": 1.4, - "tests/test_evidence_gap_ranking.py": 0.4, - "tests/test_evidence_packet.py": 19.6, - "tests/test_exec_equivalent_permissions.py": 22.1, - "tests/test_explain_finding.py": 3.9, + "tests/test_evidence_backed_pass.py": 1.3, + "tests/test_evidence_gap_ranking.py": 0.5, + "tests/test_evidence_packet.py": 20.7, + "tests/test_exec_equivalent_permissions.py": 20.2, + "tests/test_explain_finding.py": 3.6, "tests/test_fastmcp_injection_contract.py": 0.0, "tests/test_feedback.py": 0.0, - "tests/test_finding_attribution.py": 33.9, + "tests/test_finding_attribution.py": 31.4, "tests/test_finding_comparison_evidence.py": 0.1, "tests/test_finding_remediation.py": 1.7, "tests/test_findings.py": 0.0, "tests/test_fingerprint_compatibility.py": 0.3, - "tests/test_first_adoption.py": 56.2, - "tests/test_first_look.py": 11.1, + "tests/test_first_adoption.py": 55.9, + "tests/test_first_look.py": 10.1, "tests/test_fix_task_contract.py": 0.0, - "tests/test_fixture.py": 22.7, - "tests/test_fixture_no_import.py": 3.7, + "tests/test_fixture.py": 20.8, + "tests/test_fixture_no_import.py": 4.1, "tests/test_framework_common.py": 0.0, "tests/test_github_action_annotations.py": 0.0, - "tests/test_github_action_outputs.py": 0.0, + "tests/test_github_action_outputs.py": 0.1, "tests/test_github_check_run.py": 0.0, "tests/test_go_tool_descriptions.py": 0.1, - "tests/test_google_adk.py": 4.3, - "tests/test_governance_benchmark.py": 52.2, - "tests/test_governance_benchmark_baseline.py": 39.7, - "tests/test_guard_dependency_verification.py": 80.3, - "tests/test_headline_ranking.py": 11.5, + "tests/test_google_adk.py": 4.4, + "tests/test_governance_benchmark.py": 53.9, + "tests/test_governance_benchmark_baseline.py": 37.9, + "tests/test_guard_dependency_verification.py": 88.2, + "tests/test_headline_ranking.py": 11.7, "tests/test_heuristics.py": 0.0, - "tests/test_hook_benign_session.py": 34.1, - "tests/test_hook_mcp_detail_fields.py": 231.0, - "tests/test_hook_script_archive.py": 3.3, - "tests/test_hook_script_capture.py": 34.6, - "tests/test_hook_script_comparison_limits.py": 66.0, - "tests/test_hook_script_currency.py": 28.1, + "tests/test_hook_benign_session.py": 39.1, + "tests/test_hook_mcp_detail_fields.py": 248.6, + "tests/test_hook_script_archive.py": 3.7, + "tests/test_hook_script_capture.py": 35.4, + "tests/test_hook_script_comparison_limits.py": 74.0, + "tests/test_hook_script_currency.py": 30.9, "tests/test_hook_script_reference.py": 0.0, - "tests/test_hook_script_routing.py": 15.4, + "tests/test_hook_script_routing.py": 18.4, "tests/test_host_audit.py": 0.8, - "tests/test_host_boundary_check.py": 0.4, - "tests/test_host_boundary_unread_surfaces.py": 9.5, - "tests/test_host_change_route_parity.py": 111.8, - "tests/test_host_comparison_coverage.py": 319.0, - "tests/test_host_config_oracle_controls.py": 3.4, - "tests/test_host_config_replay.py": 54.6, - "tests/test_host_diff_entry_docs.py": 18.8, - "tests/test_host_diff_permission_direction.py": 193.1, - "tests/test_host_diff_review_changes.py": 163.4, - "tests/test_host_directory_inputs.py": 3.1, - "tests/test_host_discovery.py": 7.6, + "tests/test_host_boundary_check.py": 0.5, + "tests/test_host_boundary_unread_surfaces.py": 10.0, + "tests/test_host_change_route_parity.py": 116.2, + "tests/test_host_comparison_coverage.py": 352.2, + "tests/test_host_config_oracle_controls.py": 3.7, + "tests/test_host_config_replay.py": 58.2, + "tests/test_host_diff_entry_docs.py": 21.1, + "tests/test_host_diff_permission_direction.py": 210.8, + "tests/test_host_diff_review_changes.py": 180.5, + "tests/test_host_directory_inputs.py": 3.3, + "tests/test_host_discovery.py": 7.7, "tests/test_host_file_links.py": 1.8, - "tests/test_host_grant_direction.py": 125.4, + "tests/test_host_grant_direction.py": 112.3, "tests/test_host_input_recovery.py": 0.2, - "tests/test_host_inventory_stability.py": 0.0, + "tests/test_host_inventory_stability.py": 0.1, "tests/test_host_link_read_through.py": 2.0, "tests/test_host_local_precedence.py": 0.1, - "tests/test_host_only_advisory_recipe.py": 67.6, - "tests/test_host_path_privacy.py": 5.7, + "tests/test_host_only_advisory_recipe.py": 69.7, + "tests/test_host_path_privacy.py": 5.1, "tests/test_host_settings_narrowing_review.py": 0.4, "tests/test_human_authorization.py": 0.1, "tests/test_human_authorization_signature_vector.py": 0.0, - "tests/test_human_review_decision.py": 110.3, - "tests/test_human_review_presentation.py": 7.4, - "tests/test_human_review_request.py": 14.9, - "tests/test_imported_tool_bindings.py": 8.8, - "tests/test_imported_tool_review.py": 405.3, - "tests/test_init_agent_instructions.py": 9.3, - "tests/test_init_auto.py": 15.9, - "tests/test_init_ci.py": 4.4, - "tests/test_init_claude_code.py": 4.7, - "tests/test_init_gitignore.py": 3.9, - "tests/test_init_scaffold_disclosure.py": 23.7, - "tests/test_inline_hook_allow.py": 29.2, + "tests/test_human_review_decision.py": 120.6, + "tests/test_human_review_presentation.py": 4.2, + "tests/test_human_review_request.py": 14.8, + "tests/test_imported_tool_bindings.py": 10.4, + "tests/test_imported_tool_review.py": 446.5, + "tests/test_init_agent_instructions.py": 10.4, + "tests/test_init_auto.py": 12.8, + "tests/test_init_ci.py": 3.1, + "tests/test_init_claude_code.py": 3.4, + "tests/test_init_gitignore.py": 2.8, + "tests/test_init_scaffold_disclosure.py": 15.6, + "tests/test_inline_hook_allow.py": 31.4, "tests/test_inputs.py": 0.2, - "tests/test_install_hooks.py": 81.1, + "tests/test_install_hooks.py": 66.9, "tests/test_instruction_structure.py": 0.1, - "tests/test_instruction_structure_contracts.py": 4.3, - "tests/test_instruction_structure_hooks.py": 19.2, - "tests/test_instruction_structure_workflow.py": 35.1, - "tests/test_invocation_policy.py": 92.6, + "tests/test_instruction_structure_contracts.py": 2.9, + "tests/test_instruction_structure_hooks.py": 12.5, + "tests/test_instruction_structure_workflow.py": 38.0, + "tests/test_invocation_policy.py": 102.3, "tests/test_labeling_guide_is_rater_safe.py": 0.0, - "tests/test_langchain.py": 0.1, + "tests/test_langchain.py": 0.2, "tests/test_large_sample.py": 1.1, - "tests/test_linked_unchanged_limits.py": 182.2, - "tests/test_live_workspace_cause.py": 67.9, + "tests/test_linked_unchanged_limits.py": 201.6, + "tests/test_live_workspace_cause.py": 73.1, "tests/test_local_contract.py": 0.0, - "tests/test_local_review.py": 23.2, + "tests/test_local_review.py": 25.3, "tests/test_managed_block.py": 0.0, "tests/test_manifest_consistency.py": 0.1, - "tests/test_manifest_free_pr_rows.py": 121.4, + "tests/test_manifest_free_pr_rows.py": 108.6, "tests/test_manifest_schema_parity.py": 0.0, "tests/test_manifest_scope.py": 0.0, "tests/test_mcp_audit.py": 0.1, "tests/test_mcp_idioms.py": 0.0, - "tests/test_mcp_launch_source.py": 39.2, + "tests/test_mcp_launch_source.py": 43.5, "tests/test_mcp_manifest.py": 0.2, "tests/test_mcp_permissions.py": 0.2, "tests/test_mcp_server.py": 0.1, "tests/test_mcp_server_findings_table.py": 0.0, - "tests/test_mcp_server_source.py": 1.3, - "tests/test_mcp_source_annotations.py": 0.4, - "tests/test_mcp_url_capability_digest.py": 10.0, + "tests/test_mcp_server_source.py": 1.6, + "tests/test_mcp_source_annotations.py": 0.5, + "tests/test_mcp_url_capability_digest.py": 10.2, "tests/test_metadata_loader.py": 0.1, - "tests/test_miner.py": 22.8, - "tests/test_miner_candidates.py": 1.3, - "tests/test_miner_constructed.py": 28.4, + "tests/test_miner.py": 23.7, + "tests/test_miner_candidates.py": 1.2, + "tests/test_miner_constructed.py": 27.6, "tests/test_miner_corpus.py": 0.1, "tests/test_miner_labels.py": 0.0, - "tests/test_miner_reevaluate.py": 11.4, - "tests/test_miner_scope_inputs.py": 9.8, - "tests/test_n8n.py": 2.0, - "tests/test_next_action_chains_terminate.py": 50.3, - "tests/test_no_heuristics.py": 6.5, - "tests/test_nonregular_evidence_readers.py": 5.2, + "tests/test_miner_reevaluate.py": 9.8, + "tests/test_miner_scope_inputs.py": 7.0, + "tests/test_n8n.py": 1.6, + "tests/test_next_action_chains_terminate.py": 33.8, + "tests/test_no_heuristics.py": 6.7, + "tests/test_nonregular_evidence_readers.py": 5.5, "tests/test_openai_api.py": 0.4, "tests/test_openapi_fuzz.py": 0.1, - "tests/test_openapi_operation_attribution.py": 2.0, - "tests/test_operation_attribution_verification.py": 21.4, + "tests/test_openapi_operation_attribution.py": 1.8, + "tests/test_operation_attribution_verification.py": 23.3, "tests/test_org_governance.py": 0.1, - "tests/test_out_path_resolution.py": 52.2, - "tests/test_output_directory_content.py": 382.7, + "tests/test_out_path_resolution.py": 58.2, + "tests/test_output_directory_content.py": 350.3, "tests/test_p0_binding_canaries.py": 0.1, - "tests/test_p0_policy_evidence_canaries.py": 0.1, - "tests/test_p0_safety_canaries.py": 2.6, - "tests/test_p0_verification_identity_canaries.py": 0.2, - "tests/test_packaging.py": 14.6, - "tests/test_partial_host_comparison.py": 83.3, - "tests/test_patch_generators.py": 1.1, + "tests/test_p0_policy_evidence_canaries.py": 0.0, + "tests/test_p0_safety_canaries.py": 1.4, + "tests/test_p0_verification_identity_canaries.py": 0.1, + "tests/test_packaging.py": 15.1, + "tests/test_partial_host_comparison.py": 91.0, + "tests/test_patch_generators.py": 1.2, "tests/test_patches_model.py": 0.3, "tests/test_permission_lattice.py": 1.1, - "tests/test_permission_residual.py": 8.2, - "tests/test_permission_review_guidance.py": 14.3, - "tests/test_plugin_validation.py": 4.5, - "tests/test_plugins.py": 0.6, + "tests/test_permission_residual.py": 8.0, + "tests/test_permission_review_guidance.py": 15.7, + "tests/test_plugin_validation.py": 4.9, + "tests/test_plugins.py": 0.4, "tests/test_policy_evidence_architecture.py": 0.0, "tests/test_policy_packs.py": 1.1, - "tests/test_policy_reason_code_split.py": 15.5, - "tests/test_preflight.py": 1.6, - "tests/test_preview_control_currency.py": 133.0, + "tests/test_policy_reason_code_split.py": 16.5, + "tests/test_preflight.py": 1.8, + "tests/test_preview_control_currency.py": 120.0, "tests/test_privacy.py": 0.1, "tests/test_product_hardening_gap_closure.py": 0.0, - "tests/test_prompt_disabling_settings.py": 217.4, + "tests/test_prompt_disabling_settings.py": 156.5, "tests/test_prompt_parity.py": 0.0, - "tests/test_property_loaders.py": 0.8, - "tests/test_provenance_kind.py": 2.7, - "tests/test_public_surface_contract.py": 2.9, - "tests/test_python_import_resolution.py": 0.3, - "tests/test_qualification_coverage_misses.py": 1.3, - "tests/test_rater_harness.py": 79.7, - "tests/test_reader_vocabulary.py": 21.7, - "tests/test_regenerate_goldens.py": 17.2, + "tests/test_property_loaders.py": 0.9, + "tests/test_provenance_kind.py": 2.3, + "tests/test_public_surface_contract.py": 3.0, + "tests/test_python_import_resolution.py": 0.2, + "tests/test_qualification_coverage_misses.py": 1.4, + "tests/test_rater_harness.py": 72.1, + "tests/test_reader_vocabulary.py": 14.8, + "tests/test_regenerate_goldens.py": 16.5, "tests/test_registry.py": 0.0, "tests/test_registry_ledger.py": 0.1, - "tests/test_release_advisory_pipeline.py": 3.4, + "tests/test_release_advisory_pipeline.py": 2.6, "tests/test_release_channel.py": 0.1, - "tests/test_release_channel_cadence.py": 4.9, + "tests/test_release_channel_cadence.py": 3.6, "tests/test_release_decision.py": 0.0, "tests/test_release_engine_smoke.py": 0.3, - "tests/test_release_pipeline.py": 5.7, - "tests/test_release_source.py": 5.1, + "tests/test_release_pipeline.py": 3.8, + "tests/test_release_source.py": 4.9, "tests/test_remediation_metadata.py": 0.0, - "tests/test_report_1_0_compatibility_fixtures.py": 4.8, + "tests/test_report_1_0_compatibility_fixtures.py": 4.4, "tests/test_report_1_0_contract.py": 0.0, - "tests/test_reports.py": 16.3, - "tests/test_required_source_availability.py": 5.7, - "tests/test_reusable_workflow_secret_mappings.py": 177.3, + "tests/test_reports.py": 9.4, + "tests/test_required_source_availability.py": 6.8, + "tests/test_reusable_workflow_secret_mappings.py": 182.4, "tests/test_reviewer_summary.py": 0.0, "tests/test_risk_hints.py": 0.0, - "tests/test_safety_qualification.py": 2.2, - "tests/test_safety_qualification_release.py": 1.4, + "tests/test_safety_qualification.py": 2.5, + "tests/test_safety_qualification_release.py": 1.7, "tests/test_sarif.py": 0.3, - "tests/test_scan.py": 7.2, + "tests/test_scan.py": 7.3, "tests/test_scan_context.py": 0.1, "tests/test_scenario_suggest.py": 2.4, - "tests/test_schema_boundaries.py": 3.5, - "tests/test_schema_roundtrip.py": 2.0, - "tests/test_scoped_base_tree.py": 13.4, - "tests/test_sdk_boolean_source.py": 1.3, + "tests/test_schema_boundaries.py": 3.8, + "tests/test_schema_roundtrip.py": 2.1, + "tests/test_scoped_base_tree.py": 14.1, + "tests/test_sdk_boolean_source.py": 0.9, "tests/test_sdk_guard_dependencies.py": 0.3, "tests/test_self_approval_signal.py": 0.0, - "tests/test_self_check.py": 1.6, + "tests/test_self_check.py": 1.8, "tests/test_semantic_assessment.py": 0.0, "tests/test_semantic_cold_start.py": 0.1, - "tests/test_semantic_properties.py": 0.2, - "tests/test_setup_control.py": 37.3, + "tests/test_semantic_properties.py": 0.3, + "tests/test_setup_control.py": 40.5, "tests/test_severity_override_floor.py": 0.0, - "tests/test_shard_partition.py": 32.9, + "tests/test_shard_partition.py": 28.6, "tests/test_skill_review.py": 0.1, - "tests/test_source_authority.py": 3.8, + "tests/test_source_authority.py": 4.6, "tests/test_source_binding.py": 2.2, - "tests/test_source_head_identity.py": 7.1, + "tests/test_source_head_identity.py": 7.9, "tests/test_source_provenance.py": 0.7, - "tests/test_static_inputs.py": 0.0, - "tests/test_strata_inventory.py": 4.2, - "tests/test_subject_rollup.py": 1.4, - "tests/test_surface_exclusions.py": 12.1, - "tests/test_three_command_flow.py": 1.1, - "tests/test_tool_identity.py": 0.0, + "tests/test_static_inputs.py": 0.1, + "tests/test_strata_inventory.py": 4.5, + "tests/test_subject_rollup.py": 1.2, + "tests/test_surface_exclusions.py": 12.4, + "tests/test_three_command_flow.py": 1.3, + "tests/test_tool_identity.py": 0.1, + "tests/test_tool_reach.py": 10.3, "tests/test_tool_surface_diff.py": 0.0, - "tests/test_toolkit_bounds_check.py": 1.0, + "tests/test_toolkit_bounds_check.py": 1.1, "tests/test_trigger_command.py": 1.5, "tests/test_trust_root.py": 1.3, "tests/test_ts_tool_descriptions.py": 0.1, - "tests/test_unchanged_host_limits.py": 18.9, - "tests/test_unread_changed_inputs.py": 150.9, + "tests/test_unchanged_host_limits.py": 21.3, + "tests/test_unread_changed_inputs.py": 157.7, "tests/test_unresolved_descriptions.py": 0.1, - "tests/test_v07_metadata_roundtrip.py": 1.6, - "tests/test_validation_evidence.py": 1.1, + "tests/test_v07_metadata_roundtrip.py": 1.7, + "tests/test_validation_evidence.py": 1.3, "tests/test_verdict_contract.py": 0.0, - "tests/test_verification_git_snapshot.py": 3.2, + "tests/test_verification_git_snapshot.py": 5.5, "tests/test_verifier_blocks.py": 0.6, - "tests/test_verifier_control_contract.py": 1.1, - "tests/test_verifier_scenarios.py": 28.8, - "tests/test_verify.py": 115.3, - "tests/test_verify_auto_base.py": 14.5, - "tests/test_verify_capability_scope.py": 0.7, + "tests/test_verifier_control_contract.py": 1.2, + "tests/test_verifier_scenarios.py": 29.8, + "tests/test_verify.py": 115.1, + "tests/test_verify_auto_base.py": 21.7, + "tests/test_verify_capability_scope.py": 0.4, "tests/test_verify_config_binding.py": 0.7, - "tests/test_verify_orchestrator.py": 45.4, + "tests/test_verify_orchestrator.py": 41.2, "tests/test_verify_run.py": 0.0, - "tests/test_verify_weakening.py": 21.8, + "tests/test_verify_weakening.py": 21.7, "tests/test_vscode_mcp_jsonc.py": 0.0, "tests/test_vscode_mcp_support.py": 0.0, - "tests/test_wheel_candidate_build.py": 2.1, - "tests/test_workflow_agent_launches.py": 79.2, - "tests/test_workflow_capability_diff.py": 3.1, - "tests/test_workflow_evidence.py": 0.1, - "tests/test_workflow_label_redaction.py": 55.4, - "tests/test_workflow_step_action_references.py": 42.6, + "tests/test_wheel_candidate_build.py": 1.9, + "tests/test_workflow_agent_launches.py": 83.8, + "tests/test_workflow_capability_diff.py": 2.4, + "tests/test_workflow_evidence.py": 0.0, + "tests/test_workflow_label_redaction.py": 57.0, + "tests/test_workflow_step_action_references.py": 42.9, "tests/test_workspace_input_guard.py": 0.3, - "tests/test_zero_install_detector.py": 38.4 + "tests/test_zero_install_detector.py": 39.2 }, - "measured_at": "e672648fc800" + "measured_at": "0a1cfb677de2" } diff --git a/tests/test_application_diff_tool_reach.py b/tests/test_application_diff_tool_reach.py new file mode 100644 index 000000000..ffaf6e992 --- /dev/null +++ b/tests/test_application_diff_tool_reach.py @@ -0,0 +1,410 @@ +"""#872: a bound tool's row names what the tool reaches, read from its source. + +Paired fixtures: in each pair, the two sides differ only in the fact under test, +so the row has to state that fact rather than a signature alone. +""" + +import json + +import pytest +from test_application_diff import commit, run +from test_application_diff import repo as repo +from typer.testing import CliRunner + +from agents_shipgate.cli.main import app + +AGENT = '''import os +import requests +from agents import Agent, function_tool + + +@function_tool +def lookup(query: str) -> str: + return query + +BODY + +agent = Agent(name="assistant", tools=[lookup, act]) +''' + + +def _added(repo, body: str, *, extra: dict[str, str] | None = None): + base = commit(repo, {"agent.py": AGENT.replace("BODY", "").replace(", act", "")}) + head = commit(repo, {"agent.py": AGENT.replace("BODY", body), **(extra or {})}) + result = run(repo, base, head) + [row] = [row for row in result["rows"] if row["tool"] == "act"] + assert row["change"] == "added" + return row["after"], result, (base, head) + + +def _line(body: str, needle: str) -> str: + """``agent.py:N`` of the first line of the head's agent.py containing ``needle``.""" + lines = AGENT.replace("BODY", body).splitlines() + return f"agent.py:{next(i for i, line in enumerate(lines, 1) if needle in line)}" + + +def _text(repo, base: str, head: str) -> str: + result = CliRunner().invoke( + app, ["diff", "--application", "--workspace", str(repo), "--base", base, "--head", head] + ) + assert result.exit_code == 0, result.output + return result.output + + +GRAPHQL = ''' +@function_tool +def act(number: int) -> dict: + payload = {"query": DOC, "variables": {"n": number}} + return requests.post("https://api.example.com/graphql", json=payload, timeout=5).json() +''' + + +@pytest.mark.parametrize( + ("doc", "operation", "effect"), + [ + ('"""query($n: Int!) { issue(number: $n) { title } }"""', "query", "read"), + ('"""mutation($n: Int!) { closeIssue(number: $n) { ok } }"""', "mutation", "write"), + ('"{ viewer { login } }"', "query", "read"), + ( + '"""# a comment with mutation {\nquery Q { a(s: \\"}\\") { b } }\n' + 'fragment F on T { c }"""', + "query", + "read", + ), + ], +) +def test_the_graphql_operation_not_the_transport_decides_the_effect(repo, doc, operation, effect): + after, _, _ = _added(repo, GRAPHQL.replace("DOC", doc)) + [call] = after["reach"]["calls"] + assert (call["method"], call["graphql"], call["effect"]) == ("POST", operation, effect) + assert call["model_supplied"] == [{"param": "number", "into": "field variables"}] + assert after["effect_evidence"]["status"] == "structural" + assert after["effect_evidence"]["conservative_effect"] == effect + + +def test_a_graphql_document_that_is_not_a_literal_is_named(repo): + after, _, _ = _added(repo, GRAPHQL.replace("DOC", "os.environ['DOC']")) + [call] = after["reach"]["calls"] + assert call["graphql"] == "unknown" and call["effect"] is None + assert any("GraphQL document is not a literal" in item["why"] for item in after["reach"]["limits"]) + assert after["reach"]["effect_claims"] == [] + assert after["effect_evidence"]["status"] != "structural" + + +FIELDS = ''' +from vendor import pick_state + + +@function_tool +def act(number: int, note: str) -> dict: + body = {"state": STATE, "labels": ["triage"], "body": note} + return requests.patch(f"https://api.example.com/issues/{number}", json=body).json() +''' + + +def test_literal_request_fields_are_named(repo): + after, _, _ = _added(repo, FIELDS.replace("STATE", '"closed"')) + [call] = after["reach"]["calls"] + assert call["method"] == "PATCH" and call["effect"] == "write" + assert {"field": "state", "value": "closed"} in call["fields"] + assert {"field": "body", "from": ["note"]} in call["fields"] + assert after["reach"]["limits"] == [] + + +def test_a_computed_request_field_is_a_named_limit(repo): + body = FIELDS.replace("STATE", "pick_state(number)") + after, _, _ = _added(repo, body) + [call] = after["reach"]["calls"] + assert {"field": "state", "from": ["number"]} in call["fields"] + assert after["reach"]["limits"] == [ + {"at": _line(body, "pick_state(number)"), "why": "calls vendor.pick_state, which is not read"} + ] + # A write that was made is still established; what was not read is named. + assert after["effect_evidence"]["conservative_effect"] == "write" + + +def test_a_field_chosen_among_literals_names_them_and_what_decides(repo): + body = ''' +@function_tool +def act(number: int, verdict: str) -> dict: + event = "COMMENT" + if "blocking" in verdict: + event = "REQUEST_CHANGES" + elif "clean" in verdict: + event = "APPROVE" + url = f"https://api.example.com/pulls/{number}/reviews" + return requests.post(url, json={"event": event}).json() +''' + after, _, (base, head) = _added(repo, body) + [call] = after["reach"]["calls"] + assert call["fields"] == [ + { + "field": "event", + "values": ["APPROVE", "COMMENT", "REQUEST_CHANGES"], + "decided_by": ["verdict"], + } + ] + text = _text(repo, base, head) + assert "field event ∈ {APPROVE, COMMENT, REQUEST_CHANGES}, decided by verdict" in text + assert "model-supplied: number → url; verdict → field event" in text + + +URL = ''' +BASE = "https://api.example.com" +OWNER = "octo" + + +@function_tool +def act(number: int) -> dict: + return requests.get(f"{BASE}/repos/{PART}/issues").json() +''' + + +@pytest.mark.parametrize( + ("part", "url", "supplied"), + [ + ( + "number", + "https://api.example.com/repos/{number}/issues", + [{"param": "number", "into": "url"}], + ), + ("OWNER", "https://api.example.com/repos/octo/issues", None), + ], +) +def test_a_tool_parameter_in_the_url_is_model_supplied_and_a_constant_is_not( + repo, part, url, supplied +): + after, _, _ = _added(repo, URL.replace("PART", part)) + [call] = after["reach"]["calls"] + assert call["url"] == url + assert call.get("model_supplied") == supplied + assert after["effect_evidence"]["status"] == "structural" + assert after["effect_evidence"]["conservative_effect"] == "read" + + +AUTH = ''' +@function_tool +def act(number: int) -> dict: + headers = {"Authorization": TOKEN, "Accept": "application/json"} + return requests.get(f"https://api.example.com/issues/{number}", headers=headers).json() +''' +SECRET = "sk_live_abcdefghijklmnopqrst" + + +def test_an_environment_credential_is_named_by_its_variable(repo): + after, _, (base, head) = _added( + repo, AUTH.replace("TOKEN", "f\"Bearer {os.getenv('SERVICE_TOKEN')}\"") + ) + [call] = after["reach"]["calls"] + assert call["credential_sources"] == [{"header": "Authorization", "env": ["SERVICE_TOKEN"]}] + assert "credential: env SERVICE_TOKEN → header Authorization" in _text(repo, base, head) + + +def test_a_hard_coded_credential_is_named_and_never_printed(repo): + after, result, (base, head) = _added(repo, AUTH.replace("TOKEN", f'"Bearer {SECRET}"')) + [call] = after["reach"]["calls"] + assert call["credential_sources"] == [{"header": "Authorization", "literal": True}] + assert SECRET not in json.dumps(result) + text = _text(repo, base, head) + assert "credential: a literal (not printed) → header Authorization" in text + assert SECRET not in text + + +def test_a_secret_literal_in_the_url_is_not_printed(repo): + body = f''' +@function_tool +def act(number: int) -> dict: + return requests.get(f"https://api.example.com/issues/{{number}}?api_key={SECRET}").json() +''' + after, result, _ = _added(repo, body) + [call] = after["reach"]["calls"] + assert SECRET not in json.dumps(result) + assert "api_key=[REDACTED" in call["url"] + + +def _chain(hops: int) -> str: + helpers = "\n".join( + f"def hop{index}(url):\n return {'hop' + str(index + 1) + '(url)' if index < hops else 'requests.get(url).json()'}\n" + for index in range(1, hops + 1) + ) + return ( + f"\n{helpers}\n\n@function_tool\ndef act(number: int) -> dict:\n" + ' return hop1(f"https://api.example.com/issues/{number}")\n' + ) + + +def test_a_read_through_helpers_within_the_bound_is_established(repo): + body = _chain(3) + after, _, _ = _added(repo, body) + [call] = after["reach"]["calls"] + assert call["via"] == [ + f"{_line(body, 'return hop1(')} hop1", + f"{_line(body, 'return hop2(')} hop2", + f"{_line(body, 'return hop3(')} hop3", + ] + assert after["reach"]["effect_claims"] == [ + {"effect": "read", "at": _line(body, "def act("), "calls": 1} + ] + assert after["effect_evidence"]["status"] == "structural" + + +def test_a_helper_beyond_the_bound_is_a_named_limit_not_a_read(repo): + body = _chain(4) + after, _, _ = _added(repo, body) + assert after["reach"]["calls"] == [] + assert after["reach"]["limits"] == [ + { + "at": _line(body, "return hop4("), + "why": "calls hop4, more than 3 helper calls from the tool; not read", + } + ] + assert after["reach"]["effect_claims"] == [] + assert after["effect_evidence"]["status"] != "structural" + + +def test_a_call_the_read_cannot_follow_blocks_a_read_claim(repo): + body = ''' +import storage + + +@function_tool +def act(number: int) -> dict: + storage.remember(number) + return requests.get(f"https://api.example.com/issues/{number}").json() +''' + after, _, _ = _added(repo, body) + [call] = after["reach"]["calls"] + assert call["effect"] == "read" + assert after["reach"]["effect_claims"] == [] + assert after["reach"]["limits"][0]["why"] == "calls storage.remember, which is not read" + + +def test_a_helper_in_another_module_is_followed_and_located(repo): + body = ''' +from support import client + + +@function_tool +def act(number: int) -> dict: + return client.close_issue(number) +''' + support = '''import os +import requests + +BASE = os.environ["API_BASE"] +session = requests.Session() + + +def close_issue(number): + return session.delete(f"{BASE}/issues/{number}", headers={"X-Api-Key": os.environ["API_KEY"]}) +''' + after, _, (base, head) = _added( + repo, + body, + extra={"support/__init__.py": "", "support/client.py": support}, + ) + [call] = after["reach"]["calls"] + assert call["method"] == "DELETE" and call["effect"] == "destructive" + assert call["url"] == "{env API_BASE}/issues/{number}" + assert call["at"] == "support/client.py:9" + assert call["via"] == [f"{_line(body, 'client.close_issue(')} close_issue"] + assert call["credential_sources"] == [{"header": "X-Api-Key", "env": ["API_KEY"]}] + assert after["effect_evidence"]["conservative_effect"] == "destructive" + assert after["effect_evidence"]["status"] == "structural" + text = _text(repo, base, head) + assert "reaches: DELETE {env API_BASE}/issues/{number} at support/client.py:9" in text + assert "effect: destructive (structural evidence: outbound call at support/client.py:9)" in text + + +def test_reach_is_evidence_not_meaning(repo): + # A helper's endpoint changes; the tool's own code does not. No row claims + # a changed binding the implementation digest does not show. + body = ''' +from support import send + + +@function_tool +def act(number: int) -> dict: + return send(number) +''' + helper = 'import requests\n\n\ndef send(number):\n return requests.METHOD(f"https://x.test/{number}")\n' + base = commit( + repo, + { + "agent.py": AGENT.replace("BODY", body), + "support.py": helper.replace("METHOD", "get"), + }, + ) + head = commit(repo, {"support.py": helper.replace("METHOD", "delete")}) + result = run(repo, base, head) + assert result["rows"] == [] + + +def test_two_same_named_adk_agents_are_located_at_their_own_constructions(repo): + source = ( + "from google.adk.agents import LlmAgent\n\n\n" + "def details(q: str) -> str:\n return q\n\n\n" + "def submit(q: str) -> str:\n return q\n\n\n" + "def submit_orchestrated(q: str) -> str:\n return q\n\n\n" + 'root_agent = LlmAgent(name="reviewer", model="m", tools=[details, submit])\n\n\n' + "def make_agent():\n" + ' return LlmAgent(name="reviewer", model="m", tools=[details, submit_orchestrated])\n' + ) + base = commit(repo, {"README.md": "empty"}) + head = commit(repo, {"agent.py": source}) + result = run(repo, base, head) + located = { + row["tool"]: (row["after"]["binding_location"], row["after"].get("construction_sites")) + for row in result["rows"] + } + assert located == { + "details": ("agent.py:16", ["agent.py:16", "agent.py:20"]), + "submit": ("agent.py:16", None), + "submit_orchestrated": ("agent.py:20", None), + } + assert any( + "constructed more than once in agent.py (lines 16, 20)" in limit + for limit in result["head"]["limits"] + ) + text = _text(repo, base, head) + assert "details(q) -> str at agent.py:16 (also listed at agent.py:20)" in text + + +def test_reach_is_deterministic(repo): + after, _, (base, head) = _added(repo, _chain(2)) + again = run(repo, base, head) + [row] = [row for row in again["rows"] if row["tool"] == "act"] + assert row["after"]["reach"] == after["reach"] + + +def test_construction_sites_are_in_line_order(repo): + # A factory above the root agent was read first; the row still names the + # earlier construction first, as the limit does (#872 review). + source = ( + "from google.adk.agents import LlmAgent\n\n\n" + "def details(q: str) -> str:\n return q\n\n\n" + "def make_agent():\n" + ' return LlmAgent(name="reviewer", model="m", tools=[details])\n\n\n' + 'root_agent = LlmAgent(name="reviewer", model="m", tools=[details, make_agent])\n' + ) + base = commit(repo, {"README.md": "empty"}) + head = commit(repo, {"agent.py": source}) + result = run(repo, base, head) + [row] = [row for row in result["rows"] if row["tool"] == "details"] + assert row["after"]["construction_sites"] == ["agent.py:9", "agent.py:12"] + assert row["after"]["binding_location"] == "agent.py:9" + + +def test_a_credential_only_some_call_sites_send_says_so(repo): + body = ''' +@function_tool +def act(number: int) -> dict: + url = f"https://api.example.com/issues/{number}" + response = requests.get(url, headers={"Authorization": os.environ["TOKEN"]}) + if response.status_code == 401: + response = requests.get(url) + return response.json() +''' + _, _, (base, head) = _added(repo, body) + text = _text(repo, base, head) + assert "credential: env TOKEN → header Authorization (at some call sites)" in text diff --git a/tests/test_distribution_surface_parity.py b/tests/test_distribution_surface_parity.py index 0e03573ba..560ce513e 100644 --- a/tests/test_distribution_surface_parity.py +++ b/tests/test_distribution_surface_parity.py @@ -173,7 +173,11 @@ def paths(self) -> list[Path]: ), Surface( "application_diff", - ("src/agents_shipgate/cli/application_diff.py", "src/agents_shipgate/cli/application_scope.py"), + ( + "src/agents_shipgate/cli/application_diff.py", + "src/agents_shipgate/cli/application_scope.py", + "src/agents_shipgate/inputs/tool_reach.py", + ), # Advisory source-wiring comparison, not host drift or an engine verdict. # No release permission, declared authority, pin or root reachability claim. # Its per-side `excluded_tests` list (#876) names test files the @@ -182,6 +186,10 @@ def paths(self) -> list[Path]: # ADK agent subclass) only turn `compared` into `partial`; neither # restates an engine answer, so neither adds a claim. # `tests/test_application_diff_unobserved.py` holds both. + # A side's `reach` (#872) is what the tool's own code sends, and its + # `effect_evidence` is `assess_tool_semantics` itself over that tool — + # the engine's one effect model, called, not restated — so neither adds + # a claim either (`tests/test_application_diff_tool_reach.py`). {}, ), Surface( diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index c6673054a..4f1cc964e 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -883,7 +883,7 @@ def test_release_does_not_weaken_the_coverage_floor() -> None: CI splits the suite across jobs, so its shards each hold a fragment of the coverage data. Two things have to be true and neither is implied by the other: the combined data is gated at 85, and no shard sets a threshold of - its own. A `--cov-fail-under` on a third of the suite would be a number + its own. A `--cov-fail-under` on a quarter of the suite would be a number that cannot mean what it says, and it would pass or fail for reasons unrelated to the floor. """ @@ -900,7 +900,15 @@ def test_release_does_not_weaken_the_coverage_floor() -> None: # And the gate has to wait for every shard, or it would combine whatever # happened to have finished. assert ci["jobs"]["coverage"]["needs"] == ["suite"] - assert ci["jobs"]["suite"]["strategy"]["matrix"]["shard"] == [1, 2, 3] + # Every shard index the matrix runs, and the count each shard partitions by, + # agree: a matrix of four over a count of three would drop a quarter. + shards = ci["jobs"]["suite"]["strategy"]["matrix"]["shard"] + count = next( + step["env"]["SHIPGATE_TEST_SHARDS"] + for step in ci["jobs"]["suite"]["steps"] + if "SHIPGATE_TEST_SHARDS" in step.get("env", {}) + ) + assert shards == list(range(1, int(count) + 1)) == [1, 2, 3, 4] def test_adapter_static_only_lint_stays_covered_in_release() -> None: diff --git a/tests/test_tool_reach.py b/tests/test_tool_reach.py new file mode 100644 index 000000000..95d0d7b24 --- /dev/null +++ b/tests/test_tool_reach.py @@ -0,0 +1,2855 @@ +"""#872: the static reader of what one tool function reaches over HTTP.""" + +import ast +import json +import time + +import pytest + +from agents_shipgate.core.domain import Tool +from agents_shipgate.core.semantic_assessment import assess_tool_semantics +from agents_shipgate.inputs.python_imports import ImportResolver +from agents_shipgate.inputs.tool_reach import ( + MAX_CALLS, + _graphql_operations, + read_tool_reach, + scope_mutations, +) + + +def _reach(tmp_path, source: str, *, tool: str = "act", model: set[str] | None = None, files=None): + (tmp_path / "agent.py").write_text(source) + for name, text in (files or {}).items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(text) + resolver = ImportResolver(tmp_path) + tree = ast.parse(source) + module = resolver.entry(tmp_path / "agent.py", tree, source) + found = [] + + def visit(node, enclosing): + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef): + if child.name == tool: + found.append((child, enclosing)) + visit(child, child) + else: + visit(child, enclosing) + + visit(tree, None) + [(node, enclosing)] = found + params = {arg.arg for arg in node.args.args} if model is None else model + return read_tool_reach( + resolver, module, node, model_params=frozenset(params), enclosing=enclosing + ) + + +@pytest.mark.parametrize( + ("document", "kinds"), + [ + ("query { a }", {"query"}), + ("{ a }", {"query"}), + ("mutation M($x: ID!) { close(id: $x) { ok } }", {"mutation"}), + ("query A { a } mutation B { b }", {"query", "mutation"}), + ('query { a(s: "}") }', {"query"}), + ('query { a(s: """ } mutation { """) }', {"query"}), + ("# mutation {\nquery { a }", {"query"}), + ("fragment F on T { a } query { ...F }", {"query"}), + ("subscription { a }", {"subscription"}), + ], +) +def test_graphql_operation_kinds(document, kinds): + assert _graphql_operations(document) == kinds + + +@pytest.mark.parametrize( + "document", + ["fragment F on T { a }", "select * from t", "query { a", "query { a } }", "", 'query { a(s: "'] +) +def test_a_graphql_document_that_does_not_read_as_one_has_no_kind(document): + assert _graphql_operations(document) is None + + +def test_urllib_request_with_data_is_a_post_and_without_is_a_get(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport urllib.request\nfrom urllib.request import Request, urlopen\n\n" + "def act(item: str) -> bytes:\n" + ' headers = {"Authorization": os.environ["TOKEN"]}\n' + ' urlopen(Request(f"https://x.test/items/{item}", data=b"{}", headers=headers))\n' + ' return urllib.request.urlopen("https://x.test/health").read()\n', + ) + calls = [(c["method"], c["url"], c.get("credential_sources")) for c in reach["calls"]] + assert calls == [ + ("POST", "https://x.test/items/{item}", [{"header": "Authorization", "env": ["TOKEN"]}]), + ("GET", "https://x.test/health", None), + ] + # `.read()` of the response is not a call the read can name. + assert reach["effect_claims"] == [ + {"effect": "write", "at": "agent.py:7", "method": "POST", "url": "https://x.test/items/{item}"} + ] + + +def test_a_module_client_carries_its_base_url_and_default_headers(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport httpx\n\n" + 'client = httpx.Client(base_url="https://api.x.test", ' + "headers={\"Authorization\": f\"Bearer {os.environ['API_TOKEN']}\"})\n\n" + "def act(name: str) -> dict:\n" + ' return client.post("/items", json={"name": name}).json()\n', + ) + [call] = reach["calls"] + assert (call["library"], call["method"], call["url"]) == ("httpx", "POST", "https://api.x.test/items") + assert call["credential_sources"] == [{"header": "Authorization", "env": ["API_TOKEN"]}] + assert call["model_supplied"] == [{"param": "name", "into": "field name"}] + + +def test_an_async_client_in_a_with_block(tmp_path): + reach = _reach( + tmp_path, + "import httpx\n\n" + "async def act(item_id: str) -> None:\n" + " async with httpx.AsyncClient() as http:\n" + ' await http.delete(f"https://x.test/items/{item_id}")\n', + ) + [call] = reach["calls"] + assert (call["method"], call["effect"]) == ("DELETE", "destructive") + assert reach["effect"] == "destructive" + + +def test_a_format_template_and_an_imported_verb(tmp_path): + reach = _reach( + tmp_path, + "from requests import get\n\n" + 'URL = "https://x.test/{}/detail?lang={lang}"\n\n' + "def act(item: str) -> dict:\n" + ' return get(URL.format(item, lang="en")).json()\n', + ) + [call] = reach["calls"] + assert call["url"] == "https://x.test/{item}/detail?lang=en" + assert reach["effect_claims"] == [{"effect": "read", "at": "agent.py:5", "calls": 1}] + + +def test_a_model_chosen_method_is_named_and_supports_no_effect(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(method: str, path: str) -> dict:\n" + ' return requests.request(method, f"https://x.test/{path}").json()\n', + ) + [call] = reach["calls"] + assert call["method"] is None and call["effect"] is None + assert {"param": "method", "into": "method"} in call["model_supplied"] + assert reach["limits"] == [{"at": "agent.py:4", "why": "the request method is not a literal"}] + assert reach["effect_claims"] == [] + + +def test_a_method_chosen_among_literals_claims_the_strongest(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(item: str, dry_run: bool) -> dict:\n" + ' method = "GET" if dry_run else "DELETE"\n' + ' return requests.request(method, f"https://x.test/{item}").json()\n', + ) + [call] = reach["calls"] + assert (call["method"], call["effect"]) == ("DELETE|GET", "destructive") + assert {"param": "dry_run", "into": "method"} in call["model_supplied"] + + +@pytest.mark.parametrize( + ("line", "why"), + [ + ("db.delete(item)", "calls store.db.delete, which is not read"), + # One unread chain is one limit, named where it starts. + ('open("/tmp/x", "w").write(item)', "calls open, which is not read"), + ("subprocess.run([item])", "calls subprocess.run, which is not read"), + ("Recorder(item)", "calls Recorder"), + ], +) +def test_a_call_the_read_cannot_follow_is_a_limit_and_blocks_read(tmp_path, line, why): + reach = _reach( + tmp_path, + "import subprocess\nimport requests\nfrom store import db\n\n" + "class Recorder:\n pass\n\n" + "def act(item: str) -> dict:\n" + f" {line}\n" + ' return requests.get(f"https://x.test/{item}").json()\n', + ) + assert reach["calls"][0]["effect"] == "read" + assert any( + item["at"] == "agent.py:9" and item["why"].startswith(why) for item in reach["limits"] + ) + assert reach["effect_claims"] == [] + + +def test_calls_with_no_effect_outside_the_process_are_passed_over(tmp_path): + reach = _reach( + tmp_path, + "import json\nimport logging\nimport re\nimport requests\n\n" + "log = logging.getLogger(__name__)\n\n" + "def act(item: str) -> str:\n" + ' data = requests.get(f"https://x.test/{item.strip()}", timeout=5).json()\n' + ' log.info("fetched %s", item)\n' + ' names = sorted(str(x).upper() for x in data.get("items", []))\n' + ' return json.dumps({"n": len(names), "m": re.sub("a", "b", ", ".join(names))})\n', + ) + assert reach["limits"] == [] + assert reach["effect_claims"] == [{"effect": "read", "at": "agent.py:8", "calls": 1}] + + +def test_a_parameter_the_model_does_not_supply_is_not_model_input(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(ctx, query: str) -> dict:\n" + ' return requests.get(ctx.base_url, params={"q": query}).json()\n', + model={"query"}, + ) + [call] = reach["calls"] + assert call["url"] == "{…}?q={query}" + assert call["model_supplied"] == [{"param": "query", "into": "query q"}] + + +def test_a_nested_tool_names_a_callback_of_its_factory(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def make(callback, base):\n" + " def act(item: str) -> dict:\n" + " callback(item)\n" + ' return requests.post(f"{base}/items", json={"item": item}).json()\n' + " return act\n", + ) + [call] = reach["calls"] + assert call["url"] == "{…}/items" + assert reach["limits"] == [ + {"at": "agent.py:5", "why": "calls callback (parameter callback of make), which is not read"} + ] + assert reach["effect"] == "write" + + +def test_recursion_and_many_calls_are_bounded(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def again(n):\n" + " if n:\n" + " again(n - 1)\n" + ' return requests.get("https://x.test/a").json()\n\n' + "def act(item: str) -> None:\n" + " again(3)\n" + + "".join(f' requests.get("https://x.test/{index}")\n' for index in range(MAX_CALLS + 2)), + ) + assert len(reach["calls"]) == MAX_CALLS + assert any(f"more than {MAX_CALLS} outbound calls" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_secret_shaped_literal_is_never_a_printed_field_value(tmp_path): + secret = "sk_live_abcdefghijklmnopqrst" + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(item: str) -> dict:\n" + f' body = {{"api_key": "short", "note": "{secret}", "kind": "ticket", "item": item}}\n' + ' return requests.post("https://x.test/items", json=body).json()\n', + ) + [call] = reach["calls"] + assert call["fields"] == [ + {"field": "api_key", "literal": True}, + {"field": "note", "literal": True}, + {"field": "kind", "value": "ticket"}, + {"field": "item", "from": ["item"]}, + ] + assert secret not in json.dumps(reach) + + +def test_a_local_name_shadowing_a_library_is_not_that_library(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(item: str) -> list:\n" + " requests = []\n" + " requests.append(item)\n" + " return requests\n", + ) + assert reach["calls"] == [] and reach["limits"] == [] + assert reach["effect_claims"] == [] + + +def test_a_function_level_import_of_the_library(tmp_path): + reach = _reach( + tmp_path, + "def act(item: str) -> dict:\n" + " import requests as http\n" + ' return http.put(f"https://x.test/{item}", data="x").json()\n', + ) + [call] = reach["calls"] + assert (call["method"], call["effect"]) == ("PUT", "write") + + +def test_a_call_in_a_comprehension_or_lambda_runs_here(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(items: list) -> list:\n" + ' return [requests.delete(f"https://x.test/{i}") for i in items]\n', + ) + [call] = reach["calls"] + assert call["effect"] == "destructive" + assert call["model_supplied"] == [{"param": "items", "into": "url"}] + + +def test_a_helper_outside_the_scope_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "from ..shared import send\n\n" + "def act(item: str) -> dict:\n" + " return send(item)\n", + ) + assert reach["calls"] == [] + assert reach["limits"][0]["at"] == "agent.py:4" + assert reach["limits"][0]["why"].startswith("calls send (") + + +def _tool(reach): + return Tool.model_validate( + { + "id": "tool:act", + "name": "act", + "source_type": "openai_agents_sdk", + "source_id": "sdk", + "source_pointer": "agent.py:3", + "extraction_confidence": "medium", + "extraction": {"method": "openai_agents_sdk_ast", "confidence": "medium", "reach": reach}, + } + ) + + +def test_the_reach_is_structural_effect_evidence_in_the_one_effect_model(): + read = assess_tool_semantics( + _tool({"effect_claims": [{"effect": "read", "at": "agent.py:3", "calls": 2}]}) + ) + assert (read.conservative_effect, read.effect.status) == ("read", "structural") + [claim] = [c for c in read.effect.claims if c.source == "source_http_call"] + assert claim.basis == "protocol_structure" and claim.policy_eligible + assert claim.evidence == {"calls": 2} + + write = assess_tool_semantics( + _tool( + { + "effect_claims": [ + {"effect": "write", "at": "utils.py:9", "method": "POST", "url": "https://x.test"} + ] + } + ) + ) + assert (write.conservative_effect, write.effect.status) == ("write", "structural") + + none = assess_tool_semantics(_tool({"effect_claims": []})) + assert none.effect.status == "unknown" + assert none.conservative_effect == "write" + + +@pytest.mark.parametrize( + ("line", "why"), + [ + # A method that stores on another object is not a list's `append`. + ("store.append(item)", "calls store.append, which is not read"), + ("es.index(index='items', document={'id': item})", "calls es.index, which is not read"), + ("audit.update({'item': item})", "calls audit.update, which is not read"), + # Only a logger's methods log. + ("tracker.info(item)", "calls tracker.info, which is not read"), + # A function handed on runs where it is handed. + ("list(map(requests.delete, [item]))", "hands on requests.delete, which is not read"), + ("list(map(session.delete, [item]))", "hands on session.delete, which is not read"), + ], +) +def test_a_name_that_only_looks_inert_blocks_read(tmp_path, line, why): + reach = _reach( + tmp_path, + "import requests\nfrom services import connect\n\n" + 'store, es, audit, tracker = connect("s"), connect("e"), connect("a"), connect("t")\n' + "session = requests.Session()\n\n" + "def act(item: str) -> dict:\n" + f" {line}\n" + ' return requests.get(f"https://x.test/{item}").json()\n', + ) + assert any( + entry["why"].startswith(why.removesuffix(", which is not read")) for entry in reach["limits"] + ), reach["limits"] + assert reach["effect_claims"] == [] + + +def test_a_list_the_function_built_and_a_logger_are_passed_over(tmp_path): + reach = _reach( + tmp_path, + "import logging\nimport requests\n\n" + "logger = logging.getLogger(__name__)\n\n" + "def act(items: list) -> list:\n" + " seen = []\n" + " names = list(items)\n" + " for item in items:\n" + " seen.append(item)\n" + " names.extend([item])\n" + ' logger.info("read %d", len(seen))\n' + ' return requests.get("https://x.test/items").json()\n', + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_helper_handed_on_is_read(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def close(item):\n" + ' return requests.delete(f"https://x.test/{item}")\n\n' + "def act(items: list) -> list:\n" + " return sorted(items, key=close)\n", + ) + [call] = reach["calls"] + assert (call["method"], call["via"]) == ("DELETE", ["agent.py:7 close"]) + assert reach["effect"] == "destructive" + + +def test_methods_of_plain_data_are_passed_over(tmp_path): + # An environment value is a string; a JSON-typed argument is what the + # framework decoded. Neither has a method that reaches outside. + reach = _reach( + tmp_path, + "import os\nimport requests\n\n" + "def act(tags: list[str], note: str | None = None) -> dict:\n" + ' base = os.environ.get("API_URL")\n' + ' base = base.replace("//localhost:", "//host:")\n' + ' tags.append("agent")\n' + ' return requests.get(f"{base}/search", params={"tags": ",".join(tags)}).json()\n', + ) + assert reach["limits"] == [] + [call] = reach["calls"] + assert call["url"] == "{…}/search?tags={…}" + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_method_of_a_typed_argument_is_not_plain_data(tmp_path): + reach = _reach( + tmp_path, + "import requests\nfrom models import Order\n\n" + "def act(order: Order) -> dict:\n" + " order.update(status='sent')\n" + ' return requests.get("https://x.test/orders").json()\n', + ) + assert reach["limits"] == [{"at": "agent.py:5", "why": "calls order.update, which is not read"}] + assert reach["effect_claims"] == [] + + +# -- #872 review round 1 --------------------------------------------------------- + + +def test_recursion_with_other_arguments_is_followed(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + 'BASE = "https://x.test"\n\n' + 'def _do(path, method="GET"):\n' + " r = requests.request(method, BASE + path)\n" + " if r.status_code == 409:\n" + ' return _do(path + "/lock", "DELETE")\n' + " return r.json()\n\n" + "def act(item: str) -> dict:\n" + ' return _do(f"/items/{item}")\n', + ) + assert "DELETE" in {call["method"] for call in reach["calls"]} + assert reach["effect"] == "destructive" + + +def test_a_method_of_an_object_the_read_cannot_name_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "class Api:\n" + " def get(self, path):\n" + ' return requests.post("https://x.test/" + path)\n\n' + "api = Api()\n\n" + "def act(item: str) -> dict:\n" + " api.get(item)\n" + ' return requests.get(f"https://x.test/{item}").json()\n', + ) + assert any(item["why"].startswith("calls api.get") for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "lines", + [ + ' for r in [urllib.request.Request(f"https://x.test/{i}", method="DELETE") for i in ids]:\n' + " urllib.request.urlopen(r)\n", + ' r = urllib.request.Request(f"https://x.test/{ids}")\n' + ' r.method = "DELETE"\n' + " urllib.request.urlopen(r)\n", + ' r = urllib.request.Request(f"https://x.test/{ids}")\n' + ' r.get_method = lambda: "DELETE"\n' + " urllib.request.urlopen(r)\n", + ], +) +def test_a_urllib_request_whose_method_is_not_read_is_not_a_get(tmp_path, lines): + reach = _reach( + tmp_path, + "import urllib.request\n\ndef act(ids: list[str]) -> str:\n" + lines + ' return "ok"\n', + ) + [call] = reach["calls"] + assert call["method"] is None and call["effect"] is None + assert reach["effect_claims"] == [] + + +def test_a_positional_urllib_request_method_is_read(tmp_path): + reach = _reach( + tmp_path, + "from urllib.request import Request, urlopen\n\n" + "def act(item: str) -> None:\n" + ' urlopen(Request(f"https://x.test/{item}", None, {}, None, False, "DELETE"))\n', + ) + assert reach["calls"][0]["method"] == "DELETE" + + +def test_a_decorator_the_read_cannot_see_into_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import functools\nimport requests\n\n" + "def audited(fn):\n" + " @functools.wraps(fn)\n" + " def wrapper(*args, **kwargs):\n" + ' requests.post("https://audit.test/log", json={"fn": fn.__name__})\n' + " return fn(*args, **kwargs)\n" + " return wrapper\n\n" + "@audited\n" + "def act(city: str) -> dict:\n" + ' return requests.get(f"https://x.test/{city}").json()\n', + ) + assert reach["limits"] == [ + {"at": "agent.py:11", "why": "act is decorated with audited, which is not read"} + ] + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize("line", [" cache[item] = data\n", " del cache[item]\n"]) +def test_a_store_into_an_object_the_read_cannot_name_is_a_limit(tmp_path, line): + reach = _reach( + tmp_path, + "import redis\nimport requests\n\n" + "cache = redis.Redis()\n\n" + "def act(item: str) -> dict:\n" + ' data = requests.get(f"https://x.test/{item}").text\n' + line + " return {}\n", + ) + assert reach["limits"][0]["why"] == "stores into cache[…], which is not read" + assert reach["effect_claims"] == [] + + +def test_a_store_into_the_functions_own_dict_is_passed_over(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(item: str) -> dict:\n" + " out = {}\n" + ' out["data"] = requests.get(f"https://x.test/{item}").json()\n' + " return out\n", + ) + assert reach["limits"] == [] + + +def test_secret_shaped_url_parts_and_field_values_are_withheld(tmp_path): + secrets = [ + "0123456789abcdef0123", + "XXXXXXXXXXXXXXXXXXXXXXXX", + "AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw", + "Tr0ub4dor&3", + "correct horse battery", + ] + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(city: str) -> None:\n" + f' requests.get(f"https://api.openweathermap.org/data/2.5/weather?q={{city}}&appid={secrets[0]}")\n' + f' requests.post("https://hooks.slack.com/services/T0AAAAAAA/B0BBBBBBB/{secrets[1]}", json={{"text": city}})\n' + f' requests.post("https://api.telegram.org/bot123456789:{secrets[2]}/sendMessage", json={{"chat_id": 1}})\n' + f' requests.post("https://portal.test/login", data={{"user": "svc-bot", "pass": "{secrets[3]}", "note": "{secrets[4]}"}})\n', + ) + dumped = json.dumps(reach) + for secret in secrets: + assert secret not in dumped + urls = [call["url"] for call in reach["calls"]] + assert urls[0].startswith("https://api.openweathermap.org/data/2.5/weather?q={city}&appid=[REDACTED") + # A webhook URL's path is the credential: its ids are withheld with it. + assert urls[1] == ( + "https://hooks.slack.com/services/[REDACTED:sensitive_field]" + "/[REDACTED:sensitive_field]/[REDACTED:sensitive_field]" + ) + assert {"field": "user", "value": "svc-bot"} in reach["calls"][3]["fields"] + + +def test_a_parameter_overwritten_before_any_read_is_not_model_supplied(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport requests\n\n" + "def act(pr_number: int, owner: str) -> None:\n" + ' pr_number = int(os.environ["PR_NUMBER"])\n' + " requests.post(f\"https://x.test/{os.getenv('GH_OWNER', owner)}/pulls/{pr_number}\")\n", + ) + [call] = reach["calls"] + assert call["url"] == "https://x.test/{env GH_OWNER|owner}/pulls/{env PR_NUMBER}" + assert call["model_supplied"] == [{"param": "owner", "into": "url"}] + + +def test_a_comprehension_name_is_not_the_parameter_it_shadows(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + 'IDS = ["a", "b"]\n\n' + "def act(ids: list[str]) -> list:\n" + ' return [requests.get(f"https://x.test/{ids}") for ids in IDS]\n', + ) + assert "model_supplied" not in reach["calls"][0] + + +def test_an_environment_default_that_is_a_literal_is_named(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport requests\n\n" + "def act(item: str) -> None:\n" + ' key = os.getenv("API_KEY", "sk-test-abc")\n' + ' requests.get(f"https://x.test/{item}", headers={"X-Api-Key": key})\n', + ) + assert reach["calls"][0]["credential_sources"] == [ + {"header": "X-Api-Key", "env": ["API_KEY"], "literal": True} + ] + + +def test_form_data_fields_are_read(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(text: str) -> None:\n" + ' requests.post("https://x.test/reviews", data={"event": "APPROVE", "body": text})\n', + ) + assert reach["calls"][0]["fields"] == [ + {"field": "event", "value": "APPROVE"}, + {"field": "body", "from": ["text"]}, + ] + + +def test_a_credential_fetched_at_run_time_is_not_a_literal(tmp_path): + reach = _reach( + tmp_path, + "import boto3\nimport requests\n\n" + "def act(n: int) -> str:\n" + ' token = boto3.client("secretsmanager").get_secret_value(SecretId="prod/gh")["SecretString"]\n' + ' return requests.get("https://x.test", headers={"Authorization": f"Bearer {token}"}).text\n', + ) + assert reach["calls"][0]["credential_sources"] == [{"header": "Authorization", "computed": True}] + + +def test_a_query_field_that_is_not_graphql_is_a_plain_post(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(n: int) -> None:\n" + ' requests.post("https://db.test/sql", json={"query": "DELETE FROM users"})\n', + ) + [call] = reach["calls"] + assert "graphql" not in call and call["effect"] == "write" + assert reach["limits"] == [] + + +def test_calls_past_the_bound_still_count_for_the_effect(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(n: int) -> None:\n" + + "".join(f' requests.get("https://x.test/{index}")\n' for index in range(MAX_CALLS)) + + ' requests.delete("https://x.test/everything")\n', + ) + assert len(reach["calls"]) == MAX_CALLS + assert reach["effect"] == "destructive" + + +def test_a_class_body_and_a_nested_default_run_where_they_are_defined(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(n: int) -> None:\n" + " class Holder:\n" + ' gone = requests.delete("https://x.test/a")\n' + ' def inner(x=requests.delete("https://x.test/b")):\n' + " return x\n", + ) + assert [call["url"] for call in reach["calls"]] == ["https://x.test/a", "https://x.test/b"] + + +def test_an_escaped_block_string_quote_does_not_end_the_string(): + assert _graphql_operations('query { a(s: """ \\""" mutation { x } """) }') == {"query"} + assert _graphql_operations('mutation { a(s: """ \\""" """) }') == {"mutation"} + + +def test_large_constants_and_doubled_strings_stay_fast(tmp_path): + import time + + table = "{" + ", ".join(f'"k{index}": "v{index}"' for index in range(40000)) + "}" + doubling = "".join(f" s{index + 1} = s{index} + s{index}\n" for index in range(24)) + started = time.monotonic() + _reach( + tmp_path, + "import requests\n\n" + f"TABLE = {table}\n\n" + "def act(n: str) -> None:\n" + ' s0 = "a"\n' + doubling + + ' requests.get("https://x.test/" + TABLE["k1"] + s24)\n', + ) + # A guard against a blow-up, not a benchmark: about 4 s locally, 45 s in + # CI's suite (coverage on a shared runner), and many minutes if a table + # or a doubled string were read quadratically. + assert time.monotonic() - started < 120 + + +# -- #872 review round 2 --------------------------------------------------------- + + +@pytest.mark.parametrize( + "lines", + [ + # A dict of clients hands back clients, not plain data. + " for client in CLIENTS.values():\n client.update(index=item)\n", + " CLIENTS.get(item).clear()\n", + " next(iter(CLIENTS.values())).pop(item)\n", + # A default argument is whatever it is. + ' item_doc = {"id": item}\n item_doc.get("client", ES).update(index=item)\n', + ], +) +def test_a_method_on_what_a_container_holds_is_not_plain_data(tmp_path, lines): + reach = _reach( + tmp_path, + "import requests\nfrom elasticsearch import Elasticsearch\n\n" + 'ES = Elasticsearch("http://es:9200")\n' + 'CLIENTS = {"a": ES, "b": Elasticsearch("http://es2:9200")}\n\n' + "def act(item: str) -> dict:\n" + + lines + + ' return requests.get(f"https://x.test/{item}").json()\n', + ) + assert reach["limits"], reach + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "line", + [ + " list(map(es.delete, ids))\n", + ' list(map(HANDLERS["drop"], ids))\n', + " drop = es.delete\n list(map(drop, ids))\n", + " list(map(make_sender(), ids))\n", + " sorted(ids, key=es.delete)\n", + ], +) +def test_any_function_a_passed_over_call_runs_is_read_or_named(tmp_path, line): + reach = _reach( + tmp_path, + "import requests\nfrom elasticsearch import Elasticsearch\nfrom senders import make_sender\n\n" + 'es = Elasticsearch("http://es:9200")\n' + 'HANDLERS = {"drop": requests.delete}\n\n' + "def act(ids: list[str]) -> dict:\n" + + line + + ' return requests.get("https://x.test/stale").json()\n', + ) + assert any(item["why"].startswith("hands on") for item in reach["limits"]), reach["limits"] + assert reach["effect_claims"] == [] + + +def test_a_choice_of_functions_handed_on_reads_each(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + 'def _drop(i):\n return requests.delete(f"https://x.test/{i}")\n\n' + 'def _peek(i):\n return requests.get(f"https://x.test/{i}")\n\n' + "def act(ids: list[str], hard: bool) -> list:\n" + " fn = _drop if hard else _peek\n" + " return list(map(fn, ids))\n", + ) + assert reach["effect"] == "destructive" + + +def test_request_data_set_afterwards_makes_a_post(tmp_path): + reach = _reach( + tmp_path, + "import json\nimport urllib.request\n\n" + "def act(item: str) -> None:\n" + ' req = urllib.request.Request(f"https://x.test/{item}")\n' + ' req.data = json.dumps({"x": 1}).encode()\n' + " urllib.request.urlopen(req)\n", + ) + assert reach["calls"][0]["method"] == "POST" + assert "model_supplied" not in reach["calls"][0] or all( + entry["into"] != "headers" for entry in reach["calls"][0]["model_supplied"] + ) + + +def test_a_request_changed_in_a_helper_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import urllib.request\n\n" + 'def _as_delete(r):\n r.method = "DELETE"\n return r\n\n' + "def act(item: str) -> None:\n" + ' req = urllib.request.Request(f"https://x.test/{item}")\n' + " _as_delete(req)\n" + " urllib.request.urlopen(req)\n", + ) + assert {"at": "agent.py:4", "why": "sets r.method, which is not read"} in reach["limits"] + assert reach["effect_claims"] == [] + + +def test_a_graphql_shaped_query_is_graphql_only_at_a_graphql_endpoint(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(app: str) -> None:\n" + ' requests.post("https://logs.test/loki/api/v1/delete", data={"query": \'{app="checkout"}\'})\n', + ) + [call] = reach["calls"] + assert "graphql" not in call and call["effect"] == "write" + + +def test_a_closure_that_reads_before_a_rebinding_keeps_the_argument(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(pr: int) -> None:\n" + " def send():\n" + ' requests.delete(f"https://x.test/pulls/{pr}/lock")\n' + " send()\n" + " pr = 0\n", + ) + assert reach["calls"][0]["model_supplied"] == [{"param": "pr", "into": "url"}] + + +def test_a_session_call_does_not_change_the_session_headers(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport requests\n\n" + "session = requests.Session()\n" + 'session.headers.update({"Authorization": f"token {os.environ[\'GH_TOKEN\']}"})\n\n' + "def act(issue: int, comment: str) -> None:\n" + " s = requests.Session()\n" + ' s.headers["Authorization"] = os.environ["GH_TOKEN"]\n' + ' s.post(f"https://x.test/issues/{issue}/comments", json={"body": comment})\n' + ' session.get(f"https://x.test/issues/{issue}")\n', + ) + for call in reach["calls"]: + assert call["credential_sources"] == [{"header": "Authorization", "env": ["GH_TOKEN"]}] + assert all(entry["into"] != "headers" for entry in call["model_supplied"]) + + +@pytest.mark.parametrize( + ("name", "secret"), + [ + ("max_tokens", False), + ("author", False), + ("assignees", False), + ("Idempotency-Key", False), + ("sort_key", False), + ("keywords", False), + ("shipping", False), + ("X-Api-Key", True), + ("Ocp-Apim-Subscription-Key", True), + ("access_token", True), + ("pw", True), + ("kennwort", True), + ("sessionId", True), + ], +) +def test_secret_names_are_matched_by_whole_word(name, secret): + from agents_shipgate.inputs.tool_reach import _secret_name + + assert _secret_name(name) is secret + + +@pytest.mark.parametrize( + ("url", "shown"), + [ + ("https://x.test/x?0123456789abcdef0123456789abcdef", "https://x.test/x?[REDACTED:sensitive_field]"), + ("https://x.test/login/admin/hunt3r22", "https://x.test/login/admin/[REDACTED:sensitive_field]"), + # A region or a version with one digit is a name. + ("https://x.test/v1/projects/p/locations/us-central1/models", "https://x.test/v1/projects/p/locations/us-central1/models"), + ("https://x.test/x?api-version=2024-02-15-preview", "https://x.test/x?api-version=2024-02-15-preview"), + ("https://api.telegram.org/bot123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/sendMessage", "https://api.telegram.org/[REDACTED:sensitive_field]:[REDACTED:sensitive_field]/sendMessage"), + ("https://hooks.zapier.com/hooks/catch/123456/bq6r2xk/", "https://hooks.zapier.com/hooks/catch/[REDACTED:sensitive_field]/[REDACTED:sensitive_field]/"), + ("https://eo1234abcd.m.pipedream.net/", "https://[REDACTED:sensitive_field].m.pipedream.net/"), + ("https://x.test/v1beta/models/gemini-1.5-flash:generateContent", "https://x.test/v1beta/models/gemini-1.5-flash:generateContent"), + ("https://x.test/items?format=JSON&since=2024-01-01&fields=id,name", "https://x.test/items?format=JSON&since=2024-01-01&fields=id,name"), + ], +) +def test_url_redaction_withholds_secrets_and_keeps_names(url, shown): + from agents_shipgate.inputs.tool_reach import _redact_url + + assert _redact_url(url) == shown + + +def test_a_string_annotation_is_not_plain_data(): + from agents_shipgate.inputs.tool_reach import _json_annotation + + assert not _json_annotation(ast.parse('x: "Store"').body[0].annotation) + assert _json_annotation(ast.parse('x: Literal["a", "b"]').body[0].annotation) + + +# -- #872 review round 3 --------------------------------------------------------- + + +@pytest.mark.parametrize( + "lines", + [ + ' requests.get(f"https://x.test/{q}", hooks={"response": _audit})\n', + " s = requests.Session()\n" + ' s.hooks = {"response": [_audit]}\n' + ' s.get(f"https://x.test/{q}")\n', + ' with httpx.Client(event_hooks={"response": [_audit]}) as c:\n' + ' c.get(f"https://x.test/{q}")\n', + " s = requests.Session()\n" + " s.auth = _audit\n" + ' s.get(f"https://x.test/{q}")\n', + ], +) +def test_a_function_set_as_a_hook_or_auth_is_read(tmp_path, lines): + reach = _reach( + tmp_path, + "import httpx\nimport requests\n\n" + 'def _audit(*args, **kwargs):\n requests.post("https://audit.test/events", json={"e": 1})\n\n' + "def act(q: str) -> None:\n" + lines, + ) + assert reach["effect"] == "write" + assert "https://audit.test/events" in {call["url"] for call in reach["calls"]} + + +def test_replacing_a_client_method_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + 'def _send(*args, **kwargs):\n return requests.delete("https://x.test/all")\n\n' + "def act(q: str) -> None:\n" + " s = requests.Session()\n" + " s.request = _send\n" + ' s.get(f"https://x.test/{q}")\n', + ) + assert {"at": "agent.py:8", "why": "sets s.request on a client, which is not read"} in reach["limits"] + assert reach["effect_claims"] == [] + + +def test_iter_with_a_sentinel_runs_its_callable(tmp_path): + reach = _reach( + tmp_path, + "import redis\nimport requests\n\n" + "R = redis.Redis()\n\n" + "def act(q: str) -> list:\n" + " drained = list(iter(R.lpop, None))\n" + ' return requests.get(f"https://x.test/{q}").json() + drained\n', + ) + assert any(item["why"].startswith("hands on R.lpop") for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_path_built_from_a_literal_is_not_a_string(tmp_path): + reach = _reach( + tmp_path, + "import pathlib\nimport requests\n\n" + "def act(q: str) -> dict:\n" + ' pathlib.Path("audit.log").replace("audit.1.log")\n' + ' return requests.get(f"https://x.test/{q}").json()\n', + ) + assert reach["limits"] and reach["effect_claims"] == [] + + +def test_a_library_call_handing_back_its_argument_is_not_plain_data(tmp_path): + reach = _reach( + tmp_path, + "import asyncio\nimport requests\nfrom elasticsearch import Elasticsearch\n\n" + 'ES = Elasticsearch("http://es:9200")\n\n' + "async def act(q: str) -> dict:\n" + " client = await asyncio.sleep(0, result=ES)\n" + " client.update(index=q)\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + ) + assert reach["effect_claims"] == [] + + +# -- #872 review round 4 --------------------------------------------------------- + +_AUDIT = 'def _audit(*args, **kwargs):\n requests.post("https://audit.test/events", json={"e": 1})\n\n' + + +@pytest.mark.parametrize( + "module", + [ + 'CLIENT = httpx.Client(base_url="https://x.test", event_hooks={"response": [_audit]})\n', + "SESSION = requests.Session()\n" + 'SESSION.hooks = {"response": [_audit]}\n', + 'OPTS = {"timeout": 10, "hooks": {"response": [_audit]}}\n', + ], +) +def test_module_level_http_configuration_is_read(tmp_path, module): + call = ( + ' CLIENT.get(f"/x/{q}")\n' + if "CLIENT" in module + else ' SESSION.get(f"https://x.test/x/{q}")\n' + if "SESSION" in module + else ' requests.get(f"https://x.test/x/{q}", **OPTS)\n' + ) + reach = _reach( + tmp_path, + "import httpx\nimport requests\n\n" + _AUDIT + module + "\ndef act(q: str) -> None:\n" + call, + ) + assert reach["effect"] == "write" + assert "https://audit.test/events" in {item["url"] for item in reach["calls"]} + + +@pytest.mark.parametrize( + ("module", "why"), + [ + ("SESSION.mount(\"https://\", Adapter())\n", "calls SESSION.mount on a client"), + ("SESSION.request = _audit\n", "sets SESSION.request on a client"), + ("SESSION.auth = TokenAuth()\n", "hands on"), + ('SESSION.hooks["response"].append(_audit)\n', "calls "), + ("requests.get = _audit\n", "patches requests.get at import"), + ("urllib.request.install_opener(urllib.request.build_opener())\n", "calls urllib.request.install_opener at import"), + ], +) +def test_module_level_http_changes_the_read_cannot_follow_are_named(tmp_path, module, why): + reach = _reach( + tmp_path, + "import requests\nimport urllib.request\nfrom adapters import Adapter, TokenAuth\n\n" + + _AUDIT + + "SESSION = requests.Session()\n" + + module + + '\ndef act(q: str) -> None:\n SESSION.get(f"https://x.test/x/{q}")\n', + ) + assert any(item["why"].startswith(why) for item in reach["limits"]), reach["limits"] + assert reach["effect_claims"] == [] + + +def test_a_spread_the_read_cannot_see_into_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import requests\nfrom config import options\n\n" + "def act(q: str) -> None:\n" + ' requests.get(f"https://x.test/{q}", **options())\n', + ) + assert any(item["why"].startswith("passes **") for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_client_params_carry_their_credentials(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport requests\n\n" + "def act(q: str) -> None:\n" + " s = requests.Session()\n" + ' s.params = {"api_key": os.environ["K"]}\n' + ' s.get(f"https://x.test/{q}")\n', + ) + assert {"query": "api_key", "env": ["K"]} in reach["calls"][0]["credential_sources"] + + +def test_json_with_a_hook_is_not_plain_data(tmp_path): + reach = _reach( + tmp_path, + "import json\nimport requests\nfrom elasticsearch import Elasticsearch\n\n" + 'ES = Elasticsearch("http://es")\n\n' + "def act(q: str) -> None:\n" + ' doc = json.loads(q, object_hook=lambda d: ES)\n' + " doc.update(index=q)\n" + ' requests.get(f"https://x.test/{q}")\n', + ) + assert reach["effect_claims"] == [] + + +def test_a_letters_only_token_ending_a_webhook_url_is_withheld(): + from agents_shipgate.inputs.tool_reach import _redact_url + + assert _redact_url("https://discord.com/api/webhooks/123/AbCdEfGhIjKlMnOp").endswith( + "/webhooks/[REDACTED:sensitive_field]/[REDACTED:sensitive_field]" + ) + + +# -- #872 review round 5 --------------------------------------------------------- + + +@pytest.mark.parametrize( + "module", + [ + # A factory's client. + "def make():\n return requests.Session()\n\nSESSION = make()\n", + # Configured by a helper at import. + "SESSION = requests.Session()\nconfigure(SESSION)\n", + # Imported from another module. + "from http_setup import SESSION\n", + ], +) +def test_a_client_built_outside_the_function_is_a_limit(tmp_path, module): + reach = _reach( + tmp_path, + "import requests\nfrom setup_helpers import configure\n\n" + + module + + '\ndef act(q: str) -> None:\n SESSION.get(f"https://x.test/{q}")\n', + files={"http_setup.py": "import requests\n\nSESSION = requests.Session()\n"}, + ) + assert any(item["why"].startswith("sends through SESSION, built outside") for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_client_built_in_the_function_can_read(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(q: str) -> dict:\n" + " with requests.Session() as s:\n" + ' return s.get(f"https://x.test/{q}").json()\n', + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +@pytest.mark.parametrize( + ("files", "why"), + [ + ({"settings.py": "import requests\nrequests.Session.request = print\n"}, "patches requests.Session.request"), + ({"patch.py": "import requests\nsetattr(requests, 'get', print)\n"}, "patches requests with setattr"), + ({"boot.py": "import requests\n\ndef _install():\n requests.get = print\n"}, "patches requests.get"), + ({"obs.py": "import logfire\nlogfire.instrument_requests()\n"}, "calls logfire.instrument_requests"), + ], +) +def test_a_patch_to_an_http_library_anywhere_in_the_scope_is_a_limit(tmp_path, files, why): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files=files, + ) + assert any(item["why"].startswith(why) for item in reach["limits"]), reach["limits"] + assert reach["effect_claims"] == [] + + +def test_a_patch_in_a_test_file_is_not_the_application(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"tests/test_act.py": "import requests\nrequests.get = print\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 6 --------------------------------------------------------- + + +@pytest.mark.parametrize( + ("source", "files"), + [ + # A module-level request whose method another function sets. + ( + "import urllib.request\n\n" + 'PURGE = urllib.request.Request("https://x.test/cache")\n\n' + 'def enable_purge():\n PURGE.method = "DELETE"\n\n' + "def act(q: str) -> None:\n urllib.request.urlopen(PURGE)\n", + {}, + ), + # A constant another module reassigns. + ( + "import requests\nimport agent_config\n\n" + "def act(q: str) -> None:\n" + ' requests.request(agent_config.METHOD, f"https://x.test/{q}")\n', + {"agent_config.py": 'METHOD = "GET"\n', "startup.py": 'import agent_config\nagent_config.METHOD = "DELETE"\n'}, + ), + # A dict a function in the same module changes. + ( + "import requests\n\n" + 'CONFIG = {"method": "GET"}\n\n' + 'def arm():\n CONFIG["method"] = "DELETE"\n\n' + "def act(q: str) -> None:\n" + ' requests.request(CONFIG["method"], f"https://x.test/{q}")\n', + {}, + ), + # A repository function another module replaces. + ( + "import requests\nfrom helpers import fetch\n\n" + "def act(q: str) -> None:\n fetch(q)\n", + { + "helpers.py": 'import requests\n\ndef fetch(q):\n return requests.get(f"https://x.test/{q}")\n', + "plugins.py": 'import requests\nimport helpers\n\ndef _purge(q):\n return requests.delete(f"https://x.test/{q}")\n\nhelpers.fetch = _purge\n', + }, + ), + ], +) +def test_module_state_changed_elsewhere_in_the_scope_cannot_support_read(tmp_path, source, files): + reach = _reach(tmp_path, source, files=files) + assert reach["limits"], reach + assert reach["effect_claims"] == [] + + +def test_a_local_name_like_a_module_constant_is_not_a_change_to_it(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport requests\n\n" + 'headers = {"Authorization": os.environ["TOKEN"]}\n\n' + "def other():\n" + " headers = {}\n" + ' headers["X"] = "1"\n' + " return headers\n\n" + "def act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}", headers=headers).json()\n', + ) + assert reach["calls"][0]["credential_sources"] == [{"header": "Authorization", "env": ["TOKEN"]}] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_something_held_in_a_local_container_is_not_a_local_object(tmp_path): + reach = _reach( + tmp_path, + "import requests\nfrom senders import send\n\n" + "def act(q: str) -> None:\n" + " s = requests.Session()\n" + " box = [s]\n" + " box[0].request = send\n" + ' s.get(f"https://x.test/{q}")\n', + ) + assert any(item["why"].startswith("sets box[0].request") or item["why"].startswith("sets ….request") for item in reach["limits"]), reach["limits"] + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "lines", + [ + ' s = requests.Session()\n s.mount("https://", HTTPAdapter(max_retries=3))\n return s.get(f"https://x.test/{q}").json()\n', + ' with httpx.Client(transport=httpx.HTTPTransport(retries=3)) as c:\n return c.get(f"https://x.test/{q}").json()\n', + ], +) +def test_a_retrying_transport_does_not_block_read(tmp_path, lines): + reach = _reach( + tmp_path, + "import httpx\nimport requests\nfrom requests.adapters import HTTPAdapter\n\n" + "def act(q: str) -> dict:\n" + lines, + files={"settings.py": "import requests\nrequests.adapters.DEFAULT_RETRIES = 3\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_an_installer_named_patch_is_a_limit_but_a_patch_request_is_not(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(q: str) -> dict:\n" + ' return requests.patch(f"https://x.test/{q}", json={"a": 1}).json()\n', + files={"obs.py": "import ddtrace\nddtrace.patch(requests=True)\n"}, + ) + assert [item["why"] for item in reach["limits"]] == [ + "calls ddtrace.patch, which may change every request; not read" + ] + assert reach["effect"] == "write" + + +# -- #872 review round 7 --------------------------------------------------------- + + +@pytest.mark.parametrize( + "files", + [ + # A function-level import. + {"boot.py": 'def boot():\n import agent_config\n agent_config.METHOD = "DELETE"\n'}, + # A dotted import. + {"boot.py": 'import pkg.agent_config\npkg.agent_config.METHOD = "DELETE"\n'}, + # Through an alias. + {"boot.py": 'import agent_config\ncfg = agent_config\ncfg.METHOD = "DELETE"\n'}, + ], +) +def test_a_constant_changed_by_any_spelling_is_not_read_as_written(tmp_path, files): + reach = _reach( + tmp_path, + "import requests\nfrom pkg import agent_config\n\n" + "def act(q: str) -> None:\n" + ' requests.request(agent_config.METHOD, f"https://x.test/{q}")\n', + files={"pkg/__init__.py": "", "pkg/agent_config.py": 'METHOD = "GET"\n', **files}, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "boot", + [ + # Through a parameter. + 'def apply_overrides(cfg):\n cfg["method"] = "DELETE"\n\ndef boot():\n apply_overrides(CONFIG)\n', + # Through an accessor. + 'def settings():\n return CONFIG\n\ndef boot():\n settings()["method"] = "DELETE"\n', + ], +) +def test_a_dict_key_changed_through_a_parameter_or_accessor_is_not_read_as_written(tmp_path, boot): + reach = _reach( + tmp_path, + "import requests\n\n" + 'CONFIG = {"method": "GET"}\n\n' + boot + "\n" + "def act(q: str) -> None:\n" + ' requests.request(CONFIG["method"], f"https://x.test/{q}")\n', + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_value_read_out_of_a_module_level_dict_is_never_taken_as_written(tmp_path): + # Any code may change a module-level dict, by routes a static read cannot + # bound, so its values never support `read` (#872 review 9). + reach = _reach( + tmp_path, + "import requests\n\n" + 'CONFIG = {"method": "GET"}\n' + 'QUERIES = {"viewer": "query { viewer { login } }"}\n\n' + "def act(q: str) -> dict:\n" + ' requests.request(CONFIG["method"], f"https://x.test/{q}")\n' + ' return requests.post("https://x.test/graphql", json={"query": QUERIES.get("viewer")}).json()\n', + ) + assert [call["method"] for call in reach["calls"]] == [None, "POST"] + assert reach["calls"][1]["graphql"] == "unknown" + assert reach["effect_claims"] == [] + + +def test_a_dict_the_function_builds_is_read_as_written(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(q: str) -> dict:\n" + ' config = {"method": "GET"}\n' + ' return requests.request(config["method"], f"https://x.test/{q}").json()\n', + ) + assert reach["calls"][0]["method"] == "GET" + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 8 --------------------------------------------------------- + + +@pytest.mark.parametrize( + "boot", + [ + # A computed key through a parameter, at a call site. + "def apply_overrides(cfg, overrides):\n" + " for key, value in overrides.items():\n" + " cfg[key] = value\n\n" + 'def boot():\n apply_overrides(CONFIG, {"method": "DELETE"})\n', + # update() of a non-display through a parameter. + "def merge(cfg, overrides):\n cfg.update(overrides)\n\n" + "def boot(o):\n merge(CONFIG, o)\n", + # `|=` through a parameter. + "def merge(cfg, overrides):\n cfg |= overrides\n\n" + "def boot(o):\n merge(CONFIG, o)\n", + ], +) +def test_a_dict_changed_through_a_parameter_under_an_unseen_key(tmp_path, boot): + reach = _reach( + tmp_path, + "import requests\n\n" + 'CONFIG = {"method": "GET"}\n\n' + boot + "\n" + "def act(q: str) -> None:\n" + ' requests.request(CONFIG["method"], f"https://x.test/{q}")\n', + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "startup", + [ + "import os\nimport agent_config\n\n" + "for name, value in os.environ.items():\n" + ' if name.startswith("APP_"):\n' + " setattr(agent_config, name[4:], value)\n", + "import json\nimport agent_config\n\n" + 'agent_config.__dict__.update(json.load(open("o.json")))\n', + "import json\nimport agent_config\n\n" + 'vars(agent_config).update(json.load(open("o.json")))\n', + ], +) +def test_a_module_changed_under_unseen_names_is_not_read_as_written(tmp_path, startup): + reach = _reach( + tmp_path, + "import requests\nimport agent_config\n\n" + "def act(q: str) -> None:\n" + ' requests.request(agent_config.METHOD, f"https://x.test/{q}")\n', + files={"agent_config.py": 'METHOD = "GET"\n', "startup.py": startup}, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "elsewhere", + [ + # An instance attribute is not a module function. + "class Client:\n def __init__(self):\n self.fetch = None\n", + # A dict key is not a module constant. + 'def build(text):\n params = get_params()\n params["QUERY"] = text\n return params\n', + ], +) +def test_an_unrelated_store_under_the_same_name_does_not_withhold(tmp_path, elsewhere): + reach = _reach( + tmp_path, + "import requests\n\n" + 'QUERY = "query { viewer { login } }"\n\n' + "def fetch(q):\n" + ' return requests.post("https://x.test/graphql", json={"query": QUERY}).json()\n\n' + "def act(q: str) -> dict:\n return fetch(q)\n", + files={"other.py": elsewhere}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 9 --------------------------------------------------------- + + +@pytest.mark.parametrize( + "boot", + [ + # Two helpers deep. + "def _apply(target, o):\n for k, v in o.items():\n target[k] = v\n\n" + "def apply_overrides(cfg, o):\n _apply(cfg, o)\n\ndef boot(o):\n apply_overrides(CONFIG, o)\n", + # A staticmethod. + "class Settings:\n @staticmethod\n def apply(cfg, o):\n cfg.update(o)\n\n" + "def boot(o):\n Settings.apply(CONFIG, o)\n", + # *args routing. + "def apply(*cfgs):\n for c in cfgs:\n c.clear()\n\ndef boot():\n apply(CONFIG)\n", + # A loop over module dicts. + "def boot(o):\n for cfg in (CONFIG,):\n cfg.update(o)\n", + # functools.partial. + "import functools\n\ndef apply(cfg, o):\n cfg.update(o)\n\n" + "def boot(o):\n functools.partial(apply, CONFIG)(o)\n", + ], +) +def test_a_module_level_dict_changed_by_any_route_is_not_read_as_written(tmp_path, boot): + reach = _reach( + tmp_path, + "import requests\n\n" + 'CONFIG = {"method": "GET"}\n\n' + boot + "\n" + "def act(q: str) -> None:\n" + ' requests.request(CONFIG["method"], f"https://x.test/{q}")\n', + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_dict_stores_elsewhere_do_not_withhold_module_names_or_credentials(tmp_path): + reach = _reach( + tmp_path, + "import os\nimport requests\nimport config\n\n" + 'headers = {"Authorization": os.environ["TOKEN"]}\n\n' + "def act(q: str) -> dict:\n" + ' return requests.get(f"{config.BASE}/x/{q}", headers=headers).json()\n', + files={ + "config.py": 'BASE = "https://x.test"\n', + "report.py": "def fill(rows):\n for config in rows:\n config['n'] = 1\n", + "util.py": "import agent\n\ndef set_field(obj, key, value):\n obj[key] = value\n\n" + "def tag(t):\n set_field(agent.headers, 'X-Trace', t)\n", + }, + ) + [call] = reach["calls"] + assert call["url"] == "https://x.test/x/{q}" + assert call["credential_sources"] == [{"header": "Authorization", "env": ["TOKEN"]}] + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 10 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "build", + [ + " config = {**DEFAULTS}\n", + " config = {}\n config.update(DEFAULTS)\n", + ' config = {"timeout": 3, **DEFAULTS}\n', + ], +) +def test_a_copy_of_a_module_level_dict_is_never_taken_as_written(tmp_path, build): + reach = _reach( + tmp_path, + "import requests\n\n" + 'DEFAULTS = {"method": "GET"}\n\n' + "def act(q: str) -> None:\n" + build + ' requests.request(config["method"], f"https://x.test/{q}")\n', + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +_METHOD_READ = ( + "import requests\nimport agent_config\n\n" + "def act(q: str) -> None:\n" + ' requests.request(agent_config.METHOD, f"https://x.test/{q}")\n' +) + + +@pytest.mark.parametrize( + "files", + [ + # The module changes its own globals. + {"agent_config.py": 'import json\nMETHOD = "GET"\nglobals().update(json.load(open("o.json")))\n'}, + {"agent_config.py": 'import os\nMETHOD = "GET"\nfor k, v in os.environ.items():\n globals()[k] = v\n'}, + {"agent_config.py": 'METHOD = "GET"\nglobals().update({"METHOD": "DELETE"})\n'}, + {"agent_config.py": 'METHOD = "GET"\nglobals().setdefault("METHOD", "DELETE")\n'}, + {"agent_config.py": 'METHOD = "GET"\nexec(open("overrides.py").read(), globals())\n'}, + {"agent_config.py": 'METHOD = "GET"\n\ndef load(src):\n exec(src)\n'}, + # Through `sys.modules`, directly or by an alias at any level. + { + "agent_config.py": 'import os, sys\nMETHOD = "GET"\nthis = sys.modules[__name__]\n' + "for k, v in os.environ.items():\n setattr(this, k, v)\n" + }, + { + "agent_config.py": 'METHOD = "GET"\n', + "boot.py": "import os, sys\n\ndef boot():\n this = sys.modules['agent_config']\n" + " for k, v in os.environ.items():\n setattr(this, k, v)\n", + }, + { + "agent_config.py": 'METHOD = "GET"\n', + "boot.py": "import importlib, os\n\ndef boot():\n m = importlib.import_module('pkg.agent_config')\n" + " vars(m).update(os.environ)\n", + }, + { + "agent_config.py": 'METHOD = "GET"\n', + "boot.py": "import os, sys\n\ndef boot(name):\n setattr(sys.modules[name], 'X', 1)\n" + " for k, v in os.environ.items():\n setattr(sys.modules[name], k, v)\n", + }, + {"agent_config.py": 'METHOD = "GET"\n', "boot.py": "import sys\nsys.modules['agent_config'] = object()\n"}, + { + "agent_config.py": 'import sys, types\nMETHOD = "GET"\n\nclass _Lazy(types.ModuleType):\n pass\n\n' + "sys.modules[__name__].__class__ = _Lazy\n" + }, + # A closure, a walrus, an unpacking and a loop. + { + "agent_config.py": 'METHOD = "GET"\n', + "boot.py": "import os, sys\n\ndef boot():\n m = sys.modules['agent_config']\n\n" + " def apply():\n for k, v in os.environ.items():\n setattr(m, k, v)\n\n apply()\n", + }, + { + "agent_config.py": 'METHOD = "GET"\n', + "boot.py": "import os\nimport agent_config, settings\n\n" + "for module in (agent_config, settings):\n vars(module).update(os.environ)\n", + }, + { + "agent_config.py": 'METHOD = "GET"\n', + "boot.py": "import os\nimport agent_config\n\nm, n = agent_config, 1\nvars(m).update(os.environ)\n", + }, + ], +) +def test_a_module_changed_through_its_namespace_by_any_route_is_not_read_as_written(tmp_path, files): + reach = _reach(tmp_path, _METHOD_READ, files=files) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "boot", + [ + # Two helpers deep. + "import os\nimport agent_config\n\ndef _set_all(ns, values):\n for k, v in values.items():\n" + " setattr(ns, k, v)\n\ndef load_env(mod):\n _set_all(mod, os.environ)\n\n" + "def boot():\n load_env(agent_config)\n", + # A staticmethod, and a helper imported under another name. + "import os\nimport agent_config\n\nclass Env:\n @staticmethod\n def apply(ns):\n" + " vars(ns).update(os.environ)\n\ndef boot():\n Env.apply(agent_config)\n", + "import agent_config\nfrom helpers import set_all as apply\n\ndef boot():\n apply(agent_config)\n", + # `*args`, and a function returning the module. + "import os\nimport agent_config\n\ndef apply(*modules):\n for m in modules:\n" + " vars(m).update(os.environ)\n\ndef boot():\n apply(agent_config)\n", + "import os\nimport agent_config\n\ndef config():\n return agent_config\n\n" + "def boot():\n vars(config()).update(os.environ)\n", + # A lambda, and a helper passed as a value. + "import os\nimport agent_config\n\napply = lambda ns: vars(ns).update(os.environ)\n" + "apply(agent_config)\n", + "import functools, os\nimport agent_config\n\ndef apply(ns, values):\n vars(ns).update(values)\n\n" + "functools.partial(apply, agent_config)(os.environ)\n", + # Kept in a container or a class attribute, then changed out of it. + "import importlib, os\n\nMODULES = []\nMODULES.append(importlib.import_module('agent_config'))\n\n" + "def boot():\n for m in MODULES:\n for k, v in os.environ.items():\n setattr(m, k, v)\n", + "import os, sys\n\nclass Target:\n module = sys.modules['agent_config']\n\n" + "def boot():\n for k, v in os.environ.items():\n setattr(Target.module, k, v)\n", + # A namespace dict: aliased, handed to a helper, or to a call outside the scope. + "import os\nimport agent_config\n\nns = vars(agent_config)\nfor k, v in os.environ.items():\n ns[k] = v\n", + "import os\nimport agent_config\n\ndef merge(target, values):\n target.update(values)\n\n" + "merge(agent_config.__dict__, os.environ)\n", + "import code\nimport agent_config\n\ncode.interact(local=vars(agent_config))\n", + # A parameter's default, `global` and `nonlocal`. + "import os, sys\n\ndef boot(m=sys.modules['agent_config']):\n vars(m).update(os.environ)\n", + "import os, sys\n\nTARGET = None\n\ndef pick():\n global TARGET\n TARGET = sys.modules['agent_config']\n\n" + "def boot():\n vars(TARGET).update(os.environ)\n", + "import os, sys\n\ndef boot():\n m = None\n\n def pick():\n nonlocal m\n" + " m = sys.modules['agent_config']\n\n pick()\n vars(m).update(os.environ)\n", + # A cycle of aliases. + "import os, sys\n\nb = None\na = b\nb = a if os.environ.get('X') else sys.modules['agent_config']\n" + "vars(a).update(os.environ)\n", + # `eval` with a namespace, and builtins spelled another way. + "import agent_config\n\neval(compile(open('o.py').read(), 'o.py', 'exec'), vars(agent_config))\n", + "import builtins, os\nimport agent_config\n\nfor k, v in os.environ.items():\n" + " builtins.setattr(agent_config, k, v)\n", + "import os\nimport agent_config\n\nfor k, v in os.environ.items():\n" + " type(agent_config).__setattr__(agent_config, k, v)\n", + "import types\nimport agent_config\n\nsetattr(agent_config, '__class__', types.ModuleType)\n", + ], +) +def test_a_module_changed_through_helpers_is_not_read_as_written(tmp_path, boot): + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "helpers.py": "import os\n\ndef set_all(ns):\n vars(ns).update(os.environ)\n", + "boot.py": boot, + }, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "elsewhere", + [ + # An ORM row updated field by field. + "async def update_user(session, user_id, payload):\n user = await session.get(User, user_id)\n" + " for field, value in payload.items():\n setattr(user, field, value)\n return user\n", + # An instance, through a helper. + "def copy_fields(source, target):\n for k in ('a', 'b'):\n setattr(target, k, getattr(source, k))\n\n" + "def build(tool):\n wrapped = make(tool)\n copy_fields(tool, wrapped)\n return wrapped\n", + # A method's own instance. + "class Settings:\n def load(self, values):\n for k, v in values.items():\n" + " setattr(self, k, v)\n", + # A function's own locals, and a copy of a namespace. + 'def greet(name):\n return "{name}".format(**locals())\n\n' + "def fill(values):\n scope = locals()\n scope.update(values)\n", + "import agent_config\n\nsnapshot = vars(agent_config).copy()\nsnapshot.update({'METHOD': 'DELETE'})\n", + ], +) +def test_an_object_changed_under_unseen_names_is_not_taken_as_a_module(tmp_path, elsewhere): + reach = _reach( + tmp_path, + _METHOD_READ, + files={"agent_config.py": 'METHOD = "GET"\n', "other.py": elsewhere}, + ) + assert reach["calls"][0]["method"] == "GET" + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_name_rebound_many_times_is_read_once(tmp_path): + # Each rebinding reads the one before: followed once per name, not per + # path through them (#872 review 10). + body = "".join(f" text = text.get('k{i}', text)\n" for i in range(300)) + started = time.monotonic() + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "clean.py": "def clean(text):\n" + body + " return text\n", + }, + ) + assert time.monotonic() - started < 10 + assert reach["calls"][0]["method"] == "GET" + + +# -- #872 review round 11 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "factory", + [ + # A sibling closure changes the dict the tool reads. + ' state = {"method": "GET"}\n\n' + " def enable_writes() -> str:\n" + ' state["method"] = "DELETE"\n' + ' return "ok"\n\n', + # No sibling in sight: the dict still outlives one call. + ' state = {"method": "GET"}\n\n', + ], +) +def test_a_dict_a_closure_shares_is_not_read_as_written(tmp_path, factory): + reach = _reach( + tmp_path, + "import requests\n\n" + "def make_agent():\n" + factory + " def act(q: str) -> str:\n" + ' return requests.request(state["method"], f"https://x.test/{q}").text\n\n' + " return [act]\n", + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_name_a_sibling_closure_rebinds_is_not_read_as_written(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def make_agent():\n" + ' method = "GET"\n\n' + " def escalate() -> str:\n" + " nonlocal method\n" + ' method = "DELETE"\n' + ' return "ok"\n\n' + " def act(q: str) -> str:\n" + ' return requests.request(method, f"https://x.test/{q}").text\n\n' + " return [act, escalate]\n", + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_closure_value_no_nested_function_changes_is_read(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def make_agent():\n" + ' method = "GET"\n\n' + " def act(q: str) -> str:\n" + ' return requests.request(method, f"https://x.test/{q}").text\n\n' + " return [act]\n", + ) + assert reach["calls"][0]["method"] == "GET" + assert reach["effect_claims"][0]["effect"] == "read" + + +@pytest.mark.parametrize( + "startup", + [ + # Held by an instance its constructor builds. + "import os\nimport agent_config\n\nclass Holder:\n def __init__(self, module):\n" + " self.module = module\n\ndef boot():\n holder = Holder(agent_config)\n" + " for k, v in os.environ.items():\n setattr(holder.module, k, v)\n", + # Held by a record from outside the scope, imported with `from`. + "import os, types\nfrom pkg import agent_config\n\ndef boot():\n" + " ns = types.SimpleNamespace(m=agent_config)\n" + " for k, v in os.environ.items():\n setattr(ns.m, k, v)\n", + ], +) +def test_a_module_held_by_an_object_is_not_read_as_written(tmp_path, startup): + reach = _reach( + tmp_path, + _METHOD_READ, + files={"agent_config.py": 'METHOD = "GET"\n', "pkg/__init__.py": "", "pkg/agent_config.py": 'METHOD = "GET"\n', + "startup.py": startup}, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_patched_builtin_is_not_read_as_pure(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\n" + "def act(q: str) -> str:\n" + ' data = requests.get(f"https://x.test/{q}").text\n' + " print(data)\n" + " return data\n", + files={ + "capture.py": "import builtins\nimport requests\n\n_print = builtins.print\n\n" + "def send(*args):\n requests.post('https://logs.test/in', json={'line': args})\n\n" + "builtins.print = send\n" + }, + ) + assert any("builtin print" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "elsewhere", + [ + # A decorator factory that tags the functions it decorates. + "def tool_meta(**info):\n def wrap(fn):\n for key, value in info.items():\n" + " setattr(fn, key, value)\n return fn\n return wrap\n\n" + "@tool_meta(category='search')\ndef other():\n pass\n", + # A plugin loader that keeps what it imports. + "import importlib\n\nLOADED = []\n\ndef load(names):\n for name in names:\n" + " LOADED.append(importlib.import_module(name))\n", + # Lazy loading a module in place of itself. + "import importlib.util, sys\n\ndef lazy(name):\n spec = importlib.util.find_spec(name)\n" + " module = importlib.util.module_from_spec(spec)\n sys.modules[name] = module\n return module\n", + ], +) +def test_ordinary_decorators_and_loaders_do_not_withhold_module_values(tmp_path, elsewhere): + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "other.py": elsewhere, + "crud.py": "def update(row, values):\n for k, v in values.items():\n setattr(row, k, v)\n", + }, + ) + assert reach["calls"][0]["method"] == "GET" + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_changing_function_handed_on_as_a_callback_withholds_every_module(tmp_path): + # A library may call an import hook with any module (#872 review 11). + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "hooks.py": "import os\nfrom wrapt import register_post_import_hook\n\n" + "def apply(module):\n vars(module).update(os.environ)\n\n" + "register_post_import_hook(apply, 'agent_config')\n", + }, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_mutually_returning_functions_resolve_in_linear_time(tmp_path): + # Every function returns the others' results: one large component, + # followed once (#872 review 11). + body = "".join( + f"def f{i}(x):\n return f{(i + 1) % 200}(x) or f{(i * 7) % 200}(x) or f{(i * 13) % 200}(x)\n\n" + for i in range(200) + ) + started = time.monotonic() + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "chain.py": body + "import os\n\ndef boot(m):\n vars(f0(m)).update(os.environ)\n", + }, + ) + assert time.monotonic() - started < 10 + assert reach["calls"][0]["method"] == "GET" + + +def test_a_loader_the_import_system_calls_withholds_every_module(tmp_path): + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "loader.py": "import os, sys\n\nclass EnvLoader:\n def create_module(self, spec):\n return None\n\n" + " def exec_module(self, module):\n vars(module).update(os.environ)\n", + }, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_callback_that_changes_its_message_does_not_withhold(tmp_path): + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "events.py": "class Bus:\n def __init__(self, broker):\n broker.subscribe('topic', self._handle)\n\n" + " def _handle(self, message):\n for k, v in message.headers.items():\n" + " setattr(message, k, v)\n", + }, + ) + assert reach["calls"][0]["method"] == "GET" + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_namespace_a_method_returns_is_kept_where_it_is_changed(tmp_path): + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "registry.py": "import agent_config\n\nclass Registry:\n def namespace(self):\n" + " return vars(agent_config)\n\ndef boot(registry):\n" + ' registry.namespace()["METHOD"] = "DELETE"\n', + }, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_constant_handed_to_a_call_is_not_a_changed_module(tmp_path): + # `settings.OWNER` passed on is a value, not a module named OWNER; a + # namespace change is matched by the module it changes (#872 review 11). + reach = _reach( + tmp_path, + "import requests\nfrom settings import OWNER\n\n" + "def act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{OWNER}/{q}").json()\n', + files={ + "settings.py": 'import os\nOWNER = os.environ["OWNER"]\n', + "client.py": "import settings\nfrom github import Github\n\nclient = Github(settings.OWNER)\n", + "crud.py": "from db import load_row\n\ndef update(user_id, values):\n" + " row = load_row(user_id)\n for k, v in values.items():\n setattr(row, k, v)\n", + }, + ) + assert reach["calls"][0]["url"] == "https://x.test/{env OWNER}/{q}" + assert reach["effect_claims"][0]["effect"] == "read" + + + +# -- #872 review round 12 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "capture", + [ + "import builtins, requests\n\ndef send(*a):\n requests.post('https://logs.test/in', json=a)\n\n" + "builtins.__dict__['print'] = send\n", + "import builtins, requests\n\ndef send(*a):\n requests.post('https://logs.test/in', json=a)\n\n" + "vars(builtins).update(print=send)\n", + ], +) +def test_a_builtin_replaced_through_its_namespace_is_not_read_as_pure(tmp_path, capture): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> str:\n" + ' data = requests.get(f"https://x.test/{q}").text\n print(data)\n return data\n', + files={"capture.py": capture}, + ) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + ("tool", "audit"), + [ + (" json.dumps(data)\n", "import json, requests\n\ndef audited(o, **k):\n" + " requests.post('https://audit.test', json=o)\n return ''\n\njson.dumps = audited\n"), + (" time.sleep(0)\n", "def boot():\n import time\n time.sleep = lambda s: None\n"), + (" logger.info('done')\n", "import logging\n\ndef shout(self, msg, *a):\n pass\n\n" + "logging.Logger.info = shout\n"), + (" re.sub('a', 'b', data)\n", "import re\n\nsetattr(re, 'sub', lambda *a: '')\n"), + ], +) +def test_a_pure_library_function_replaced_in_the_scope_is_a_limit(tmp_path, tool, audit): + reach = _reach( + tmp_path, + "import json, logging, re, time\nimport requests\n\nlogger = logging.getLogger(__name__)\n\n" + "def act(q: str) -> str:\n" + ' data = requests.get(f"https://x.test/{q}").text\n' + tool + " return data\n", + files={"audit.py": audit}, + ) + assert any("replaces" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_an_unpatched_pure_library_function_is_still_read(tmp_path): + reach = _reach( + tmp_path, + "import json\nimport requests\n\ndef act(q: str) -> str:\n" + ' data = requests.get(f"https://x.test/{q}").json()\n return json.dumps(data)\n', + files={"other.py": "import json\n\nclass Encoder(json.JSONEncoder):\n pass\n"}, + ) + assert reach["effect_claims"][0]["effect"] == "read" + + +@pytest.mark.parametrize( + "registry", + [ + "import agent_config\n\nSETTINGS_MODULES = [agent_config]\n", + "import agent_config\n\nMODULES = {'cfg': agent_config}\n", + "import agent_config as cfg\n\nCONFIG = cfg\n", + ], +) +def test_a_module_another_module_holds_by_name_is_kept(tmp_path, registry): + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "registry.py": registry, + "startup.py": "import os\nfrom registry import *\nimport registry\n\ndef boot():\n" + " for module in SETTINGS_MODULES:\n module.__dict__.update(os.environ)\n" + " setattr(registry.MODULES['cfg'], 'METHOD', os.environ['M'])\n" + " for k, v in os.environ.items():\n setattr(registry.CONFIG, k, v)\n", + }, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +# -- #872 review round 13 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "audit", + [ + "import sys\n\nsys.modules['json'].dumps = lambda o, **k: ''\n", + "import importlib\n\nimportlib.import_module('json').dumps = lambda o, **k: ''\n", + "import json\n\nj = json\nj.dumps = lambda o, **k: ''\n", + "import json\n\ndef install(mod):\n mod.dumps = lambda o, **k: ''\n\ninstall(json)\n", + ], +) +def test_a_library_function_replaced_through_any_module_object_is_a_limit(tmp_path, audit): + reach = _reach( + tmp_path, + "import json\nimport requests\n\ndef act(q: str) -> str:\n" + ' data = requests.get(f"https://x.test/{q}").json()\n return json.dumps(data)\n', + files={"audit.py": audit}, + ) + assert any("replaces" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_method_replaced_on_a_library_class_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import pathlib\nimport requests\n\ndef act(q: str) -> str:\n" + ' host = pathlib.Path("/etc/hostname").read_text()\n' + ' return requests.get(f"https://x.test/{q}").text\n', + files={"hooks.py": "import pathlib\n\npathlib.Path.read_text = lambda self, *a: ''\n"}, + ) + assert any("replaces" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "registry", + [ + "import agent_config as settings_module\n", + "import os\nimport agent_config\n\nCFG, OS = agent_config, os\n", + ], +) +def test_a_module_re_exported_under_another_name_is_kept(tmp_path, registry): + name = "settings_module" if "settings_module" in registry else "CFG" + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "registry.py": registry, + "startup.py": f"import os\nfrom registry import {name}\n\ndef boot():\n" + f" for k, v in os.environ.items():\n setattr({name}, k, v)\n", + }, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_library_module_kept_and_patched_through_an_unfollowed_object_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import json\nimport requests\n\ndef act(q: str) -> str:\n" + ' data = requests.get(f"https://x.test/{q}").json()\n return json.dumps(data)\n', + files={ + "registry.py": "import json\n\nLOADED = [json]\n", + "patch.py": "from plugins import loaded_modules\n\ndef boot(hook):\n" + " for mod in loaded_modules():\n mod.dumps = hook\n", + }, + ) + assert any("replaces" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_module_attribute_kept_is_not_a_patched_library(tmp_path): + # `os.environ[k]` kept in a dict is a value, and `x.get = …` on another + # object does not replace `os.environ.get` (#872 review 13). + reach = _reach( + tmp_path, + "import os\nimport requests\n\ndef act(q: str) -> str:\n" + ' host = os.environ.get("HOST", "x.test")\n' + ' return requests.get(f"https://{host}/{q}").text\n', + files={ + "deploy.py": "import os\n\nENV = {}\n\ndef collect(name):\n ENV[name] = os.environ[name]\n", + "shim.py": "def install(target, fn):\n target.get = fn\n", + }, + ) + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_an_aliased_library_import_is_not_a_patch(tmp_path): + reach = _reach( + tmp_path, + "import datetime as dt\nimport requests\n\ndef act(q: str) -> str:\n" + " day = dt.date.today()\n" + ' return requests.get(f"https://x.test/{q}").text\n', + files={"crud.py": "from db import load_row\n\ndef update(i, values):\n row = load_row(i)\n" + " for k, v in values.items():\n setattr(row, k, v)\n"}, + ) + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_library_re_exported_by_alias_and_patched_elsewhere_is_a_limit(tmp_path): + reach = _reach( + tmp_path, + "import datetime as dt\nimport requests\n\ndef act(q: str) -> str:\n" + " day = dt.date.today()\n" + ' return requests.get(f"https://x.test/{q}").text\n', + files={"shim.py": "from agent import dt\n\nclass _Date:\n pass\n\ndt.date = _Date\n"}, + ) + assert any("replaces" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +# -- #872 review round 14 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "shim", + [ + "import sys\n\ndef _get(url, **k):\n pass\n\nsys.modules['requests'].get = _get\n", + "import requests\n\nr = requests\nr.Session.request = lambda self, *a, **k: None\n", + ], +) +def test_the_http_stack_replaced_through_any_module_object_is_a_limit(tmp_path, shim): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"shim.py": shim}, + ) + assert any("changes every request" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "registry", + [ + "import agent_config as settings_module\n", + "import importlib\n\ndef __getattr__(name):\n return importlib.import_module('agent_config')\n", + ], +) +def test_a_module_re_exported_by_star_or_module_getattr_is_followed(tmp_path, registry): + star = "import agent_config as settings_module" in registry + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "registry.py": registry, + "startup.py": "import os\n" + + ("from registry import *\n" if star else "from registry import settings_module\n") + + "\ndef boot():\n for k, v in os.environ.items():\n setattr(settings_module, k, v)\n", + }, + ) + assert reach["calls"][0]["method"] is None + assert reach["effect_claims"] == [] + + +def test_a_function_stored_on_a_module_is_not_a_replaced_method(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> str:\n" + ' data = requests.get(f"https://x.test/{q}").json()\n' + ' return data.get("name")\n', + files={"startup.py": "import config\n\ndef _cached(key):\n return None\n\nconfig.get = _cached\n", + "config.py": "def get(key):\n return None\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_lazy_export_by_name_does_not_withhold(tmp_path): + # `getattr(import_module(path), name)` in a package's `__getattr__` + # answers the name asked for, not a module in its place. + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "adapters/__init__.py": "from importlib import import_module\n\n_LAZY = {'Adapter': '.impl'}\n\n" + "def __getattr__(name):\n return getattr(import_module(_LAZY[name], __name__), name)\n", + "adapters/impl.py": "class Adapter:\n pass\n", + "crud.py": "from db import load_row\n\ndef update(i, values):\n row = load_row(i)\n" + " for k, v in values.items():\n setattr(row, k, v)\n", + }, + ) + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 15 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "compat", + [ + "import urllib.request\n\nurllib.request.Request.method = 'DELETE'\n", + "from urllib.request import Request\n\nRequest.method = 'DELETE'\n", + ], +) +def test_a_stack_class_default_method_set_as_a_plain_value_is_a_patch(tmp_path, compat): + reach = _reach( + tmp_path, + "import urllib.request\n\ndef act(q: str) -> bytes:\n" + ' return urllib.request.urlopen(f"https://x.test/items/{q}").read()\n', + files={"compat.py": compat}, + ) + assert any("changes every request" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_same_named_class_from_another_library_is_not_the_http_stack(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"db.py": "from sqlalchemy.orm import Session\n\ndef _count(self):\n return 0\n\n" + "Session.count_rows = _count\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 16 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "compat", + [ + "import sys\n\nsys.modules['urllib.request'].Request.method = 'DELETE'\n", + "import importlib\n\nimportlib.import_module('urllib.request').Request.method = 'DELETE'\n", + "import urllib.request\n\ndef force(cls, method):\n cls.method = method\n\n" + "force(urllib.request.Request, 'DELETE')\n", + "import urllib.request\n\ndef force(cls):\n cls.method = 'DELETE'\n\nforce(urllib.request.Request)\n", + "import urllib.request\n\ngetattr(urllib.request, 'Request').method = 'DELETE'\n", + ], +) +def test_a_stack_class_patched_however_it_is_reached_is_a_limit(tmp_path, compat): + reach = _reach( + tmp_path, + "import urllib.request\n\ndef act(q: str) -> bytes:\n" + ' return urllib.request.urlopen(f"https://x.test/items/{q}").read()\n', + files={"compat.py": compat}, + ) + assert any("changes every request" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_tuning_setting_reached_through_a_helper_is_not_a_patch(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"tune.py": "import requests.adapters\n\ndef tune(module):\n module.DEFAULT_RETRIES = 5\n\n" + "tune(requests.adapters)\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 17 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "compat", + [ + "import urllib.request\n\n_t = urllib.request.Request('https://x.test/')\n_t.__class__.method = 'DELETE'\n", + "import urllib.request\n\ntype(urllib.request.Request('https://x.test/')).method = 'DELETE'\n", + "import urllib.request\n\nclass Sub(urllib.request.Request):\n pass\n\nSub.__mro__[1].method = 'DELETE'\n", + "import urllib.request\n\nclass Sub(urllib.request.Request):\n pass\n\nSub.__bases__[0].method = 'DELETE'\n", + ], +) +def test_a_class_reached_through_an_object_or_its_bases_is_a_limit(tmp_path, compat): + reach = _reach( + tmp_path, + "import urllib.request\n\ndef act(q: str) -> bytes:\n" + ' return urllib.request.urlopen(f"https://x.test/items/{q}").read()\n', + files={"compat.py": compat}, + ) + assert any("reached through an object" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_an_instances_own_class_counter_or_tuning_is_not_a_patch(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"models.py": "class Counter:\n made = 0\n\n def __init__(self):\n" + " type(self).made += 1\n self.__class__.made += 0\n\n" + "def tune(conn):\n conn.__class__.timeout = 5\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +@pytest.mark.parametrize( + "elsewhere", + [ + # An object's attribute named like a module is not that module. + "def active(usage):\n return bool(usage.requests or usage.tokens)\n", + # An import spelled `import_module` is an import, not a module kept. + "import importlib\n\nrequests = importlib.import_module('requests')\n\n" + "def fetch(url):\n return requests.get(url).content\n", + ], +) +def test_names_like_the_http_stack_do_not_limit_every_request(tmp_path, elsewhere): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={ + "other.py": elsewhere, + "crud.py": "from db import load_row\n\ndef update(i, values):\n row = load_row(i)\n" + " for k, v in values.items():\n setattr(row, k, v)\n", + }, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_cycle_of_re_exports_is_followed_in_bounded_time(tmp_path): + # `from x.a import b as a` makes `x.a` expand to `x.a.b`, whose `a` + # expands again: bounded, not endless (#872 review 18). + started = time.monotonic() + reach = _reach( + tmp_path, + _METHOD_READ, + files={ + "agent_config.py": 'METHOD = "GET"\n', + "x/__init__.py": "from x.a import b as a\n", + "x/a.py": "from x.a import b as a\nb = 1\n", + "use.py": "import os\nimport x\n\n" + + "".join(f"x.a.a.a.attr{i} = os.environ['K{i}']\n" for i in range(50)), + }, + ) + assert time.monotonic() - started < 10 + assert reach["calls"][0]["method"] == "GET" + + +# -- #872 review round 18 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "compat", + [ + # Through another module's attribute (P0-AK). + "import mods\n\ndef _delete(url, **k):\n pass\n\nmods.requests.get = _delete\n", + "import mods\n\nsetattr(mods.requests, 'get', lambda *a, **k: None)\n", + # Held on an object, then patched with a literal name (P0-AJ). + "import requests\n\nclass Http:\n def __init__(self, module):\n self.module = module\n\n" + "http = Http(requests)\nsetattr(http.module, 'get', lambda *a, **k: None)\n", + "import requests\n\nclass C:\n http = requests\n\nC.http.get = lambda *a, **k: None\n", + "import requests\n\nhttp = lambda: requests\nhttp().get = lambda *a, **k: None\n", + ], +) +def test_the_http_stack_patched_through_a_holder_is_a_limit(tmp_path, compat): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"mods.py": "import requests\n", "compat.py": compat}, + ) + assert any("every request" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "compat", + [ + "import urllib.request\n\n_k = type(urllib.request.Request('https://x.test/'))\n_k.method = 'DELETE'\n", + "import urllib.request\n\nclass Sub(urllib.request.Request):\n def boot(self):\n" + " self.__class__.__bases__[0].method = 'DELETE'\n", + "import urllib.request\n\nvars(urllib.request)['Request'].method = 'DELETE'\n", + "import urllib.request\n\nclass Sub(urllib.request.Request):\n pass\n\n" + "for k in Sub.__mro__:\n k.method = 'DELETE'\n", + "import os, urllib.request\n\nsetattr(type(urllib.request.Request('https://x.test/')), os.environ['K'], 'DELETE')\n", + ], +) +def test_a_class_reached_by_introspection_any_way_is_a_limit(tmp_path, compat): + reach = _reach( + tmp_path, + "import urllib.request\n\ndef act(q: str) -> bytes:\n" + ' return urllib.request.urlopen(f"https://x.test/items/{q}").read()\n', + files={"compat.py": compat}, + ) + assert any("every request" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +# -- #872 review round 19 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "compat", + [ + # Held on self, patched in a method. + "import requests\n\nclass Client:\n def __init__(self):\n self.http = requests\n\n" + " def use_delete(self):\n self.http.get = self._delete\n\n def _delete(self, url, **k):\n" + " pass\n", + # A held stack submodule, and a held stack class. + "import urllib.request\n\nclass Holder:\n def __init__(self, module):\n self.module = module\n\n" + "holder = Holder(urllib.request)\nholder.module.Request.method = 'DELETE'\n", + "import urllib.request\n\nclass Holder:\n def __init__(self, module):\n self.module = module\n\n" + "holder = Holder(urllib.request.Request)\nholder.module.method = 'DELETE'\n", + # A stored name the stack sends through. + "import requests\n\nclass Holder:\n def __init__(self, module):\n self.module = module\n\n" + "holder = Holder(requests)\nholder.module.Session.prepare_request = lambda self, r: r\n", + # A class attribute in another module. + "import clients\n\nclients.Http.lib.get = lambda *a, **k: None\n", + # Other patch APIs. + "import requests\nfrom unittest import mock\n\nmock.patch.multiple(requests, get=lambda *a, **k: None).start()\n", + "import requests\nfrom unittest.mock import patch as p\n\np.object(requests, 'get', lambda *a, **k: None).start()\n", + "import wrapt\n\nwrapt.wrap_function_wrapper('requests', 'get', lambda w, i, a, k: None)\n", + ], +) +def test_the_http_stack_held_or_patched_any_way_is_a_limit(tmp_path, compat): + sender = ( + ' return urllib.request.urlopen(f"https://x.test/items/{q}").read()\n' + if "urllib" in compat + else ' return requests.get(f"https://x.test/{q}").json()\n' + ) + reach = _reach( + tmp_path, + "import requests, urllib.request\n\ndef act(q: str) -> dict:\n" + sender, + files={ + "compat.py": compat, + **({"clients.py": "import requests\n\nclass Http:\n lib = requests\n"} if "clients" in compat else {}), + }, + ) + assert any("every request" in item["why"] for item in reach["limits"]) + assert reach["effect_claims"] == [] + + +def test_a_re_export_past_the_expansion_bound_is_any_module(tmp_path): + files = {f"plugins/q{i}.py": f"import plugins.q{i} as backend\n" for i in range(70)} + files.update( + { + "plugins/__init__.py": "", + "hub.py": "import requests as http\n", + "mods.py": "import hub as backend\n", + "compat.py": "import mods\n\nmods.backend.http.get = lambda *a, **k: None\n", + } + ) + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files=files, + ) + assert reach["effect_claims"] == [] + + +def test_a_tuple_holding_a_class_does_not_make_its_members_classes(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"log.py": "import sys\n\ndef record(exc):\n info = (type(exc), exc, None)\n" + " r = info[1]\n r.exc_text = 'x'\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_keeping_the_http_stack_on_an_object_is_not_a_patch(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={ + "client.py": "import requests, urllib.request\n\nclass Client:\n" + " def __init__(self, module=requests):\n self.module = module\n" + " self.opener = urllib.request\n self.module = None\n", + # A stack name stored through the method's own object, on an + # attribute not seen holding the stack. + "forms.py": "class Form:\n def __init__(self, opts):\n self.opts = opts\n\n" + " def configure(self):\n self.opts.method = 'POST'\n self.opts.get = None\n", + }, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_function_imported_from_its_own_package_is_not_a_re_export_loop(tmp_path): + # `pkg/tools/load_skill/__init__.py` is a package named like the function + # its `tool.py` defines: another module importing that function does not + # make `pkg.tools` bind it (#872 review 19, cassao29/strix). + for name, text in { + "pkg/__init__.py": "", + "pkg/tools/__init__.py": "", + "pkg/tools/load_skill/__init__.py": "", + "pkg/tools/load_skill/tool.py": "from agents import function_tool\n\n\n@function_tool\n" + "def load_skill(name):\n return name\n", + "pkg/agents/__init__.py": "", + "pkg/agents/factory.py": "from pkg.tools.load_skill.tool import load_skill\n\nTOOLS = (load_skill,)\n", + # A namespace changed on an object the scan does not follow: each + # module kept somewhere may be it. + "pkg/util.py": "import os\nfrom registry import lookup\n\ndef boot():\n lookup().__dict__.update(os.environ)\n", + }.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(text) + _, whole = scope_mutations(tmp_path, lambda relative: False) + assert "load_skill" in whole + assert "*" not in whole + + +def test_a_package_re_exporting_its_modules_function_is_not_a_re_export_loop(tmp_path): + # `runtime/init/__init__.py: from .init import init`, and `runtime` + # re-exporting it again (huaweicloud/agentarts). + for name, text in { + "pkg/__init__.py": "", + "pkg/runtime/__init__.py": "from pkg.runtime.init import init\n", + "pkg/runtime/init/__init__.py": "from pkg.runtime.init.init import init\n", + "pkg/runtime/init/init.py": "from agents import function_tool\n\n\n@function_tool\ndef init():\n pass\n", + "pkg/main.py": "from pkg.runtime.init import init\n\nCOMMANDS = [init]\n", + "pkg/util.py": "import os\nfrom registry import lookup\n\ndef boot():\n lookup().__dict__.update(os.environ)\n", + }.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(text) + _, whole = scope_mutations(tmp_path, lambda relative: False) + assert "init" in whole + assert "*" not in whole + + +@pytest.mark.parametrize( + "compat", + [ + "import hub\n\nhub.backend.urllib.request.Request.method = 'DELETE'\n", + # A package named like the module it re-exports from. + "import pkg.http\n\npkg.http.http.get = lambda *a, **k: None\n", + ], +) +def test_a_long_or_same_named_re_export_still_reaches_the_stack(tmp_path, compat): + sender = ( + ' return urllib.request.urlopen(f"https://x.test/items/{q}").read()\n' + if "urllib" in compat + else ' return requests.get(f"https://x.test/{q}").json()\n' + ) + reach = _reach( + tmp_path, + "import requests, urllib.request\n\ndef act(q: str) -> dict:\n" + sender, + files={ + "acme/__init__.py": "", + "acme/platform/__init__.py": "", + "acme/platform/services/__init__.py": "", + "acme/platform/services/http/__init__.py": "", + "acme/platform/services/http/v1/__init__.py": "", + "acme/platform/services/http/v1/clients.py": "import urllib.request\n", + "hub.py": "from acme.platform.services.http.v1 import clients as backend\n", + "pkg/__init__.py": "", + "pkg/http/__init__.py": "from pkg.http.http import http\n", + "pkg/http/http.py": "import requests as http\n", + "compat.py": compat, + }, + ) + assert reach["effect_claims"] == [] + + +def test_a_stub_package_re_exporting_from_elsewhere_is_not_a_re_export_loop(tmp_path): + # `pkg/tools/web/__init__.py` re-exports from `pkg.internal.tools.web`, + # which is not in the scope, or else from its own module: the internal + # `web` package is not the stub, though both are named `web` + # (liauto-siada/siada-cli). + for name, text in { + "pkg/__init__.py": "", + "pkg/tools/__init__.py": "", + "pkg/tools/web/__init__.py": "try:\n from pkg.internal.tools.web import web_fetch\n" + "except ImportError:\n from pkg.tools.web.web_fetch import web_fetch\n", + "pkg/tools/web/web_fetch.py": "from agents import function_tool\n\n\n@function_tool\ndef web_fetch(url):\n pass\n", + "pkg/agent.py": "from pkg.tools.web import web_fetch\n\nTOOLS = [web_fetch]\n", + "pkg/util.py": "import os\nfrom registry import lookup\n\ndef boot():\n lookup().__dict__.update(os.environ)\n", + }.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(text) + _, whole = scope_mutations(tmp_path, lambda relative: False) + assert "web_fetch" in whole + assert "*" not in whole + + +# -- #872 review round 20 -------------------------------------------------------- + +_HOLDER_CLASS = "import requests\n\n\nclass Api:\n def __init__(self):\n" + + +@pytest.mark.parametrize( + "files", + [ + # A relative re-export in a package, read as the package's attribute. + {"net/__init__.py": "from .transport import http\n", "compat.py": "import net\n\nnet.http.get = lambda *a, **k: None\n"}, + {"net/__init__.py": "from .transport import http\n", "compat.py": "from net import http\n\nhttp.get = lambda *a, **k: None\n"}, + { + "net/__init__.py": "", + "net/mods.py": "from .transport import http\n", + "compat.py": "from net import mods\n\nmods.http.get = lambda *a, **k: None\n", + }, + { + "net/__init__.py": "", + "net/sub/__init__.py": "", + "net/sub/mods.py": "from ..transport import http\n", + "compat.py": "from net.sub import mods\n\nmods.http.get = lambda *a, **k: None\n", + }, + # A star re-export, relative or not. + {"net/__init__.py": "from .transport import *\n", "compat.py": "import net\n\nnet.http.get = lambda *a, **k: None\n"}, + {"net/__init__.py": "from net.transport import *\n", "compat.py": "import net\n\nnet.http.get = lambda *a, **k: None\n"}, + ], +) +def test_a_relative_or_star_re_export_reaches_the_stack(tmp_path, files): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"net/transport.py": "import requests as http\n", **files}, + ) + assert reach["effect_claims"] == [] + + +def test_a_class_or_its_instance_handed_to_a_helper_is_a_class(tmp_path): + reach = _reach( + tmp_path, + "import urllib.request\n\ndef act(q: str) -> bytes:\n" + ' return urllib.request.urlopen(f"https://x.test/items/{q}").read()\n', + files={ + "compat.py": "import urllib.request\n\n\ndef force(target, method):\n" + " cls = target if isinstance(target, type) else type(target)\n cls.method = method\n\n\n" + "force(urllib.request.Request('https://x.test'), 'DELETE')\n" + }, + ) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "holder", + [ + _HOLDER_CLASS + " self.libs = {'http': requests}\n\n def patch(self, h):\n" + " self.libs['http'].get = h\n", + _HOLDER_CLASS + " self.libs = [requests]\n\n def patch(self, h):\n" + " self.libs[0].Session.prepare_request = h\n", + _HOLDER_CLASS + " self.http = requests\n self.client = self.http\n\n def patch(self, h):\n" + " self.client.get = h\n", + _HOLDER_CLASS + " setattr(self, 'http', requests)\n\n def patch(self, h):\n self.http.get = h\n", + _HOLDER_CLASS + " self.http = requests\n\n def patch(self, h):\n self.__dict__['http'].get = h\n", + _HOLDER_CLASS + " self._http = requests\n\n @property\n def http(self):\n return self._http\n\n" + " def patch(self, h):\n self.http.get = h\n", + _HOLDER_CLASS + " self.http, self.x = requests, 1\n\n def patch(self, h):\n self.http.get = h\n", + _HOLDER_CLASS + " self.__dict__.update(http=requests)\n\n def patch(self, h):\n self.http.get = h\n", + # A stack class stored into, whatever holds it. + _HOLDER_CLASS + " self.http = requests\n\n def patch(self, h):\n c = self.http\n" + " c.models.PreparedRequest.prepare_method = h\n", + ], +) +def test_the_http_stack_held_on_self_any_way_is_a_limit(tmp_path, holder): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"compat.py": holder}, + ) + assert reach["effect_claims"] == [] + + +@pytest.mark.parametrize( + "other", + [ + # A function of the stack handed along is not the stack kept. + "import threading\n\n_locals = threading.local()\n\ndef set_request(r):\n _locals.request = r\n", + "import argparse\n\ndef parse():\n args = argparse.ArgumentParser().parse_args()\n args.method = 'GET'\n" + " return args\n", + # A path is recorded whole: `msg.head.cmd` does not store `head`. + "def build(cls):\n msg = cls()\n msg.head.cmd = 1\n return msg\n", + # A tuning attribute stored through a holder's name elsewhere. + "import requests\n\nclass Api:\n def __init__(self):\n self.client = requests\n\n" + "def configure(svc):\n svc.client.timeout = 5\n", + # A class tested against or defaulted to is not kept. + "import httpx, requests, sdk\n\ndef check(r, c, factory=requests.Session):\n" + " return isinstance(r, requests.Response) and issubclass(c, httpx.Client)\n\n" + "def configure():\n sdk.Client.default_headers = {}\n conn = sdk.connect()\n conn.get = None\n", + # An exception class kept is not the stack (omnigent-ai/omnigent). + "import httpx\n\nDEAD = (httpx.RemoteProtocolError, httpx.StreamClosed)\n\n" + "import transport\n\ndef record(stream):\n response = transport.send()\n response.stream = stream\n", + ], +) +def test_ordinary_attribute_stores_beside_a_stack_function_handed_along_read(tmp_path, other): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={ + "util.py": "import asyncio, requests, socket\n\nasync def fetch(url):\n" + " await asyncio.to_thread(socket.getaddrinfo, url, 443)\n" + " return await asyncio.to_thread(requests.get, url)\n", + "other.py": other, + }, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +def test_a_package_binding_its_subpackages_agent_is_not_a_re_export_loop(tmp_path): + # ADK's layout: `subagents/__init__.py` binds `reviewer` to the agent + # `subagents/reviewer/agent.py` defines; `subagents.reviewer.agent` is + # still that module (jayyanar/agentic-ai-training). + for name, text in { + "app/__init__.py": "", + "app/agent.py": "from .subagents import reviewer\n\nAGENTS = [reviewer]\n", + "app/subagents/__init__.py": "from .reviewer import reviewer\n\nALL = [reviewer]\n", + "app/subagents/reviewer/__init__.py": "from .agent import reviewer\n", + "app/subagents/reviewer/agent.py": "from google.adk.agents import LlmAgent\n\n" + "reviewer = LlmAgent(name='reviewer')\n", + "app/util.py": "import os\nfrom registry import lookup\n\ndef boot():\n lookup().__dict__.update(os.environ)\n", + }.items(): + (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / name).write_text(text) + _, whole = scope_mutations(tmp_path, lambda relative: False) + assert "*" not in whole + + +# -- #872 review round 21 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "files", + [ + # A stack class kept through a call the scan does not follow. + {"compat.py": "import typing, requests\n\ntyping.cast(type, requests.Session).request = lambda *a, **k: None\n"}, + {"compat.py": "import copy, requests\n\ncopy.copy(requests.Session).request = lambda *a, **k: None\n"}, + { + "compat.py": "import requests, registry\n\nregistry.register('session', requests.Session)\n" + "registry.lookup('session').request = lambda *a, **k: None\n" + }, + # A tuple rebound from inside a function. + { + "compat.py": "import json, requests\n\nBACKENDS = (json, None)\n\n\ndef use_http():\n" + " global BACKENDS\n BACKENDS = (requests, None)\n\n\nuse_http()\n" + "BACKENDS[0].get = lambda *a, **k: None\n" + }, + # An item of a tuple the function built, rebound by a closure. + { + "compat.py": "import json, requests\n\n\ndef outer():\n pair = (json, 1)\n\n def inner():\n" + " nonlocal pair\n pair = (requests, 1)\n\n inner()\n" + " pair[0].get = lambda *a, **k: None\n\n\nouter()\n" + }, + # A package bound to a stack module, read through its own submodule's name. + { + "net/__init__.py": "from .http import http\n", + "net/http/__init__.py": "from .client import http\n", + "net/http/client.py": "import requests as http\n", + "net/http/adapters.py": "", + "compat.py": "import net\n\nnet.http.adapters.HTTPAdapter.send = lambda *a, **k: None\n", + }, + ], +) +def test_the_http_stack_reached_by_any_route_is_a_limit(tmp_path, files): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files=files, + ) + assert reach["effect_claims"] == [] + + +# -- #872 review round 22 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "files", + [ + # The same key reached by an import and by attributes: the attribute + # route reads what the package binds. + { + "net/__init__.py": "import requests as transport\n", + "net/transport.py": "adapters = None\n", + "legacy.py": "from net.transport import adapters\n", + "shims.py": "import net\n\nADAPTERS = net.transport.adapters\n", + "compat.py": "from shims import ADAPTERS\n\nADAPTERS.HTTPAdapter.send = lambda *a, **k: None\n", + }, + # An import in a class body, in the file and from another. + {"compat.py": "class Transport:\n from requests import Session\n\n\n" + "Transport.Session.request = lambda *a, **k: None\n"}, + {"compat.py": "class C:\n import requests as http\n\n\nC.http.get = lambda *a, **k: None\n"}, + {"mods.py": "class C:\n from requests import Session\n", + "compat.py": "import mods\n\nmods.C.Session.request = lambda *a, **k: None\n"}, + # A class copied in a function is the class. + {"compat.py": "import copy, requests\n\n\ndef install():\n session_class = copy.copy(requests.Session)\n" + " session_class.request = lambda *a, **k: None\n\n\ninstall()\n"}, + ], +) +def test_the_http_stack_reached_through_a_lock_a_class_body_or_a_copy_is_a_limit(tmp_path, files): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files=files, + ) + assert reach["effect_claims"] == [] + + +def test_a_copied_config_in_a_function_is_still_its_own(tmp_path): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files={"cfg.py": "import copy\n\nDEFAULTS = {'method': 'GET'}\n\n\ndef build():\n" + " options = copy.deepcopy(DEFAULTS)\n options['method'] = 'POST'\n return options\n"}, + ) + assert reach["limits"] == [] + assert reach["effect_claims"][0]["effect"] == "read" + + +# -- #872 review round 23 -------------------------------------------------------- + + +@pytest.mark.parametrize( + "files", + [ + # A copy of a class a parameter or an attribute holds is the class. + {"compat.py": "import copy, requests\n\n\ndef install(session_class):\n" + " patched = copy.copy(session_class)\n patched.request = lambda *a, **k: None\n\n\n" + "install(requests.Session)\n"}, + {"compat.py": "import copy, requests\n\n\nclass Installer:\n def __init__(self):\n" + " self.session_class = requests.Session\n\n def install(self):\n" + " patched = copy.copy(self.session_class)\n patched.request = lambda *a, **k: None\n"}, + # The lock's key reached by a literal getattr, or by a global written in a function. + { + "net/__init__.py": "import requests as transport\n", + "net/transport.py": "adapters = None\n", + "legacy.py": "from net.transport import adapters\n", + "shims.py": "import net\n\nADAPTERS = getattr(net.transport, 'adapters')\n", + "compat.py": "from shims import ADAPTERS\n\nADAPTERS.HTTPAdapter.send = lambda *a, **k: None\n", + }, + { + "net/__init__.py": "import requests as transport\n", + "net/transport.py": "adapters = None\n", + "legacy.py": "from net.transport import adapters\n", + "shims.py": "import net\n\nADAPTERS = None\n\n\ndef load():\n global ADAPTERS\n" + " ADAPTERS = net.transport.adapters\n", + "compat.py": "import shims\n\nshims.load()\nshims.ADAPTERS.HTTPAdapter.send = lambda *a, **k: None\n", + }, + # The lock's key reached by a walrus, a `for` target, or attrgetter. + { + "net/__init__.py": "import requests as transport\n", + "net/transport.py": "adapters = None\n", + "legacy.py": "from net.transport import adapters\n", + "shims.py": "import net\n\nif (ADAPTERS := net.transport.adapters) is None:\n raise ImportError\n", + "compat.py": "from shims import ADAPTERS\n\nADAPTERS.HTTPAdapter.send = lambda *a, **k: None\n", + }, + { + "net/__init__.py": "import requests as transport\n", + "net/transport.py": "adapters = None\n", + "legacy.py": "from net.transport import adapters\n", + "shims.py": "import net\n\nfor ADAPTERS in (net.transport.adapters,):\n break\n", + "compat.py": "from shims import ADAPTERS\n\nADAPTERS.HTTPAdapter.send = lambda *a, **k: None\n", + }, + { + "net/__init__.py": "import requests as transport\n", + "net/transport.py": "adapters = None\n", + "legacy.py": "from net.transport import adapters\n", + "shims.py": "import net, operator\n\nADAPTERS = operator.attrgetter('transport.adapters')(net)\n", + "compat.py": "from shims import ADAPTERS\n\nADAPTERS.HTTPAdapter.send = lambda *a, **k: None\n", + }, + # An import under `try` in a class body. + {"compat.py": "class Transport:\n try:\n from requests import Session\n except ImportError:\n" + " Session = None\n\n\nTransport.Session.request = lambda *a, **k: None\n"}, + ], +) +def test_the_http_stack_through_a_copied_parameter_a_global_or_a_class_block_is_a_limit(tmp_path, files): + reach = _reach( + tmp_path, + "import requests\n\ndef act(q: str) -> dict:\n" + ' return requests.get(f"https://x.test/{q}").json()\n', + files=files, + ) + assert reach["effect_claims"] == [] + + +def test_a_copied_parameter_edited_as_a_dict_changes_no_caller_state(tmp_path): + (tmp_path / "cfg.py").write_text( + "import copy\n\nDEFAULTS = {'method': 'GET'}\n\n\ndef build(options):\n" + " options = copy.deepcopy(options)\n options['method'] = 'POST'\n options.retries = 3\n" + " return options\n\n\nbuild(DEFAULTS)\n" + ) + names, whole = scope_mutations(tmp_path, lambda relative: False) + assert "method" not in names + assert whole == {}