diff --git a/.github/workflows/telemetry-ownership.yml b/.github/workflows/telemetry-ownership.yml new file mode 100644 index 0000000000..2ed65f49c9 --- /dev/null +++ b/.github/workflows/telemetry-ownership.yml @@ -0,0 +1,47 @@ +name: CWL telemetry ownership + +on: + pull_request: + merge_group: + workflow_call: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + ownership: + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_call' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out exact product head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: product + persist-credentials: false + - name: Check out pinned governance scanner + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ContextualWisdomLab/.github + ref: ${{ github.repository == 'ContextualWisdomLab/.github' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && (github.event.pull_request.head.sha || github.event.merge_group.head_sha) || 'main' }} + path: governance + persist-credentials: false + sparse-checkout: scripts/ci/check_telemetry_ownership.py + sparse-checkout-cone-mode: false + - name: Verify scanner source contract + env: + SCANNER_SHA256: bbb11e76d9b6e53957ca8be451a0a49df9a1d0ba07bfc0b6d553a640e67da4b1 + run: | + printf "%s %s\n" "$SCANNER_SHA256" governance/scripts/ci/check_telemetry_ownership.py | sha256sum --check --status + - name: Check product telemetry ownership + if: github.repository != 'ContextualWisdomLab/cwl-telemetry' + run: python3 governance/scripts/ci/check_telemetry_ownership.py product + - name: Record canonical telemetry owner + if: github.repository == 'ContextualWisdomLab/cwl-telemetry' + run: echo 'ADR-0032 assigns OpenTelemetry bootstrap to cwl-telemetry.' diff --git a/docs/adr/0032-canonical-runtime-telemetry-owner.md b/docs/adr/0032-canonical-runtime-telemetry-owner.md new file mode 100644 index 0000000000..86c236e063 --- /dev/null +++ b/docs/adr/0032-canonical-runtime-telemetry-owner.md @@ -0,0 +1,234 @@ +# 0032 — Canonical runtime telemetry ownership + +- **Status:** Proposed; runtime package and receiver are not released +- **Date:** 2026-09-25 +- **Issue:** [ContextualWisdomLab/.github#1565](https://github.com/ContextualWisdomLab/.github/issues/1565) + +## Context + +The current default branches of +[naruon `develop@042b0c7`](https://github.com/ContextualWisdomLab/naruon/blob/042b0c70531b229af3acbd0421a2f23098d848b3/backend/core/telemetry.py), +[LineageWeave `main@83eba56`](https://github.com/ContextualWisdomLab/LineageWeave/blob/83eba56149eb802cd63642c507c324c9976ec78e/lineageweave/observability.py), +and [contextual-orchestrator `main@5665b0a`](https://github.com/ContextualWisdomLab/contextual-orchestrator/blob/5665b0ad1e07ffb5e9f8c59e44b6b2a785298013/contextual_orchestrator/telemetry.py) +construct OpenTelemetry exporters or providers in product code. Those files +and branch heads were re-read on 2026-09-24 UTC. The +existing `context-graph-contracts` repository contains only a README on +`develop`, so it supplies neither a runtime API nor a released dependency. +The product specification calls for OpenTelemetry across naruon and its +connector, while this repository owns governance and compatibility checks. + +## Decision + +Create a dedicated, versioned [cwl-telemetry](https://github.com/ContextualWisdomLab/cwl-telemetry) +runtime package as the canonical SDK/Port owner. The repository now exists; +[its implementation PR #1](https://github.com/ContextualWisdomLab/cwl-telemetry/pull/1) +is open, and no release is available. Its first reviewed release must precede +production adoption and any organization-wide blocking gate. `.github` owns the +architecture contract and the canary fitness check, not runtime providers. +Products own event production and domain-specific classification. The shared +package owns explicit logger, tracer, meter and exporter bootstrap, bounded +attribute admission, and OTLP delivery policy. A Collector/gateway owns +receiver validation, routing and buffering. A telemetry backend stores +operational signals. SIEM consumes only normalized security events. + +The version-one API is an explicit `bootstrap(TelemetryConfig(...))` call returning logger/tracer/meter Ports +and a shutdown handle. Importing the package must have no network, thread, +provider, or credential side effect. Receiver opt-in must validate an HTTPS +endpoint and scoped credentials. Tenant/workspace references are opaque and +bounded. No product may construct an OTLP exporter/provider or SIEM client +after adopting the shared package. An ADR for a genuine canonical runtime +owner is the only exception to the central fitness rule. + +The event contract is versioned and admits only bounded, typed fields: +event name, UTC timestamp, severity, service/version, environment, exact +source revision, opaque tenant/workspace and permitted principal references, +trace/span/request correlation, bounded context and operation codes, resource +reference, action/result/status, error type/code, retry count, duration, +dependency/provider, source location, classification, purpose code and +provenance reference. A strict allowlist rejects raw prompts, responses, +document bodies, credentials, cookies, Authorization headers, DSNs and +unnecessary personal data before queue admission. Unknown fields fail closed; +high-cardinality values cannot become metric labels. + +```mermaid +flowchart LR + P[Product producer] --> S[Shared telemetry SDK and Ports] + S --> C[OpenTelemetry Collector / validated receiver] + C --> B[Operational telemetry backend] + C --> N[Normalized security-event projection] + N --> I[SIEM] + P --> A[Separate authoritative audit/outbox] +``` + +| Signal | Owner | Retention and purpose | +| --- | --- | --- | +| Operational logs/traces/metrics | Product emits; shared SDK bounds; Collector routes | Diagnosis, 90 days from event time | +| Normalized security event | Security producer and SIEM schema owner | Security investigation, 365 days from event time; no raw debug stream | +| Authoritative audit/domain event | Product audit/outbox owner | Durable business or compliance record, outside telemetry delivery; product-specific legal schedule | + +These are CWL's initial telemetry retention limits, not numbers prescribed by +[ISO/IEC 27002:2022](https://www.iso.org/standard/75652.html). +[NIST SP 800-53 AU-11](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final) +explicitly leaves audit-record duration to the organization's retention policy. +Where personal data is +present, [GDPR Article 5(1)(e)](https://eur-lex.europa.eu/eli/reg/2016/679/) +requires storage no longer than the stated purpose needs. The 90/365-day split +also matches the existing [appguardrail scan/audit defaults](https://github.com/ContextualWisdomLab/appguardrail/blob/e71d37e7c58118e6764c96ab7c4492fe33eed6f8/appguardrail_core/retention_policy.py), +but that product policy is precedent, not authority for this receiver. +The security/privacy owner must record any applicable shorter legal or +contractual limit before deployment. A documented legal hold suspends deletion +only for the affected records, with access and release audited. The Collector, +backend, SIEM, replicas and backups need tested expiry; a configuration value +alone does not prove deletion. +The SDK receiver's seven-day delivered-event replay cache is a deduplication +window, not the 365-day SIEM record store; undelivered outbox rows remain +pending until an exact acknowledgement or an explicit audited disposition. + +Receiver admission checks schema/version, content type, size, tenant binding, +timestamp window, replay/idempotency, authentication and TLS. An external +telemetry record cannot invoke a domain command or change authorization. +The shared security outbox can hand one normalized event at a time to an +operator-approved HTTPS SIEM gateway. The gateway must acknowledge the exact +event ID in a bounded JSON response before the sender marks it delivered; +HTTP failure, redirect, malformed acknowledgement, or TLS failure leaves the +record pending. Local failure/recovery tests cover this handoff. The retention +limits above are now defined, but no actual SIEM destination, gateway +deployment, expiry enforcement, or live delivery has been verified. + +Policy-owner lookup (2026-09-27 13:12 UTC): the existing +[CWL GRC repository](https://github.com/ContextualWisdomLab/governance-risk-compliance/blob/529cf321f134e26c0cd379ee53c06ab5297363b6/README.md) +owns versioned policy, control, risk, evidence and compliance-audit truth. +Its inspected default-branch head `529cf321f134e26c0cd379ee53c06ab5297363b6` +is a loopback-only developer preview. Its +[HTTP routes](https://github.com/ContextualWisdomLab/governance-risk-compliance/blob/529cf321f134e26c0cd379ee53c06ab5297363b6/cwl_grc/app.py) +provide policy/evidence operations, not the normalized security gateway contract +used here. GRC is the existing home for retention-policy provenance; naming it +does not supply an approved policy version, SIEM destination, operational +assignee or deployed expiry evidence. Other GRC feature branches and deployments +were not verified in this lookup. + +Normal export failure must not fail a product transaction: a bounded queue +retries with backoff, then follows an explicit drop/dead-letter/local durable +buffer policy and reports loss. The audit/outbox path remains durable and +separate. On Collector or SIEM outage, product work continues, the bounded +buffer fills, a loss signal appears, and recovery drains with idempotency and +preserved source identity. Shutdown has a bounded flush and reports residue. + +The canary `python3 scripts/ci/check_telemetry_ownership.py ` +reports direct Python OpenTelemetry bootstrap calls, including simple factory +aliases. OpenTelemetry wildcard imports conservatively bind the known bootstrap +names; subsequent assignment and parameter shadowing still apply. An unused +wildcard import alone is not a finding. It is not yet a required workflow: it does not see dynamic dispatch +or non-Python clients, and no shared +release exists for the detected products to adopt. The release gate requires +schema/privacy/cardinality and trace/source tests, timeout/backoff/saturation/ +shutdown/Collector/SIEM outage and recovery tests, receiver hostile-input +tests, and one product's released-adapter migration with parity tests. Only +after that canary succeeds may `.github` make the check blocking for products. +The runtime PR's local pinned-Collector canary observed traces, logs, +and metrics from the shared SDK after TLS and bearer-token admission, and +rejected malformed or unauthenticated requests. A second local test stored a +security record in the Collector's persistent queue during consumer outage, +restarted the Collector, and verified delivery to the recovered HTTPS consumer +(2026-09-24 UTC). The +[naruon migration draft #1772](https://github.com/ContextualWisdomLab/naruon/pull/1772) +removes product-owned exporters and passes focused local parity/privacy tests. +Its draft now adds an encrypted database credential row, an operator stdin +provisioning command, startup activation, and an image-sealed source revision; +focused local tests and a backend image build verified those paths. Its +repository rule requires runtime credentials from a KV/credential registry, +so the temporary environment-variable opt-in was removed. The image still +lacks a hash-pinned released shared wheel, and no deployed credential, +backend, or SIEM route has been verified. A pinned reusable ownership workflow +is piloted on the naruon PR; it is not yet an organization-wide required gate. + +## Issue #1565 acceptance audit — 2026-09-24 18:07 UTC + +This is a local and GitHub evidence snapshot, not a release verdict. The +runtime PR head is `a41a8bd098ba1cf268f3dbbb06d382856bc26ad1`, the +governance PR head is `42e12580ca41d189ff8e711a441cbc7bf6d46654`, and +the naruon draft head is `3cccca66c58a6037259016764f231298ac644099`. +Their current-head check rollups are pending; the runtime and naruon PRs still +require independent review. No `cwl-telemetry` release exists. + +| Acceptance item | Current evidence | Remaining proof | +| --- | --- | --- | +| 1. Reject product-local vendor bootstrap | The Python canary passes naruon and reports eight direct constructions on LineageWeave main. Tests cover aliases, lexical shadowing, defaults and assignment values. The reusable workflow exempts only the ADR-0032 runtime owner `ContextualWisdomLab/cwl-telemetry` using the caller repository identity. | Dynamic/non-Python clients and direct Collector/SIEM clients are not covered. | +| 2. Versioned, inert shared Port | Runtime PR contains `0.1.0` API and an import-side-effect test. | Reviewed, published wheel with verified digest; no release exists. | +| 3. Schema, privacy, identity and trace contract | Runtime contract tests cover bounded fields, prohibited content, W3C propagation and exact product source revision. | Current-head hosted result and independent review. | +| 4. Degraded delivery and audit durability | Local tests cover bounded SDK queue, shutdown failure, Collector restart with a persistent security queue, security outbox recovery and SIEM outage/acknowledgement. Naruon request still succeeds when its receiver is down. | Deployed queue capacity/alerting and the product's separate authoritative audit/outbox durability are not proven by these telemetry tests. | +| 5. Hostile receiver admission | Local Collector canary and security decoder tests cover TLS, bearer, content type, size, schema/version, tenant, time and replay cases. | Hosted current-head result and deployed receiver admission. | +| 6. Owner, purpose, retention and degraded sequence | This ADR defines initial 90/365-day telemetry limits and the runtime README defines the route; the pinned Collector canary executes local routing and recovery. | Named backend/SIEM destination, security/privacy review of applicable limits, deployed expiry and persistent volume, and live delivery evidence. | +| 7. One product migration with parity | Naruon draft removes direct exporter/provider construction; 97 relevant tests pass, one live-DB test skips, and a local HTTPS OTLP wire test checks redaction and source identity. | Released hash-pinned wheel in the production image, live DB evidence, current-head hosted checks and review. | +| 8. Central compatibility gate | Governance reusable workflow and naruon caller pin exact commits; local canary rejects LineageWeave and passes naruon. | Current-head hosted caller result, required-status rollout and coverage of other product languages/client libraries. | + +### Evidence update — 2026-09-26 15:51 UTC + +The shared runtime [PR #1](https://github.com/ContextualWisdomLab/cwl-telemetry/pull/1) +reached `6af2a93fd5069b91d5eddbc817c26a0c2d2fd560`: all 23 local contract, +hostile-receiver, and pinned-Collector tests passed; the wheel and source +distribution built. Its SIEM sender now closes failed HTTP responses while +leaving unacknowledged events pending. The naruon migration +[PR #1772](https://github.com/ContextualWisdomLab/naruon/pull/1772) reached +`79d6e89bc4c3e41085fcf07c98bb8c2706de9452` and pins that exact SDK +revision for development. On a fresh isolated PostgreSQL database, Alembic +reached head, an actual permission-change request succeeded with the telemetry +receiver unavailable, and a separate DB session found its committed security +audit row. This adds local product audit durability evidence to item 4 and +removes the live-DB evidence gap from item 7. Neither PR has current-head +hosted checks or independent approval; the runtime is unreleased, Naruon's +production image has no released hash-pinned wheel, and no approved backend or +SIEM destination, retention policy, or live delivery is verified. + +The runtime's main-only manual workflow can build and attach wheel, source +distribution and SHA-256 manifest to a **draft** release after merge. A draft +or a local build is not a published dependency. Until publication and the +product image's hash-pinned install are verified, keep telemetry disabled in +production. Do not infer backend or SIEM delivery from local canaries. + +## Consequences + +- One runtime package can version redaction and delivery policy independently + of product releases; the central checker can prevent duplicate bootstrap. +- A new package and Collector deployment must be maintained. Until both exist, + this ADR and checker are governance evidence, not runtime completion. + +## Alternatives considered + +- **Use `.github` as runtime owner:** rejected because this control-plane + repository does not ship product runtime dependencies. +- **Promote `contextual-orchestrator`:** rejected because its telemetry serves + model routing and is a product-specific bounded context. +- **Expand `context-graph-contracts`:** rejected for runtime ownership now: + its present protected baseline only declares interoperability contracts. + A future explicit charter and package release could revisit the decision. +- **Treat wardnet as the SIEM consumer:** rejected for this first contract. + At [main `f8260f1`](https://github.com/ContextualWisdomLab/wardnet/blob/f8260f1e03836039ff9463dd99fa982e4e270c4b/README.md), + wardnet exposes its own WAF/SOC events as NDJSON and explicitly leaves full + SIEM adapters for later. Its event payload includes client IP and raw path; + that product-specific producer cannot silently become the normalized + organization security-event consumer. Wardnet is the related early-stage SOC + repository, not an already deployed `/v1/security-events` gateway. Its + [draft preservation PR #90](https://github.com/ContextualWisdomLab/wardnet/pull/90) + explicitly keeps SIEM conversion offline and leaves delivery credentials, + Collector retry/acknowledgement, and durable dispatch to other owner slices. + A future Wardnet consumer needs a separate normalized-ingest contract and + evidence before it can be named as the production SIEM destination. + + +### Scanner delivery evidence — 2026-09-27 + +The reusable canary reads the exact `.github` PR or merge-group head while +reviewing this governance change, then reads the durable `main` ref for product +consumers. It verifies the scanner's SHA-256 +`4aac066e64b18bf31d5a1a04e73b9148ef1e3e9d5cbbc5a82d25a7844eb72533` +before execution, so a moved `main` cannot silently change the scanner. The +previous PR-only revision would become unreferenced when GitHub deletes the +source branch after a squash merge. The paired regression compares the digest +with the producer source. Update the digest together with any scanner change; +the scanner digest excludes the workflow itself, avoiding a self-referential pin. +The exact digest-bound source rejects the wildcard bootstrap fixture, and 28 ownership +tests pass with and without `GITHUB_ACTIONS=true`. Earlier reusable-workflow +consumer pins still require updating; local scanner changes alone do not prove +consumer adoption. Required organization enforcement, release and independent +review remain pending. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6e5f1c549a..d23b41510e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -114,6 +114,7 @@ flowchart LR | G-15 | 첨부파일 처리 경계가 제품별로 다르고, 1MB 상한은 업무 데이터와 맞지 않으며 미지원 MIME/컨테이너가 parser registry에서 명시적으로 pending/quarantine 되는지 확인되지 않았다. 현재 20MB 초과 파일 가능성과 PDF/HWP/HWPX·이미지·압축파일의 parse/sidecar 흐름을 하나의 exact contract로 묶지 못했다 | 큰 업무 첨부를 거부하거나 파싱 실패를 조용히 잃으면 고객의 메일·문서 업무가 중단된다 | naruon/newsdom-api 소유 PR에서 streaming upload, configurable bounded limit above 20MB, MIME sniffing, parser capability registry, quarantine/retry, source-position provenance, and ADR를 추가하고 size/unsupported-type/zip-bomb tests를 required evidence로 만든다 | | G-16 | Required Pingora policy treated a changed documentation PNG screenshot as UTF-8 runtime evidence | Valid UI evidence blocked otherwise valid product PRs before policy evaluation | This branch verifies bounded PNG magic before exemption while runtime paths and malformed assets continue to fail closed; protected-main delivery remains the release gate | | G-17 | `.github#2279` blocked authenticated GitHub REST redirects in source, but redirect tests invoked `_RejectRedirects` directly and four Strix transport fixtures still patched the removed `urlopen` seam | A future opener-composition regression could forward a bearer token on a 3xx while redirect tests stayed green; Strix error mapping could fail before exercising production | Proposed `57477289ebec5631b0c48f0bc419f336dbe19deb` sends all four synthetic redirect classes through both real module-level openers; `663ffac390d27ab21daa58b91b624d3f00dce7de` moves every Strix fixture to the production opener; `9c19c6e00eafc028068719ab482282c1256f8893` adds malformed-authority coverage and records the owner evidence. Mutation RED proves the default opener contacts a second same-authority URL with the bearer header. The focused suite passes twice (`87 passed` normal and `GITHUB_ACTIONS=true`) with 100% statement/branch coverage on both affected modules. Exact-head hosted security and independent review remain required | +| G-18 | [`.github#1565`](https://github.com/ContextualWisdomLab/.github/issues/1565) remains open. On 2026-09-24 UTC, current default branches still had product-owned OTLP bootstrap in naruon and LineageWeave; `context-graph-contracts` had only a README. A new [shared runtime PR](https://github.com/ContextualWisdomLab/cwl-telemetry/pull/1) and [naruon migration draft](https://github.com/ContextualWisdomLab/naruon/pull/1772) exist, but neither is merged or released. Local tests passed a pinned-Collector canary, versioned operational/security log separation, persistent security-queue outage/restart/recovery, and exact-ack HTTPS SIEM gateway handoff; no approved SIEM destination was used. Naruon's draft adds an encrypted DB credential registry and image-sealed source revision; its backend image build still lacks the shared SDK. On 2026-09-26 UTC, a fresh PostgreSQL test also proved that a real permission-change request commits its audit row while the telemetry receiver is unavailable. ADR-0032 now sets initial 90-day operational and 365-day normalized-security retention limits; deployed expiry and live receiver/backend/SIEM delivery remain unverified. | Product telemetry privacy, delivery and security-event semantics can drift separately; ordinary export failure can affect customer work. | [ADR-0032](adr/0032-canonical-runtime-telemetry-owner.md) selects the new dedicated package and defines the boundary. Wardnet is an early-stage SOC product, not yet a deployed normalized SIEM receiver. A pinned reusable check passes the naruon draft locally and rejects LineageWeave main; it is not yet a required organization gate. Finish exact-head review and release, add the released hash-pinned SDK wheel to Naruon's image, deploy and verify receiver/security-consumer delivery and expiry, then make the central check required. | ## 4. 열린 PR live inventory diff --git a/scripts/ci/check_telemetry_ownership.py b/scripts/ci/check_telemetry_ownership.py new file mode 100755 index 0000000000..ad897d5ea9 --- /dev/null +++ b/scripts/ci/check_telemetry_ownership.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Report product-owned OpenTelemetry SDK and OTLP bootstrap calls. + +This is a canary fitness check. It does not run source files or imply that a +shared runtime dependency has been released. +""" + +from __future__ import annotations + +import argparse +import ast +import os +from pathlib import Path + + +BOOTSTRAP_NAMES = frozenset( + { + "OTLPSpanExporter", + "OTLPMetricExporter", + "OTLPLogExporter", + "TracerProvider", + "MeterProvider", + "LoggerProvider", + "BatchSpanProcessor", + "PeriodicExportingMetricReader", + } +) +SKIP_DIRS = frozenset({ + ".git", ".venv", ".codegraph", ".next", "node_modules", + "build", "dist", "tests", "docs", "__pycache__", +}) + + +def scan_source(source: str) -> tuple[tuple[int, str], ...]: + """Find calls to SDK bootstrap symbols imported from OpenTelemetry.""" + other = frozenset({"other"}) + module = frozenset({"module"}) + direct = frozenset({"direct"}) + + def match_pattern_binding_names(pattern: ast.pattern) -> set[str]: + """Return names bound by one structural-pattern arm.""" + binding_names: set[str] = set() + for pattern_node in ast.walk(pattern): + if isinstance(pattern_node, (ast.MatchAs, ast.MatchStar)) and pattern_node.name is not None: + binding_names.add(pattern_node.name) + elif isinstance(pattern_node, ast.MatchMapping) and pattern_node.rest is not None: + binding_names.add(pattern_node.rest) + return binding_names + + def match_pattern_is_irrefutable(pattern: ast.pattern) -> bool: + """Return whether a guard-free pattern prevents match fallthrough.""" + if isinstance(pattern, ast.MatchAs): + return pattern.pattern is None or match_pattern_is_irrefutable(pattern.pattern) + if isinstance(pattern, ast.MatchOr): + return any(match_pattern_is_irrefutable(child_pattern) for child_pattern in pattern.patterns) + return False + + def bound_names(scope: ast.AST) -> set[str]: + class Collector(ast.NodeVisitor): + def __init__(self) -> None: + self.names: set[str] = set() + + def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + self.names.add(node.name) + + visit_AsyncFunctionDef = visit_FunctionDef + visit_ClassDef = visit_FunctionDef + + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, (ast.Store, ast.Del)): + self.names.add(node.id) + + def visit_arg(self, node: ast.arg) -> None: + self.names.add(node.arg) + + def visit_Import(self, node: ast.Import) -> None: + self.names.update(alias.asname or alias.name.split(".")[0] for alias in node.names) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + self.names.update(alias.asname or alias.name for alias in node.names) + + def visit_MatchAs(self, node: ast.MatchAs) -> None: + if node.name is not None: + self.names.add(node.name) + self.generic_visit(node) + + def visit_MatchStar(self, node: ast.MatchStar) -> None: + if node.name is not None: + self.names.add(node.name) + + def visit_MatchMapping(self, node: ast.MatchMapping) -> None: + if node.rest is not None: + self.names.add(node.rest) + self.generic_visit(node) + + def visit_comprehension_scope(self, node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp) -> None: + for generator in node.generators: + self.visit(generator.iter) + for condition in generator.ifs: + self.visit(condition) + if isinstance(node, ast.DictComp): + self.visit(node.key) + self.visit(node.value) + else: + self.visit(node.elt) + + visit_ListComp = visit_comprehension_scope + visit_SetComp = visit_comprehension_scope + visit_DictComp = visit_comprehension_scope + visit_GeneratorExp = visit_comprehension_scope + + collector = Collector() + collector.visit(scope.args) + for statement in scope.body: + collector.visit(statement) + return collector.names + + class Scanner(ast.NodeVisitor): + def __init__(self) -> None: + self.bindings: dict[str, frozenset[str]] = {} + self.findings: list[tuple[int, str]] = [] + self.class_outer: dict[str, frozenset[str]] | None = None + self.break_states: list[list[dict[str, frozenset[str]]]] = [] + + def join(self, branches: list[dict[str, frozenset[str]]]) -> dict[str, frozenset[str]]: + names = set().union(*(branch.keys() for branch in branches)) + return {name: frozenset().union(*(branch.get(name, other) for branch in branches)) for name in names} + + def run(self, start: dict[str, frozenset[str]], statements: list[ast.stmt]) -> dict[str, frozenset[str]]: + self.bindings = start.copy() + for statement in statements: + self.visit(statement) + return self.bindings.copy() + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + self.bindings[name] = module if alias.name == "opentelemetry" or alias.name.startswith("opentelemetry.") else other + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + otel = node.module == "opentelemetry" or (node.module or "").startswith("opentelemetry.") + for alias in node.names: + if otel and alias.name == "*": + # Wildcard exports are opaque; track known bootstrap names conservatively. + self.bindings.update(dict.fromkeys(BOOTSTRAP_NAMES, direct)) + continue + name = alias.asname or alias.name + self.bindings[name] = direct if otel and alias.name in BOOTSTRAP_NAMES else module if otel else other + + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, (ast.Store, ast.Del)): + self.bindings[node.id] = other + + def value_binding(self, value: ast.AST) -> frozenset[str]: + if isinstance(value, ast.Name): + return self.bindings.get(value.id, other) + if isinstance(value, ast.IfExp): + return self.value_binding(value.body) | self.value_binding(value.orelse) + if isinstance(value, ast.BoolOp): + return frozenset().union(*(self.value_binding(part) for part in value.values)) + if isinstance(value, ast.Attribute) and value.attr in BOOTSTRAP_NAMES: + root = value.value + while isinstance(root, ast.Attribute): + root = root.value + if isinstance(root, ast.Name) and "module" in self.bindings.get(root.id, other): + return direct + return other + + def visit_Assign(self, node: ast.Assign) -> None: + self.visit(node.value) + binding = self.value_binding(node.value) + for target in node.targets: + self.visit(target) + if isinstance(target, ast.Name): + self.bindings[target.id] = binding + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + self.visit(node.annotation) + if node.value is not None: + self.visit(node.value) + binding = self.value_binding(node.value) if node.value is not None else other + self.visit(node.target) + if node.value is not None and isinstance(node.target, ast.Name): + self.bindings[node.target.id] = binding + + def visit_NamedExpr(self, node: ast.NamedExpr) -> None: + self.visit(node.value) + binding = self.value_binding(node.value) + self.visit(node.target) + self.bindings[node.target.id] = binding + + def visit_Call(self, node: ast.Call) -> None: + name = node.func + if isinstance(name, ast.Name) and "direct" in self.bindings.get(name.id, other): + self.findings.append((node.lineno, name.id)) + elif isinstance(name, ast.NamedExpr) and "direct" in self.value_binding(name.value): + self.findings.append((node.lineno, name.target.id)) + elif isinstance(name, (ast.IfExp, ast.BoolOp)) and "direct" in self.value_binding(name): + self.findings.append((node.lineno, "conditional factory")) + elif isinstance(name, ast.Attribute) and name.attr in BOOTSTRAP_NAMES: + root = name.value + while isinstance(root, ast.Attribute): + root = root.value + if isinstance(root, ast.Name) and "module" in self.bindings.get(root.id, other): + self.findings.append((node.lineno, name.attr)) + self.generic_visit(node) + + def visit_comprehension_scope(self, node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp) -> None: + self.visit(node.generators[0].iter) + previous = self.bindings + self.bindings = previous.copy() + for generator in node.generators: + for name in ast.walk(generator.target): + if isinstance(name, ast.Name) and isinstance(name.ctx, ast.Store): + self.bindings[name.id] = other + for index, generator in enumerate(node.generators): + if index: + self.visit(generator.iter) + self.visit(generator.target) + for condition in generator.ifs: + self.visit(condition) + if isinstance(node, ast.DictComp): + self.visit(node.key) + self.visit(node.value) + else: + self.visit(node.elt) + self.bindings = previous + + visit_ListComp = visit_comprehension_scope + visit_SetComp = visit_comprehension_scope + visit_DictComp = visit_comprehension_scope + visit_GeneratorExp = visit_comprehension_scope + + def visit_If(self, node: ast.If) -> None: + self.visit(node.test) + start = self.bindings.copy() + self.bindings = self.join([self.run(start, node.body), self.run(start, node.orelse)]) + + def visit_Match(self, node: ast.Match) -> None: + self.visit(node.subject) + start = self.bindings.copy() + branches: list[dict[str, frozenset[str]]] = [] + has_fallthrough = True + for match_case in node.cases: + self.bindings = start.copy() + for binding_name in match_pattern_binding_names(match_case.pattern): + self.bindings[binding_name] = other + if match_case.guard is not None: + self.visit(match_case.guard) + branches.append(self.run(self.bindings, match_case.body)) + if match_case.guard is None and match_pattern_is_irrefutable(match_case.pattern): + has_fallthrough = False + if has_fallthrough: + branches.append(start) + self.bindings = self.join(branches) + + def visit_Try(self, node: ast.Try | ast.TryStar) -> None: + start = self.bindings.copy() + break_offsets = [len(states) for states in self.break_states] + prefixes = [start] + self.bindings = start.copy() + for statement in node.body: + self.visit(statement) + prefixes.append(self.bindings.copy()) + normal = self.run(self.bindings, node.orelse) + handler_start = self.join(prefixes) + branches = [normal] + for handler in node.handlers: + branches.append(self.run(handler_start, [handler])) + for states, offset in zip(self.break_states, break_offsets): + for index in range(offset, len(states)): + states[index] = self.run(states[index], node.finalbody) + self.bindings = self.join([self.run(branch, node.finalbody) for branch in branches]) + + visit_TryStar = visit_Try + + def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: + if node.type is not None: + self.visit(node.type) + if node.name is not None: + self.bindings[node.name] = other + for statement in node.body: + self.visit(statement) + + def visit_For(self, node: ast.For | ast.AsyncFor) -> None: + self.visit(node.iter) + start = self.bindings.copy() + self.bindings = start.copy() + self.visit(node.target) + self.break_states.append([]) + body = self.run(self.bindings, node.body) + after_else = self.run(self.join([start, body]), node.orelse) + breaks = self.break_states.pop() + self.bindings = self.join([after_else, *breaks]) if breaks else after_else + + visit_AsyncFor = visit_For + + def visit_While(self, node: ast.While) -> None: + self.visit(node.test) + start = self.bindings.copy() + self.break_states.append([]) + body = self.run(start, node.body) + after_else = self.run(self.join([start, body]), node.orelse) + breaks = self.break_states.pop() + self.bindings = self.join([after_else, *breaks]) if breaks else after_else + + def visit_Break(self, node: ast.Break) -> None: + if self.break_states: + self.break_states[-1].append(self.bindings.copy()) + + def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + for decorator in node.decorator_list: + self.visit(decorator) + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + self.visit(default) + previous = self.bindings + outer_class = self.class_outer + self.bindings = (outer_class if outer_class is not None else previous) | dict.fromkeys(bound_names(node), other) + self.class_outer = None + for statement in node.body: + self.visit(statement) + self.bindings = previous + self.class_outer = outer_class + self.bindings[node.name] = other + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + for decorator in node.decorator_list: + self.visit(decorator) + for base in node.bases: + self.visit(base) + previous = self.bindings + outer_class = self.class_outer + self.bindings = previous.copy() + self.class_outer = previous.copy() + for statement in node.body: + self.visit(statement) + self.bindings = previous + self.class_outer = outer_class + self.bindings[node.name] = other + + scanner = Scanner() + scanner.visit(ast.parse(source)) + return tuple(sorted(set(scanner.findings))) + + +def scan_tree(root: Path) -> tuple[str, ...]: + """Scan product Python source while excluding tests and generated paths.""" + findings = [] + for directory, subdirs, files in os.walk(root, followlinks=False): + current = Path(directory) + if current != root and (current / ".git").exists(): + subdirs[:] = [] + continue + subdirs[:] = [ + name for name in subdirs + if name not in SKIP_DIRS and not (current / name).is_symlink() + ] + for name in files: + path = current / name + if not name.endswith(".py") or path.is_symlink(): + continue + for line, symbol in scan_source(path.read_text(encoding="utf-8")): + findings.append(f"{path.relative_to(root)}:{line}: product-owned {symbol}()") + return tuple(sorted(findings)) + + +def main() -> int: + """Print canary findings and fail when product bootstrap is present.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repository", type=Path) + args = parser.parse_args() + if not args.repository.is_dir(): + parser.error("repository must be a directory") + findings = scan_tree(args.repository) + print("\n".join(findings) if findings else "No product-owned OTLP bootstrap calls found") + return bool(findings) + + +if __name__ == "__main__": # pragma: no cover - CLI entry point + raise SystemExit(main()) diff --git a/scripts/ci/opencode_repository_dispatch_targets.json b/scripts/ci/opencode_repository_dispatch_targets.json index dd82dd1fd0..c264155b29 100644 --- a/scripts/ci/opencode_repository_dispatch_targets.json +++ b/scripts/ci/opencode_repository_dispatch_targets.json @@ -22,6 +22,7 @@ "ContextualWisdomLab/codec-carver", "ContextualWisdomLab/context-graph-contracts", "ContextualWisdomLab/contextual-orchestrator", + "ContextualWisdomLab/cwl-telemetry", "ContextualWisdomLab/disksage", "ContextualWisdomLab/enterprise-architecture-core", "ContextualWisdomLab/fast-mlsirm", diff --git a/tests/test_telemetry_ownership.py b/tests/test_telemetry_ownership.py new file mode 100644 index 0000000000..954bb2eee7 --- /dev/null +++ b/tests/test_telemetry_ownership.py @@ -0,0 +1,492 @@ +"""Canary checks for the shared telemetry ownership boundary.""" + +import sys +import hashlib +import json +import re +from pathlib import Path + +import pytest + +from scripts.ci import check_telemetry_ownership as ownership + +scan_source = ownership.scan_source +scan_tree = ownership.scan_tree + + +def test_scan_source_finds_aliased_and_qualified_bootstrap_only() -> None: + """Calls are findings; prose and unused imports are not.""" + source = ''' +from opentelemetry.sdk.trace import TracerProvider as Provider +from opentelemetry.exporter.otlp.proto.http import trace_exporter as otlp +from opentelemetry.sdk.metrics import MeterProvider +Provider() +otlp.OTLPSpanExporter() +message = "OTLPSpanExporter()" +''' + assert scan_source(source) == ((5, "Provider"), (6, "OTLPSpanExporter")) + + +def test_scan_tree_skips_tests_and_reports_product_call(tmp_path) -> None: + """The canary scans product source without treating test fixtures as owners.""" + package = tmp_path / "product" + package.mkdir() + (package / "telemetry.py").write_text( + "from opentelemetry.sdk.trace import TracerProvider\nTracerProvider()\n", + encoding="utf-8", + ) + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_telemetry.py").write_text( + "from opentelemetry.sdk.trace import TracerProvider\nTracerProvider()\n", + encoding="utf-8", + ) + assert scan_tree(tmp_path) == ("product/telemetry.py:2: product-owned TracerProvider()",) + + +def test_scan_tree_does_not_scan_nested_checkouts_or_generated_dependencies(tmp_path) -> None: + source = "from opentelemetry.sdk.trace import TracerProvider\nTracerProvider()\n" + (tmp_path / "telemetry.py").write_text(source, encoding="utf-8") + nested = tmp_path / "nested-checkout" + nested.mkdir() + (nested / ".git").write_text("gitdir: elsewhere\n", encoding="utf-8") + (nested / "telemetry.py").write_text(source, encoding="utf-8") + dependencies = tmp_path / "node_modules" + dependencies.mkdir() + (dependencies / "telemetry.py").write_text(source, encoding="utf-8") + + assert scan_tree(tmp_path) == ("telemetry.py:2: product-owned TracerProvider()",) + + +def test_qualified_import_and_non_otel_lookalike() -> None: + """Only an OpenTelemetry import can authorize a qualified finding.""" + source = ''' +import opentelemetry.sdk.trace as sdk +import opentelemetry.sdk.trace +import unrelated +sdk.TracerProvider() +opentelemetry.sdk.trace.TracerProvider() +unrelated.TracerProvider() +sdk.get_tracer() +''' + assert scan_source(source) == ((5, "TracerProvider"), (6, "TracerProvider")) + + +def test_root_opentelemetry_imports_cannot_hide_provider_construction() -> None: + source = ''' +import opentelemetry +from opentelemetry import sdk as otel_sdk +opentelemetry.sdk.trace.TracerProvider() +otel_sdk.metrics.MeterProvider() +''' + assert scan_source(source) == ((4, "TracerProvider"), (5, "MeterProvider")) + + +def test_shadowed_names_and_local_imports_do_not_cross_scopes() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +import opentelemetry.sdk.metrics as metrics +def parameter(TracerProvider): + return TracerProvider() +def reassigned(): + TracerProvider = lambda: None + metrics = object() + TracerProvider() + metrics.MeterProvider() +def direct(): + return TracerProvider() +def local(): + from opentelemetry.sdk.metrics import MeterProvider + return MeterProvider() +def sibling(): + return MeterProvider() +''' + assert scan_source(source) == ((12, "TracerProvider"), (15, "MeterProvider")) + + +def test_nested_import_cannot_shadow_outer_function_binding() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +def outer(): + def inner(): + from elsewhere import TracerProvider + return TracerProvider() + return TracerProvider() +''' + assert scan_source(source) == ((7, "TracerProvider"),) + + +def test_default_and_assignment_rhs_are_scanned_before_binding() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +def start(provider=TracerProvider()): + return provider +TracerProvider = TracerProvider() +''' + assert scan_source(source) == ((3, "TracerProvider"), (5, "TracerProvider")) + + +def test_factory_aliases_are_tracked_until_shadowed() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider as Provider +import opentelemetry.sdk.metrics as metrics +trace_factory = Provider +metric_factory: object = metrics.MeterProvider +trace_factory() +metric_factory() +trace_factory = lambda: None +trace_factory() +if enabled: + selected = Provider +else: + selected = lambda: None +selected() +''' + assert scan_source(source) == ((6, "trace_factory"), (7, "metric_factory"), (14, "selected")) + + +def test_conditional_factory_cannot_hide_product_owned_provider() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider as Provider +selected = Provider if enabled else object +selected() +(Provider if enabled else object)() +alternate = Provider or object +alternate() +(enabled and Provider)() +''' + assert scan_source(source) == ( + (4, "selected"), (5, "conditional factory"), + (7, "alternate"), (8, "conditional factory"), + ) + + +def test_method_does_not_inherit_class_import() -> None: + source = ''' +class Owner: + from opentelemetry.sdk.trace import TracerProvider + def create(self): + return TracerProvider() + created = TracerProvider() +''' + assert scan_source(source) == ((6, "TracerProvider"),) + + +def test_comprehension_target_does_not_shadow_outer_import() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +def build(providers): + [TracerProvider for TracerProvider in providers] + [TracerProvider() for TracerProvider in providers] + return TracerProvider() +TracerProvider() +''' + assert scan_source(source) == ((6, "TracerProvider"), (7, "TracerProvider")) + + +def test_optional_branches_preserve_possible_bootstrap_binding() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +if enabled: + TracerProvider = None +TracerProvider() +try: + risky() + TracerProvider = None +except Exception: + pass +TracerProvider() +''' + assert scan_source(source) == ((5, "TracerProvider"), (11, "TracerProvider")) + + +def test_all_branches_shadow_import_without_false_positive() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +if enabled: + TracerProvider = None +else: + TracerProvider = lambda: None +TracerProvider() +try: + risky() +except Exception: + TracerProvider = None +else: + TracerProvider = lambda: None +TracerProvider() +''' + assert scan_source(source) == () + + +def test_try_else_does_not_shadow_exception_path_and_finally_does() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +try: + risky() +except Exception: + pass +else: + TracerProvider = None +TracerProvider() +try: + risky() +except Exception: + pass +finally: + TracerProvider = None +TracerProvider() +''' + assert scan_source(source) == ((9, "TracerProvider"),) + + +def test_optional_module_shadow_still_reports_qualified_call() -> None: + source = ''' +import opentelemetry.sdk.trace as sdk +if enabled: + sdk = None +sdk.TracerProvider() +''' + assert scan_source(source) == ((5, "TracerProvider"),) + + +def test_match_branches_preserve_possible_bootstrap_binding() -> None: + """A shadow in one match arm cannot erase the import on another arm.""" + source = ''' +from opentelemetry.sdk.trace import TracerProvider +match selected: + case "disabled": + TracerProvider = None + case _: + pass +TracerProvider() +''' + assert scan_source(source) == ((8, "TracerProvider"),) + + +def test_match_capture_is_a_function_local_binding() -> None: + """Pattern captures shadow an outer import throughout the function.""" + source = ''' +from opentelemetry.sdk.trace import TracerProvider +def choose(selected): + TracerProvider() + match selected: + case TracerProvider: + pass +''' + assert scan_source(source) == () + + +def test_match_pattern_bindings_and_exhaustive_shadowing() -> None: + """Mapping, star, and exhaustive patterns remain lexical bindings.""" + source = ''' +from opentelemetry.sdk.trace import TracerProvider +def choose(selected): + match selected: + case {"provider": TracerProvider, **remaining}: + return remaining + case [*providers]: + return providers +match selected: + case "first": + TracerProvider = None + case _: + TracerProvider = lambda: None +TracerProvider() +''' + assert scan_source(source) == () + + +def test_loop_break_preserves_binding_that_else_shadows() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +for item in items: + if item: + break +else: + TracerProvider = None +TracerProvider() +while enabled: + if stop: + break +else: + TracerProvider = None +TracerProvider() +''' + assert scan_source(source) == ((8, "TracerProvider"), (14, "TracerProvider")) + + +def test_nested_loop_break_does_not_skip_outer_else() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +for item in items: + for child in children: + break +else: + TracerProvider = None +TracerProvider() +''' + assert scan_source(source) == () + + +def test_finally_shadows_binding_before_break_exits_loop() -> None: + source = ''' +from opentelemetry.sdk.trace import TracerProvider +for item in items: + try: + break + finally: + TracerProvider = None +else: + TracerProvider = None +TracerProvider() +''' + assert scan_source(source) == () + + +def test_orphan_break_does_not_crash_source_scan() -> None: + assert scan_source("break\n") == () + + +def test_scanner_handles_extended_binding_syntax() -> None: + """Aliases, comprehensions, loops, guards, and nested scopes stay sound.""" + source = ''' +import opentelemetry.sdk.trace +import unrelated +from elsewhere import TracerProvider as OtherProvider +from opentelemetry.sdk.trace import TracerProvider +qualified_factory = opentelemetry.sdk.trace.TracerProvider +fake_factory = unrelated.TracerProvider +holder.factory = qualified_factory +annotation_only: object +(named_factory := TracerProvider)() +{key: TracerProvider() for key in values if key} +[TracerProvider() for group in groups for item in group if item] +match selected: + case ("enabled" | _) as match_value if (guard_factory := TracerProvider): + guard_factory() +try: + risky() +except OtherProvider as error_value: + pass +try: + risky_again() +except: + pass +for item_value in values: + loop_factory = TracerProvider +loop_factory() +while enabled: + while_factory = TracerProvider +while_factory() +@TracerProvider() +class Child(TracerProvider()): + pass +async def build_async(default_factory=TracerProvider()): + import unrelated.local + [item for item in values if item] + {key: value for key, value in pairs} + async for item_value in stream: + async_factory = TracerProvider + return async_factory() +def pattern_scope(selected): + match selected: + case {"key": captured_value}: + pass + case [*_]: + pass + case _: + pass +@TracerProvider() +def decorated_factory(*, required_option): + return required_option +match selected: + case ("enabled" | _): + TracerProvider = None +''' + findings = scan_source(source) + finding_names = [finding_name for _, finding_name in findings] + assert finding_names.count("TracerProvider") == 6 + assert {"named_factory", "guard_factory", "loop_factory", "while_factory", "async_factory"}.issubset(finding_names) + + +def test_scan_tree_skips_symlink_and_empty_tree(tmp_path) -> None: + """A symlink cannot expand the canary beyond the checkout.""" + outside = tmp_path.parent / "outside_telemetry.py" + outside.write_text( + "from opentelemetry.sdk.trace import TracerProvider\nTracerProvider()\n", + encoding="utf-8", + ) + (tmp_path / "link.py").symlink_to(outside) + assert scan_tree(tmp_path) == () + + +def test_main_reports_positive_and_empty_canary(tmp_path, monkeypatch, capsys) -> None: + """The CLI's exit status matches its printed report.""" + monkeypatch.setattr(sys, "argv", ["check", str(tmp_path)]) + assert ownership.main() == 0 + assert "No product-owned" in capsys.readouterr().out + + (tmp_path / "telemetry.py").write_text( + "from opentelemetry.sdk.trace import TracerProvider\nTracerProvider()\n", + encoding="utf-8", + ) + assert ownership.main() == 1 + assert "telemetry.py:2" in capsys.readouterr().out + + monkeypatch.setattr(sys, "argv", ["check", str(tmp_path / "missing")]) + with pytest.raises(SystemExit, match="2"): + ownership.main() + + +def test_reusable_gate_reads_exact_pr_head_with_pinned_read_only_scanner() -> None: + workflow = Path(".github/workflows/telemetry-ownership.yml").read_text(encoding="utf-8") + assert all(f" {event}:" in workflow for event in ("workflow_call", "pull_request", "merge_group")) + assert "pull_request_target:" not in workflow + assert ( + " ownership:\n if: github.event_name == 'pull_request' || " + "github.event_name == 'merge_group' || github.event_name == 'workflow_call'" + ) in workflow + assert "contents: read" in workflow and "persist-credentials: false" in workflow + assert "github.event.pull_request.head.sha || github.sha" in workflow + assert "repository: ContextualWisdomLab/.github" in workflow + assert ( + "github.repository == 'ContextualWisdomLab/.github' && " + "(github.event_name == 'pull_request' || github.event_name == 'merge_group')" + in workflow + ) + assert "github.event.pull_request.head.sha || github.event.merge_group.head_sha" in workflow + assert "|| 'main' }}" in workflow + digest = re.search(r"SCANNER_SHA256: ([0-9a-f]{64})", workflow) + assert digest is not None + assert digest.group(1) == hashlib.sha256(Path(ownership.__file__).read_bytes()).hexdigest() + assert 'printf "%s %s\\n" "$SCANNER_SHA256" governance/scripts/ci/check_telemetry_ownership.py | sha256sum --check --status' in workflow + assert "python3 governance/scripts/ci/check_telemetry_ownership.py product" in workflow + assert "if: github.repository != 'ContextualWisdomLab/cwl-telemetry'" in workflow + assert "if: github.repository == 'ContextualWisdomLab/cwl-telemetry'" in workflow + + +def test_wildcard_otel_import_tracks_calls_and_respects_shadowing() -> None: + """An opaque import must not hide bootstrap calls or override local scope.""" + source = "\n".join(( + "from opentelemetry.sdk.trace import *", + "TracerProvider()", + "factory = TracerProvider", + "factory()", + "def local(TracerProvider):", + " return TracerProvider()", + "TracerProvider = lambda: None", + "TracerProvider()", + )) + assert scan_source(source) == ((2, "TracerProvider"), (4, "factory")) + assert scan_source("from opentelemetry.sdk.trace import *") == () + assert scan_source("from unrelated import *\nTracerProvider()") == () + + +def test_canonical_sdk_is_an_exact_review_dispatch_target() -> None: + mirror = json.loads( + Path("scripts/ci/opencode_repository_dispatch_targets.json").read_text( + encoding="utf-8" + ) + ) + assert mirror["targets"].count("ContextualWisdomLab/cwl-telemetry") == 1