Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -573,25 +573,27 @@ jobs:
if [ "$PR_DRAFT" = "true" ]; then
echo "Event draft snapshot is stale; checking the verdict for the live ready PR."
fi
reviews="$(gh api --paginate --slurp "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")"
reviews_file="$(mktemp)"
helper="$(mktemp)"
trap 'rm -f "$helper"' EXIT
trap 'rm -f "$reviews_file" "$helper"' EXIT
gh api --paginate --slurp "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100" >"$reviews_file"
gh api "repos/ContextualWisdomLab/.github/contents/scripts/ci/opencode_review_receipt_gate.py?ref=${WORKFLOW_SHA}" \
--jq .content | base64 --decode >"$helper"
verdict="$(python3 - "$helper" "$reviews" "$HEAD_SHA" <<'PY'
verdict="$(python3 - "$helper" "$reviews_file" "$HEAD_SHA" <<'PY'
import importlib.machinery
import importlib.util
import json
import sys

helper_path, reviews_json, head_sha = sys.argv[1:]
helper_path, reviews_path, head_sha = sys.argv[1:]
loader = importlib.machinery.SourceFileLoader("trusted_opencode_receipt_gate", helper_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
if spec is None or spec.loader is None:
raise RuntimeError("trusted OpenCode receipt helper could not be loaded")
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
pages = json.loads(reviews_json)
with open(reviews_path, encoding="utf-8") as stream:
pages = json.load(stream)
if not isinstance(pages, list) or not all(
isinstance(page, list) and all(isinstance(review, dict) for review in page)
for page in pages
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
### Strix report identity rejects colon-suffixed file lookalikes

- The completed-report scope validator no longer treats every colon as proof
that a changed path token ended. A distinct Linux filename such as
`scripts/ci/strix_quick_gate.sh:backup` now fails closed, including beneath
the private Strix scope root, while numeric `path:line` and
`path:line:column` evidence remains valid. A sentence period also ends a
path token only before a real delimiter, so `strix_quick_gate.sh.:backup`
and `strix_quick_gate.sh.@backup` are rejected in both report forms.
Focused evidence is 69 tests across the three scope suites with 100%
statement and branch coverage for `strix_report_scope.py`; hosted
exact-head Checks and qualifying independent review remain required.

### Law CI cleanup rejects symlinked PostgreSQL descendants

- The central law-agent cleanup now refuses symbolic links at every component
Expand Down
10 changes: 9 additions & 1 deletion docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,18 @@
작성 기준일: **2026-08-26 10:35 KST**
대상: **ContextualWisdomLab/.github** 중앙 거버넌스·자동화 레포지터리와 이를 소비하는 naruon 생태계
현재 보호된 `main`: `826b92394c63deb6981c3a8d16a724d71f85a0d7`
현재 열린 PR 수: **107** (아래 표에 이 스냅샷의 전체 목록 포함; live API 재수집)
현재 열린 PR 수: **107** (아래 기준선 인벤토리 행 수; 이 스냅샷의 기계 계약)

2026-10-07 delta: 그날 관측한 보호된 `main`은 `7554587c2e3106a388998bcad048a3d7121de25e`였고, live connector 조회는 반환 상한 100개를 모두 채웠으므로 열린 PR은 **최소 100개**였다. 이 관측값은 위 2026-08-26 스냅샷을 대체하지 않으며, 전체 수로 외삽하거나 아래 기준선 행 수를 재작성하지 않는다.

이 문서는 제품·기술·운영 Gap을 현재 문서와 현재 GitHub 상태에 묶어 두는 기준선이다. 새 작업은 먼저 이 문서의 Gap ID를 PR 설명과 테스트 증거에 연결하고, PR의 정확한 exact HEAD·Checks·리뷰를 다시 수집한 뒤 구현한다. 표의 상태는 작성 시점의 관측값이므로, 병합 판단에는 재사용하지 않는다. 이 인벤토리는 스냅샷이며 merge authorization이 아니다.

### 2026-10-07 Strix report-identity boundary delta

| Gap ID | 상태 | exact-tree evidence | causal owner / next gate |
|---|---|---|---|
| CONTROL-STRIX-REPORT-PATH-IDENTITY-01 | **Source repaired on Draft `.github#2574`; protected integration pending** | RED `d8aeb759f780d58b13c8692fe52dd39cd4f60d80` proved that both `scripts/ci/strix_quick_gate.sh:backup` and its private-scope-root form were accepted as the changed file. GREEN `983e31ff828baeca9873042d8c3faf1b949f2df8` restricts colon suffixes to numeric `path:line[:column]` evidence. Focused evidence is 39 passed with 55/55 statements and 26/26 branches covered. Protected `main@7554587c2e3106a388998bcad048a3d7121de25e` was integrated by ordinary two-parent merge `d1cd94627279e1bcb9b5cd5fddbad9a0056d6e1a`. | Central `.github` owns the Strix report-to-changed-source identity boundary. Keep the PR Draft until its final exact head has hosted source-executing security/runtime Checks, no unresolved actionable review, and qualifying independent current-head approval. Do not treat zero-step runner failures as source evidence or use bypass. |

### 2026-09-30 central coverage owner stack delta

| Gap ID | 상태 | exact-head evidence | causal owner / next gate |
Expand Down
4 changes: 4 additions & 0 deletions scripts/ci/contextual_orchestrator_review_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

from scripts.ci.contextual_orchestrator_review_policy import (
FREE_POOL_CREDENTIAL_NAMES,
normalize_input_modalities,
provider_account,
)

Expand Down Expand Up @@ -261,6 +262,9 @@ def _report_rows(
"model": model_id,
"agent_id": str(getattr(model, "agent_id", None) or f"{provider}_{model_id}"),
"is_free": (provider, model_id) in free_route_identities,
"input_modalities": normalize_input_modalities(
getattr(model, "input_modalities", None)
),
"prompt_price_per_1k": getattr(model, "prompt_price_per_1k", None),
"completion_price_per_1k": getattr(model, "completion_price_per_1k", None),
"currency_code": getattr(model, "currency_code", None),
Expand Down
7 changes: 5 additions & 2 deletions scripts/ci/strix_report_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@

# The PR scan target is a private directory holding only the changed files, so
# a path under it (as Strix reports it) is bound to this invocation's scope.
# A sentence period ends a token only when a real delimiter (or end) follows it;
# "name.:suffix" or "name.@suffix" is a different file, not the changed path.
_TOKEN_DELIMITER = r"(?=$|[\s`\"'<>,;!?)])"
PATH_TOKEN_END = (
r"(?=$|[\s`\"'<>,;!?)]"
r"|:(?=[0-9]+(?::[0-9]+)?(?:$|[\s`\"'<>,;!?)]|\.(?![\w./-])))"
r"|\.(?![\w./-]))"
r"|:(?=[0-9]+(?::[0-9]+)?(?:$|[\s`\"'<>,;!?)]|\." + _TOKEN_DELIMITER + r"))"
r"|\." + _TOKEN_DELIMITER + r")"
)
SCOPE_PATH_RE = re.compile(
r"(?<![\w./-])/workspace/strix-pr-scope\.[A-Za-z0-9]+/([A-Za-z0-9_./-]+)"
Expand Down
3 changes: 2 additions & 1 deletion tests/test_codeql_ghas_configuration_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,8 @@ def test_request_json_maps_http_and_transport_failures(monkeypatch):

class _HTTPError(identity.urllib.error.HTTPError):
def read(self, size: int = -1) -> bytes:
return b"denied"[:size]
body = b"denied"
return body if size is None or size < 0 else body[:size]

def raise_http(request, timeout=30):
del request, timeout
Expand Down
140 changes: 140 additions & 0 deletions tests/test_codeql_ghas_configuration_pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Synthetic paginated HTTP inputs, not captured GitHub analyses.

Pages advance through GitHub Link headers (main #2555 contract), not page counts.
"""
from urllib.parse import parse_qs, urlencode, urlsplit
import pytest
from scripts.ci import codeql_ghas_configuration_identity as identity

BASE = 'a' * 40
HEAD = 'b' * 40

def _link_next(url, response_headers, has_next):
"""Expose main's Link-header pagination contract for a synthetic page."""
if response_headers is None:
return
if not has_next:
response_headers['Link'] = ''
return
parts = urlsplit(url)
query = {key: values[0] for key, values in parse_qs(parts.query).items()}
query['page'] = str(int(query.get('page', '1')) + 1)
response_headers['Link'] = f'<{parts.scheme}://{parts.netloc}{parts.path}?{urlencode(query)}>; rel="next"'


def row(sha, category='/language:actions'):
return {'commit_sha': sha, 'category': category, 'analysis_key': identity.DEFAULT_SETUP_ANALYSIS_KEY, 'tool': {'name': 'CodeQL'}}

def test_base_identity_beyond_first_page_must_prevent_false_pairing_pass(monkeypatch):
calls = []
def request(url, *, token, timeout_seconds, response_headers=None):
params = parse_qs(urlsplit(url).query)
page = int(params.get('page', ['1'])[0])
calls.append(page)
assert params['ref'] == ['refs/heads/main']
_link_next(url, response_headers, page == 1)
if page == 1:
return [row('c' * 40, '/language:python') for _ in range(100)]
assert page == 2
return [row(BASE)]
monkeypatch.setattr(identity, '_request_json', request)
base = identity.list_codeql_analyses('ContextualWisdomLab/example', token='synthetic-fixture', ref='refs/heads/main')
ready, missing = identity.pairing_ready(base, [], base_sha=BASE, head_sha=HEAD, language='actions')
assert ready is False, 'truncated base inventory must not authorize pairing'
assert missing == [identity.default_setup_identity('actions')]
assert calls == [1, 2]


def test_head_identity_on_later_page_satisfies_exact_pairing(monkeypatch):
calls = []
def request(url, *, token, timeout_seconds, response_headers=None):
page = int(parse_qs(urlsplit(url).query).get('page', ['1'])[0])
calls.append(page)
_link_next(url, response_headers, page == 1)
return [row('c' * 40) for _ in range(100)] if page == 1 else [row(HEAD)]
monkeypatch.setattr(identity, '_request_json', request)
head = identity.list_codeql_analyses('ContextualWisdomLab/example', token='synthetic-fixture', ref='refs/pull/1/head')
assert identity.pairing_ready([row(BASE)], head, base_sha=BASE, head_sha=HEAD, language='actions') == (True, [])
assert calls == [1, 2]


@pytest.mark.parametrize('later', [{'malformed': True}, 'transport_error'])
def test_later_page_error_cannot_return_partial_analyses(monkeypatch, later):
def request(url, *, token, timeout_seconds, response_headers=None):
page = int(parse_qs(urlsplit(url).query).get('page', ['1'])[0])
_link_next(url, response_headers, page == 1)
if page == 1:
return [row(BASE) for _ in range(100)]
if later == 'transport_error':
raise identity.ConfigurationIdentityError('synthetic later-page failure')
return later
monkeypatch.setattr(identity, '_request_json', request)
with pytest.raises(identity.ConfigurationIdentityError):
identity.list_codeql_analyses('ContextualWisdomLab/example', token='synthetic-fixture')


@pytest.mark.parametrize('per_page', [0, -1, 101, True, 1.5, '100'])
def test_invalid_page_size_rejected_before_request(monkeypatch, per_page):
calls = []
def request(*args, **kwargs):
calls.append(True)
raise RuntimeError('invalid page size reached transport')
monkeypatch.setattr(identity, '_request_json', request)
with pytest.raises(identity.ConfigurationIdentityError):
identity.list_codeql_analyses('ContextualWisdomLab/example', token='synthetic-fixture', per_page=per_page)
assert calls == []


def test_full_page_filtered_rows_still_requires_next_page(monkeypatch):
calls = []
def request(url, *, token, timeout_seconds, response_headers=None):
page = int(parse_qs(urlsplit(url).query).get('page', ['1'])[0])
calls.append(page)
_link_next(url, response_headers, page == 1)
return [None for _ in range(100)] if page == 1 else [row(BASE)]
monkeypatch.setattr(identity, '_request_json', request)
assert identity.list_codeql_analyses('ContextualWisdomLab/example', token='synthetic-fixture') == [row(BASE)]
assert calls == [1, 2]

@pytest.mark.parametrize("boundary", ["codeql-body", "launcher-close", "launcher-long-header"])
def test_http_exception_custody_and_bounded_classification(monkeypatch, boundary):
"""Real modules must bound HTTP diagnostics and settle error streams offline."""
import io
import urllib.error
from scripts.ci import contextual_orchestrator_review_launcher as launcher
from types import SimpleNamespace

class Stream(io.BytesIO):
def __init__(self):
super().__init__(b"synthetic diagnostic " * 100)
self.read_sizes = []

def read(self, size=-1):
self.read_sizes.append(size)
return super().read(size)

stream = Stream()
error = urllib.error.HTTPError("https://api.github.com/fixture", 429, "synthetic", {"Retry-After": "12"}, stream)
try:
if boundary == "codeql-body":
def refuse(*args, **kwargs):
raise error
monkeypatch.setattr(identity._GITHUB_API_OPENER, "open", refuse)
with pytest.raises(identity.ConfigurationIdentityError):
identity._request_json("https://api.github.com/fixture", token="synthetic-only", timeout_seconds=1)
assert stream.read_sizes == [400], "HTTP diagnostic read must be bounded"
assert stream.closed, "HTTP error must close before propagation"
elif boundary == "launcher-close":
row = {"finish_reason": "stale", "reasoning_without_content": True}
launcher._record_provider_exception(row, error)
assert row == {"status": "rejected", "error_type": "HTTPError", "http_status": 429, "retry_after_s": 12}
assert stream.closed, "provider HTTP error must close after classification"
else:
try:
value = launcher._safe_retry_after_seconds(SimpleNamespace(headers={"Retry-After": "9" * 5000}))
except ValueError:
pytest.fail("long decimal header escaped safe classification")
assert value is None
assert launcher._safe_retry_after_seconds(error) == 12
finally:
error.close()
27 changes: 27 additions & 0 deletions tests/test_contextual_orchestrator_review_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,3 +796,30 @@ def test_build_catalog_interleaving_skips_exhausted_accounts() -> None:
"nvidia_nim",
"nvidia_nim",
]


@pytest.mark.parametrize("value, expected", [
(None, []),
([], []),
((" TEXT ", "image", "text", "audio_2", "video-1"),
["text", "image", "audio_2", "video-1"]),
])
def test_valid_modality_evidence_retains_order_and_deduplicates(value, expected):
"""Missing evidence stays empty; valid explicit evidence is preserved."""
assert policy.normalize_input_modalities(value) == expected


@pytest.mark.parametrize("value, message", [
("text", "must be a list or tuple"),
({"text": True}, "must be a list or tuple"),
([False], "non-string token"),
(["text", 1], "non-string token"),
([""], "invalid token"),
(["image extra"], "invalid token"),
(["../image"], "invalid token"),
(["追加"], "invalid token"),
])
def test_malformed_evidence_fails_closed(value, message):
"""Malformed evidence cannot lose filtering tags and become a valid model."""
with pytest.raises(policy.PolicyError, match=message):
policy.normalize_input_modalities(value)
17 changes: 17 additions & 0 deletions tests/test_migrated_receipt_rejection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Explicit invalid receipts must stay invalid after test-harness migration."""
import pytest
from tests.test_opencode_required_verdict_regression import HEAD, runtime_verdict


@pytest.mark.parametrize('overrides', [
{'body': '## OpenCode Review Overview\n- Gate result: COMMENT'},
{'body': '@opencode-agent please review'},
{'body': '## Pull request overview\n- Head SHA: `' + 'b' * 40 + '`'},
{'id': None},
])
def test_invalid_receipt_is_not_improved_by_fixture_constructor(overrides):
row = {'id': 1, 'state': 'APPROVED', 'commit_id': HEAD,
'user': {'login': 'opencode-agent[bot]'},
'body': '## Pull request overview\n- Head SHA: `' + HEAD + '`'}
row.update(overrides)
assert runtime_verdict([row]) == ''
2 changes: 2 additions & 0 deletions tests/test_opencode_required_rerun_capacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ def test_missing_verdict_fails_after_one_review_read(tmp_path: Path) -> None:

assert result.returncode == 1
assert "will rerun this failed job" in result.stdout
assert "No current-head formal OpenCode verdict:" in result.stderr
assert "Current-head OpenCode verdict:" not in result.stdout
assert calls.read_text(encoding="utf-8").splitlines() == [
"api repos/owner/repo/pulls/7",
"api --paginate --slurp repos/owner/repo/pulls/7/reviews?per_page=100",
Expand Down
1 change: 1 addition & 0 deletions tests/test_opencode_required_verdict_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def _run_fail_closed_step(
"HEAD_SHA": head_sha,
"PR_ACTION": pr_action,
"PR_DRAFT": pr_draft,
"WORKFLOW_SHA": "6a37e4cdfbd8bf6f3a60645a0ad7c13e1b9c1ae3",
"LIVE_PR_JSON": json.dumps(
{
"draft": pr_draft.lower() == "true",
Expand Down
Loading
Loading