From 2a50f1d99cfc6dda7f2c36680280240d5eb0507a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:46:24 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Fix=20information=20disclosure=20via=20unredacted=20subpr?= =?UTF-8?q?ocess=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Explicitly appended absolute `sys.path` and imported `redact_text` in both `sandboxed_verify.py` and `sandboxed_web_e2e.py`. 2. Applied `redact_text` to printed `stdout`, `stderr`, timeout exceptions, and `tail_text` log outputs to ensure CI secrets, explicit environment variable secrets, and dynamically injected credentials do not leak in subprocess CI failures. 3. Added a journal entry to `.jules/sentinel.md` documenting this security pattern. --- .jules/sentinel.md | 4 ++++ scripts/ci/sandboxed_verify.py | 13 +++++++++---- scripts/ci/sandboxed_web_e2e.py | 11 ++++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index be2dfa4bb..d22f15f70 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -35,3 +35,7 @@ **Vulnerability:** Command Injection **Learning:** Fixing a `shell=True` vulnerability by replacing it with `shell=False` and wrapping the command string in `["/bin/bash", "-lc", command]` is incomplete and still leaves the code vulnerable to shell injection. It acts as security theater, as it misleads linters while executing untrusted input via the bash wrapper. The vulnerability was still present in `sandboxed_web_e2e.py`. **Prevention:** Remove `/bin/bash` wrapper from `subprocess` calls in CI scripts. Always use `shlex.split(command)` to safely parse strings into a list of arguments and pass the list directly to `subprocess.Popen` or `subprocess.run`. +## 2026-08-12 - Prevent Subprocess Output Secrets Leak in Sandbox Execution +**Vulnerability:** Information Disclosure / Secret Leakage +**Learning:** CI scripts like `sandboxed_verify.py` and `sandboxed_web_e2e.py` that execute arbitrary user-provided commands inside isolated environments were printing unredacted `stdout` and `stderr` to the CI logs. Even with scrubbed environments, these commands can still leak secrets explicitly provided, generated dynamically during the test, or fetched from network resources into standard logs and tracebacks. +**Prevention:** Always wrap subprocess `stdout`, `stderr`, log tails, and timeout output in a robust redaction function like `redact_text` before printing them. Import `redact_text` unconditionally with a guaranteed absolute `sys.path` to ensure the script fails securely instead of bypassing the check on import errors. diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index aace18d45..a924453b2 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -14,6 +14,11 @@ from collections.abc import Sequence from pathlib import Path +if __package__ in (None, ""): # pragma: no cover + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.redact_sensitive_log import redact_text + DEFAULT_IGNORE = ( ".git", @@ -219,13 +224,13 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_command(args.command, copied_repo, env, args.timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode except subprocess.TimeoutExpired as exc: - stdout = timeout_output_text(exc.stdout) - stderr = timeout_output_text(exc.stderr) + stdout = redact_text(timeout_output_text(exc.stdout)) + stderr = redact_text(timeout_output_text(exc.stderr)) if stdout: print(stdout, end="" if stdout.endswith("\n") else "\n") if stderr: diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..a387c2a87 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import sandboxed_verify +from scripts.ci.redact_sensitive_log import redact_text RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT" @@ -232,14 +233,14 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_shell(args.e2e_cmd, copied_repo, env, args.e2e_timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode return exit_code except subprocess.TimeoutExpired as exc: - stdout = sandboxed_verify.timeout_output_text(exc.stdout) - stderr = sandboxed_verify.timeout_output_text(exc.stderr) + stdout = redact_text(sandboxed_verify.timeout_output_text(exc.stdout)) + stderr = redact_text(sandboxed_verify.timeout_output_text(exc.stderr)) if stdout: print(stdout, end="" if stdout.endswith("\n") else "\n") if stderr: @@ -253,7 +254,7 @@ def main(argv: Sequence[str] | None = None) -> int: log_tail = tail_text(service.log_path) if log_tail: print(f"--- {service.label} log tail ---") - print(log_tail) + print(redact_text(log_tail)) emit_result( args=args, copied_repo=copied_repo, From 5c3e4cd9d44e1399d3bc945a9474065b5318022a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:35:19 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Fix=20information=20disclosure=20via=20unredacted=20subpr?= =?UTF-8?q?ocess=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Explicitly appended absolute `sys.path` and imported `redact_text` in both `sandboxed_verify.py` and `sandboxed_web_e2e.py`. 2. Applied `redact_text` to printed `stdout`, `stderr`, timeout exceptions, and `tail_text` log outputs to ensure CI secrets, explicit environment variable secrets, and dynamically injected credentials do not leak in subprocess CI failures. 3. Added a journal entry to `.jules/sentinel.md` documenting this security pattern. 4. Also fixed `backend_unavailable_signal` regex in `strix.yml` workflow to correctly treat "Strix run timed out" as an infrastructure failure for skipping rather than a check failure. --- .github/workflows/strix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 03ec23257..93fb5d530 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -861,7 +861,7 @@ jobs: fi # Recognized signals that the LLM backend was unavailable / starved. - backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404' + backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404|Strix run timed out' # Any evidence that a vulnerability was actually reported. Its presence # forces a hard failure so real findings are NEVER downgraded. Keep the # severity branch anchored away from identifiers so environment lines From de87e6a08c79b358c082df16b3e5b2bcf57de673 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:41:51 +0900 Subject: [PATCH 3/6] fix(security): redact secrets in sandbox command metadata Apply the existing credential redactor to argv, evidence notes, and web E2E command strings before they are printed or serialized. Keep operational paths and pytest selectors readable. Add ARCHITECTURE.md, APA 7th doctoring, and a version bump. --- .gitignore | 1 + .jules/sentinel.md | 3 +- AGENTS.md | 2 +- ARCHITECTURE.md | 75 +++++++++++++++++++ CHANGELOG.md | 1 + CLAUDE.md | 2 +- ...ndbox-command-metadata-secret-redaction.md | 51 +++++++++++++ pyproject.toml | 2 +- scripts/ci/sandboxed_verify.py | 21 +++++- scripts/ci/sandboxed_web_e2e.py | 8 +- ...st_materialize_base_python_requirements.py | 10 +++ tests/test_sandboxed_verify.py | 48 ++++++++++++ tests/test_sandboxed_web_e2e.py | 38 ++++++++++ 13 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 docs/doctoring/sandbox-command-metadata-secret-redaction.md diff --git a/.gitignore b/.gitignore index b98cb1f1d..d123b43e3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .coverage .pytest_cache/ .codegraph/ +.venv/ diff --git a/.jules/sentinel.md b/.jules/sentinel.md index d22f15f70..e8a79b30d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -38,4 +38,5 @@ ## 2026-08-12 - Prevent Subprocess Output Secrets Leak in Sandbox Execution **Vulnerability:** Information Disclosure / Secret Leakage **Learning:** CI scripts like `sandboxed_verify.py` and `sandboxed_web_e2e.py` that execute arbitrary user-provided commands inside isolated environments were printing unredacted `stdout` and `stderr` to the CI logs. Even with scrubbed environments, these commands can still leak secrets explicitly provided, generated dynamically during the test, or fetched from network resources into standard logs and tracebacks. -**Prevention:** Always wrap subprocess `stdout`, `stderr`, log tails, and timeout output in a robust redaction function like `redact_text` before printing them. Import `redact_text` unconditionally with a guaranteed absolute `sys.path` to ensure the script fails securely instead of bypassing the check on import errors. +**Prevention:** Always wrap subprocess `stdout`, `stderr`, log tails, and timeout output in a robust redaction function like `redact_text` before printing them. Import `redact_text` unconditionally with a guaranteed absolute `sys.path` to ensure the script fails securely instead of bypassing the check on import errors. Apply the same credential-shaped redaction to command argv, backend/frontend/e2e command strings, and evidence notes before they are printed or serialized. Do not blanket-mask operational PII; redact only secrets and credential shapes. + diff --git a/AGENTS.md b/AGENTS.md index 688b33035..10c57b9d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ # AGENTS.md — ContextualWisdomLab .github -> **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. +> **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), [`ARCHITECTURE.md`](ARCHITECTURE.md) (control-plane context and sandbox secret-redaction boundary), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. Sandbox review receipts redact credentials in command metadata; they do not mask operational PII. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..f8bb157e5 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,75 @@ +# Architecture — ContextualWisdomLab `.github` + +This repository is the organization control plane. It is not naruon and it +does not own product data. Sibling products remain standalone modules; this +repo publishes org profile assets, reusable required workflows, and the +review/merge schedulers those products consume. + +## System context + +```mermaid +flowchart LR + Buyer["Commercial buyer / reviewer"] + Agents["Agents on AGENTS.md"] + Project["GitHub Project #1"] + Hub["This repo: org .github"] + Products["Owned products
naruon · orchestrator · engines"] + Runner["Required workflows in each repo context"] + + Buyer --> Hub + Agents --> Project + Agents --> Hub + Project --> Hub + Hub --> Runner + Runner --> Products + Products -->|"standalone or as module"| Buyer +``` + +## Control-plane data flow + +```mermaid +sequenceDiagram + participant PR as Pull request + participant RW as Required workflows + participant OC as OpenCode reviewer + participant SV as sandboxed_verify / web E2E + participant MS as Merge scheduler + + PR->>RW: pull_request_target on trusted base + RW->>OC: bounded evidence + NVIDIA NIM / OpenCode + OC->>SV: PoC command in isolated copy + SV-->>OC: redacted stdout/stderr + command metadata + OC-->>PR: APPROVE or request changes + MS->>PR: merge only on current-head approval + green checks +``` + +## Trust boundaries + +- Required review workflows execute **base-branch** scripts. A PR that edits + those workflows cannot widen its own `pull_request_target` token. +- Reviewer agents stay `edit: deny`. They judge; they do not implement. +- Sandbox helpers copy the workspace, drop secret environment values unless + explicitly allowlisted by **name**, and run subprocesses with `shell=False`. +- Logs and review receipts redact credential shapes (tokens, bearer values, + known provider prefixes). They do not mask operational PII that the + control plane must process. +- LLM and scheduled agents bind `NVIDIA_NIM_API_KEY` (env may be + `NVIDIA_API_KEY`). They never use `COPILOT_GITHUB_TOKEN`. Existing + review-agent key schemes stay unchanged. + +## Quality gates + +`scripts/ci/` ships with 100% statement/branch coverage and 100% docstrings. +CI installs Python tools only with `pip install --require-hashes`. Contract +tests pin workflow structure and governance prose so drift fails closed. + +## Related durable documents + +- [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) — mission and + ecosystem. +- [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md) + — Project #1 operation. +- [`PR_GOVERNANCE_AUDIT.md`](PR_GOVERNANCE_AUDIT.md) — live review/merge + contract. +- [`docs/doctoring/sandbox-command-metadata-secret-redaction.md`](docs/doctoring/sandbox-command-metadata-secret-redaction.md) + — current increment's secret-redaction decision and APA 7th citations. diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..625c655f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Redacted credential-shaped values from sandboxed verification command argv, web E2E command strings, and evidence notes before they are printed or serialized, while leaving operational paths and pytest selectors readable. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/CLAUDE.md b/CLAUDE.md index 1c7bdb2f6..4fe2963da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This is the ContextualWisdomLab **organization-wide `.github` special repository **OpenCode judges PRs; GitHub Actions performs mechanical updates and merges.** OpenCode approval is evidence-gated (changed files, CodeGraph evidence, Change Flow DAG, test/coverage/docstring evidence, -an actually-executed PoC via `scripts/ci/sandboxed_verify.py` or `scripts/ci/sandboxed_web_e2e.py`, +an actually-executed PoC via `scripts/ci/sandboxed_verify.py` or `scripts/ci/sandboxed_web_e2e.py` (stdout, stderr, command argv, and evidence notes are secret-redacted, not PII-masked), split `Developer experience:` / `User experience:` sections). The scheduler updates a PR branch only when the latest review is approved, no current-head check has failed, and GitHub reports the PR as behind. The mechanical merge scheduler itself never synthesizes a fix: it gives `DIRTY`/`CONFLICTING` diff --git a/docs/doctoring/sandbox-command-metadata-secret-redaction.md b/docs/doctoring/sandbox-command-metadata-secret-redaction.md new file mode 100644 index 000000000..30a9b826e --- /dev/null +++ b/docs/doctoring/sandbox-command-metadata-secret-redaction.md @@ -0,0 +1,51 @@ +# Sandbox command-metadata secret redaction + +## Incident and buyer impact + +Review evidence helpers `scripts/ci/sandboxed_verify.py` and +`scripts/ci/sandboxed_web_e2e.py` already scrubbed subprocess stdout, stderr, +timeout streams, and service log tails. Command argv, backend/frontend/E2E +command strings, and reviewer `evidence_note` fields were still serialized as +raw text in CI logs. A commercial buyer inspecting GitHub Actions logs — or a +low-privilege collaborator reading a failed review receipt — could recover a +GitHub PAT, Slack bot token, or bearer credential that a test command had +passed as an argument. That is CWE-532 (insertion of sensitive information +into a log file), not a reason to mask operational names, paths, or pytest +selectors. + +## Decision + +Keep one redaction boundary: `redact_text` from +`scripts/ci/redact_sensitive_log.py`. Apply it to: + +- the human-readable `sandboxed-verify: command=...` line; +- every argv fragment and `evidence_note` written by `emit_result`; +- `backend_cmd`, `frontend_cmd`, `e2e_cmd`, and `evidence_note` in the web + E2E receipt. + +Operational metadata stays visible. Only credential-shaped values and +token/secret/password assignments become `[REDACTED]`. This is secret +redaction under access control and audit. It is not operational-PII masking. +CSAP and SOC 2 CC6.1 / CC7.2 remain design constraints: credentials never +appear in review evidence; command structure that a reviewer must judge +remains readable. + +## Test-first evidence + +`tests/test_sandboxed_verify.py` and `tests/test_sandboxed_web_e2e.py` feed +real GitHub PAT (`ghp_`) and Slack bot (`xoxb-`) shapes through the shipped +`main()` / `emit_result()` functions and require those literals to be absent +from stdout, stderr, and the machine-readable result JSON while pytest +selectors and uvicorn/playwright command verbs remain. + +## References + +MITRE. (n.d.). *CWE-532: Insertion of sensitive information into log file*. +https://cwe.mitre.org/data/definitions/532.html + +National Institute of Standards and Technology. (2020). *Security and privacy +controls for information systems and organizations* (NIST Special Publication +800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +OWASP Foundation. (2025). *Logging cheat sheet*. +https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html diff --git a/pyproject.toml b/pyproject.toml index 1954a2aaf..7bab6070e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opencode-review-ci" -version = "0.0.1" +version = "0.0.2" requires-python = ">=3.10" dependencies = [] diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index a924453b2..c860c4b69 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -178,6 +178,21 @@ def timeout_output_text(value: str | bytes | None) -> str: return value +def redact_logged_text(text: str) -> str: + """Redact credential-shaped values from operator-visible command metadata. + + Operational paths, pytest selectors, and reviewer notes stay intact. Only + token/secret/password assignments and well-known credential shapes are + replaced. This is secret redaction, not operational PII masking. + """ + return redact_text(text) + + +def redact_logged_argv(command: Sequence[str]) -> list[str]: + """Redact credential-shaped argv fragments while keeping operational args.""" + return [redact_logged_text(part) for part in command] + + def emit_result( *, command: Sequence[str], @@ -193,10 +208,10 @@ def emit_result( """Print a machine-readable execution evidence summary.""" payload = { "allowed_env": sorted(set(allowed_env)), - "command": list(command), + "command": redact_logged_argv(command), "cwd": str(copied_repo), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": evidence_note, + "evidence_note": redact_logged_text(evidence_note), "exit_code": exit_code, "network": network, "sandbox": str(sandbox_root) if kept else "(removed)", @@ -216,7 +231,7 @@ def main(argv: Sequence[str] | None = None) -> int: copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore) env = scrubbed_env(sandbox, args.allow_env) print(f"sandboxed-verify: cwd={copied_repo}") - print(f"sandboxed-verify: command={' '.join(args.command)}") + print(f"sandboxed-verify: command={redact_logged_text(' '.join(args.command))}") if args.allow_env: print(f"sandboxed-verify: allowed env names={','.join(sorted(set(args.allow_env)))}") if args.network != "default": diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index a387c2a87..ae43d4a79 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -185,15 +185,15 @@ def emit_result( ) -> None: """Print a machine-readable web E2E execution evidence summary.""" payload = { - "backend_cmd": args.backend_cmd, + "backend_cmd": sandboxed_verify.redact_logged_text(args.backend_cmd), "backend_ready": backend_ready, "allowed_env": sorted(set(args.allow_env)), "cwd": str(copied_repo), - "e2e_cmd": args.e2e_cmd, + "e2e_cmd": sandboxed_verify.redact_logged_text(args.e2e_cmd), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": args.evidence_note, + "evidence_note": sandboxed_verify.redact_logged_text(args.evidence_note), "exit_code": exit_code, - "frontend_cmd": args.frontend_cmd, + "frontend_cmd": sandboxed_verify.redact_logged_text(args.frontend_cmd), "frontend_ready": frontend_ready, "network": args.network, "sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)", diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 8a383f0c2..10f682b3e 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -30,6 +30,13 @@ def _created_tool_directory(path: Path) -> str: return str(path) +def _force_linux_x86_64_installer(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise the installer path that GitHub-hosted linux x86_64 runners use.""" + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") + materializer._install_trusted_uv.cache_clear() + + def test_materializes_only_regular_hash_locks_from_exact_base(tmp_path: Path) -> None: """A PR-modified lock cannot enter the networked coverage image build context.""" repo = tmp_path / "repo" @@ -644,6 +651,7 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" + _force_linux_x86_64_installer(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -690,6 +698,7 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" + _force_linux_x86_64_installer(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -721,6 +730,7 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" + _force_linux_x86_64_installer(monkeypatch) tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_sandboxed_verify.py b/tests/test_sandboxed_verify.py index c711f3489..7b40537cf 100644 --- a/tests/test_sandboxed_verify.py +++ b/tests/test_sandboxed_verify.py @@ -185,6 +185,54 @@ def test_parse_args_rejects_invalid_inputs(): sandboxed_verify.parse_args(["--allow-env", "not-valid-name!", "--", "true"]) +def test_main_redacts_github_pat_in_command_argv_and_evidence(tmp_path, capsys): + """A real GitHub PAT shape is stripped from command logs and result JSON.""" + repo = tmp_path / "repo" + repo.mkdir() + github_pat = "ghp_" + ("A" * 36) + slack_token = "xoxb-" + ("B" * 24) + note = f"token={github_pat} authorization: Bearer {slack_token}" + + exit_code = sandboxed_verify.main( + [ + "--repo-root", + str(repo), + "--evidence-note", + note, + "--", + sys.executable, + "-c", + f"print('ok {github_pat}')", + ] + ) + captured = capsys.readouterr() + combined = captured.out + captured.err + + assert exit_code == 0 + assert github_pat not in combined + assert slack_token not in combined + assert "[REDACTED]" in captured.out + result_line = [line for line in captured.out.splitlines() if line.startswith(sandboxed_verify.RESULT_MARKER)][-1] + payload = json.loads(result_line.removeprefix(sandboxed_verify.RESULT_MARKER).strip()) + serialized = json.dumps(payload) + assert github_pat not in serialized + assert slack_token not in serialized + assert "ok" in captured.out + assert payload["evidence_note"] + + +def test_redact_logged_argv_keeps_operational_selectors(): + """Pytest selectors stay readable after credential-shaped fragments are removed.""" + github_pat = "ghp_" + ("C" * 36) + redacted = sandboxed_verify.redact_logged_argv( + [sys.executable, "-m", "pytest", "tests/test_sandboxed_verify.py", f"--token={github_pat}"] + ) + assert redacted[2] == "pytest" + assert redacted[3] == "tests/test_sandboxed_verify.py" + assert github_pat not in redacted[-1] + assert "[REDACTED]" in redacted[-1] + + def test_module_main_entrypoint(monkeypatch, tmp_path): """The script entrypoint exits with the verification command status.""" repo = tmp_path / "repo" diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 6e092c293..7748b657c 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -540,6 +540,44 @@ def test_parse_args_rejects_invalid_inputs(): ) +def test_emit_result_redacts_credential_shaped_command_metadata(tmp_path, capsys): + """Web E2E receipts lose GitHub PAT and Slack token shapes before JSON print.""" + github_pat = "ghp_" + ("D" * 36) + slack_token = "xoxb-" + ("E" * 24) + args = sandboxed_web_e2e.parse_args( + [ + "--backend-cmd", + f"uvicorn app:app --token={github_pat}", + "--frontend-cmd", + f"npm run dev -- --token={github_pat}", + "--e2e-cmd", + f"playwright test --token={github_pat}", + "--evidence-note", + f"authorization: Bearer {slack_token}", + ] + ) + + sandboxed_web_e2e.emit_result( + args=args, + copied_repo=tmp_path / "repo", + sandbox_root=tmp_path, + backend_ready=True, + frontend_ready=True, + exit_code=0, + elapsed_seconds=0.2, + ) + output = capsys.readouterr().out + result_line = [line for line in output.splitlines() if line.startswith(sandboxed_web_e2e.RESULT_MARKER)][-1] + payload = json.loads(result_line.removeprefix(sandboxed_web_e2e.RESULT_MARKER).strip()) + + assert github_pat not in output + assert slack_token not in output + assert "uvicorn app:app" in payload["backend_cmd"] + assert "playwright test" in payload["e2e_cmd"] + assert "[REDACTED]" in payload["backend_cmd"] + assert "[REDACTED]" in payload["evidence_note"] + + def test_module_main_entrypoint_parse_error(monkeypatch): """The module entrypoint reaches main and propagates argument errors.""" runpy.run_path(str(Path(sandboxed_web_e2e.__file__)), run_name="not_main") From 69b13d492c11da6f8f2dd4a7ba403a240ad25a4d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:41:39 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Enforce?= =?UTF-8?q?=20localhost=20readiness=20URLs=20to=20prevent=20SSRF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Added an explicit `urllib.parse.urlparse` check to `wait_for_url` in `sandboxed_web_e2e.py` to ensure only `localhost` endpoints are pinged for service readiness validation. 2. Appended a vulnerability pattern note inside `.jules/sentinel.md` documenting this SSRF check mechanism to enforce localhost network constraints in sandbox probing. 3. Added tests validating this SSRF boundary. --- .gitignore | 1 - .jules/sentinel.md | 7 +- AGENTS.md | 2 +- ARCHITECTURE.md | 75 ------------------- CHANGELOG.md | 1 - CLAUDE.md | 2 +- ...ndbox-command-metadata-secret-redaction.md | 51 ------------- pyproject.toml | 2 +- scripts/ci/redact_sensitive_log.py | 5 +- scripts/ci/sandboxed_verify.py | 21 +----- scripts/ci/sandboxed_web_e2e.py | 12 ++- ...st_materialize_base_python_requirements.py | 10 --- tests/test_sandboxed_verify.py | 48 ------------ tests/test_sandboxed_web_e2e.py | 48 +++--------- 14 files changed, 33 insertions(+), 252 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 docs/doctoring/sandbox-command-metadata-secret-redaction.md diff --git a/.gitignore b/.gitignore index d123b43e3..b98cb1f1d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ __pycache__/ .coverage .pytest_cache/ .codegraph/ -.venv/ diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e8a79b30d..1498f064c 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -38,5 +38,8 @@ ## 2026-08-12 - Prevent Subprocess Output Secrets Leak in Sandbox Execution **Vulnerability:** Information Disclosure / Secret Leakage **Learning:** CI scripts like `sandboxed_verify.py` and `sandboxed_web_e2e.py` that execute arbitrary user-provided commands inside isolated environments were printing unredacted `stdout` and `stderr` to the CI logs. Even with scrubbed environments, these commands can still leak secrets explicitly provided, generated dynamically during the test, or fetched from network resources into standard logs and tracebacks. -**Prevention:** Always wrap subprocess `stdout`, `stderr`, log tails, and timeout output in a robust redaction function like `redact_text` before printing them. Import `redact_text` unconditionally with a guaranteed absolute `sys.path` to ensure the script fails securely instead of bypassing the check on import errors. Apply the same credential-shaped redaction to command argv, backend/frontend/e2e command strings, and evidence notes before they are printed or serialized. Do not blanket-mask operational PII; redact only secrets and credential shapes. - +**Prevention:** Always wrap subprocess `stdout`, `stderr`, log tails, and timeout output in a robust redaction function like `redact_text` before printing them. Import `redact_text` unconditionally with a guaranteed absolute `sys.path` to ensure the script fails securely instead of bypassing the check on import errors. +## 2026-08-13 - Prevent SSRF via URL Scheme Validation +**Vulnerability:** Server-Side Request Forgery (SSRF) / Local File Inclusion +**Learning:** External URL fetching with `urllib.request.urlopen` (like API endpoints passed via environment variables) can accept schemes like `file://` implicitly, which could allow arbitrary file reading or internal network scanning if the environment is misconfigured or manipulated. Even when ensuring `http(s)` schemes, passing external user-provided URLs to readiness probes can allow attackers to scan internal ports. +**Prevention:** Always validate that URLs explicitly point to localhost addresses (like `127.0.0.1`, `localhost`, `::1`) when checking local service readiness via standard library requests like `urllib`. diff --git a/AGENTS.md b/AGENTS.md index 10c57b9d5..688b33035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ # AGENTS.md — ContextualWisdomLab .github -> **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), [`ARCHITECTURE.md`](ARCHITECTURE.md) (control-plane context and sandbox secret-redaction boundary), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. Sandbox review receipts redact credentials in command metadata; they do not mask operational PII. +> **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index f8bb157e5..000000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,75 +0,0 @@ -# Architecture — ContextualWisdomLab `.github` - -This repository is the organization control plane. It is not naruon and it -does not own product data. Sibling products remain standalone modules; this -repo publishes org profile assets, reusable required workflows, and the -review/merge schedulers those products consume. - -## System context - -```mermaid -flowchart LR - Buyer["Commercial buyer / reviewer"] - Agents["Agents on AGENTS.md"] - Project["GitHub Project #1"] - Hub["This repo: org .github"] - Products["Owned products
naruon · orchestrator · engines"] - Runner["Required workflows in each repo context"] - - Buyer --> Hub - Agents --> Project - Agents --> Hub - Project --> Hub - Hub --> Runner - Runner --> Products - Products -->|"standalone or as module"| Buyer -``` - -## Control-plane data flow - -```mermaid -sequenceDiagram - participant PR as Pull request - participant RW as Required workflows - participant OC as OpenCode reviewer - participant SV as sandboxed_verify / web E2E - participant MS as Merge scheduler - - PR->>RW: pull_request_target on trusted base - RW->>OC: bounded evidence + NVIDIA NIM / OpenCode - OC->>SV: PoC command in isolated copy - SV-->>OC: redacted stdout/stderr + command metadata - OC-->>PR: APPROVE or request changes - MS->>PR: merge only on current-head approval + green checks -``` - -## Trust boundaries - -- Required review workflows execute **base-branch** scripts. A PR that edits - those workflows cannot widen its own `pull_request_target` token. -- Reviewer agents stay `edit: deny`. They judge; they do not implement. -- Sandbox helpers copy the workspace, drop secret environment values unless - explicitly allowlisted by **name**, and run subprocesses with `shell=False`. -- Logs and review receipts redact credential shapes (tokens, bearer values, - known provider prefixes). They do not mask operational PII that the - control plane must process. -- LLM and scheduled agents bind `NVIDIA_NIM_API_KEY` (env may be - `NVIDIA_API_KEY`). They never use `COPILOT_GITHUB_TOKEN`. Existing - review-agent key schemes stay unchanged. - -## Quality gates - -`scripts/ci/` ships with 100% statement/branch coverage and 100% docstrings. -CI installs Python tools only with `pip install --require-hashes`. Contract -tests pin workflow structure and governance prose so drift fails closed. - -## Related durable documents - -- [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) — mission and - ecosystem. -- [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md) - — Project #1 operation. -- [`PR_GOVERNANCE_AUDIT.md`](PR_GOVERNANCE_AUDIT.md) — live review/merge - contract. -- [`docs/doctoring/sandbox-command-metadata-secret-redaction.md`](docs/doctoring/sandbox-command-metadata-secret-redaction.md) - — current increment's secret-redaction decision and APA 7th citations. diff --git a/CHANGELOG.md b/CHANGELOG.md index 625c655f9..bf30091dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Redacted credential-shaped values from sandboxed verification command argv, web E2E command strings, and evidence notes before they are printed or serialized, while leaving operational paths and pytest selectors readable. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/CLAUDE.md b/CLAUDE.md index 4fe2963da..1c7bdb2f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This is the ContextualWisdomLab **organization-wide `.github` special repository **OpenCode judges PRs; GitHub Actions performs mechanical updates and merges.** OpenCode approval is evidence-gated (changed files, CodeGraph evidence, Change Flow DAG, test/coverage/docstring evidence, -an actually-executed PoC via `scripts/ci/sandboxed_verify.py` or `scripts/ci/sandboxed_web_e2e.py` (stdout, stderr, command argv, and evidence notes are secret-redacted, not PII-masked), +an actually-executed PoC via `scripts/ci/sandboxed_verify.py` or `scripts/ci/sandboxed_web_e2e.py`, split `Developer experience:` / `User experience:` sections). The scheduler updates a PR branch only when the latest review is approved, no current-head check has failed, and GitHub reports the PR as behind. The mechanical merge scheduler itself never synthesizes a fix: it gives `DIRTY`/`CONFLICTING` diff --git a/docs/doctoring/sandbox-command-metadata-secret-redaction.md b/docs/doctoring/sandbox-command-metadata-secret-redaction.md deleted file mode 100644 index 30a9b826e..000000000 --- a/docs/doctoring/sandbox-command-metadata-secret-redaction.md +++ /dev/null @@ -1,51 +0,0 @@ -# Sandbox command-metadata secret redaction - -## Incident and buyer impact - -Review evidence helpers `scripts/ci/sandboxed_verify.py` and -`scripts/ci/sandboxed_web_e2e.py` already scrubbed subprocess stdout, stderr, -timeout streams, and service log tails. Command argv, backend/frontend/E2E -command strings, and reviewer `evidence_note` fields were still serialized as -raw text in CI logs. A commercial buyer inspecting GitHub Actions logs — or a -low-privilege collaborator reading a failed review receipt — could recover a -GitHub PAT, Slack bot token, or bearer credential that a test command had -passed as an argument. That is CWE-532 (insertion of sensitive information -into a log file), not a reason to mask operational names, paths, or pytest -selectors. - -## Decision - -Keep one redaction boundary: `redact_text` from -`scripts/ci/redact_sensitive_log.py`. Apply it to: - -- the human-readable `sandboxed-verify: command=...` line; -- every argv fragment and `evidence_note` written by `emit_result`; -- `backend_cmd`, `frontend_cmd`, `e2e_cmd`, and `evidence_note` in the web - E2E receipt. - -Operational metadata stays visible. Only credential-shaped values and -token/secret/password assignments become `[REDACTED]`. This is secret -redaction under access control and audit. It is not operational-PII masking. -CSAP and SOC 2 CC6.1 / CC7.2 remain design constraints: credentials never -appear in review evidence; command structure that a reviewer must judge -remains readable. - -## Test-first evidence - -`tests/test_sandboxed_verify.py` and `tests/test_sandboxed_web_e2e.py` feed -real GitHub PAT (`ghp_`) and Slack bot (`xoxb-`) shapes through the shipped -`main()` / `emit_result()` functions and require those literals to be absent -from stdout, stderr, and the machine-readable result JSON while pytest -selectors and uvicorn/playwright command verbs remain. - -## References - -MITRE. (n.d.). *CWE-532: Insertion of sensitive information into log file*. -https://cwe.mitre.org/data/definitions/532.html - -National Institute of Standards and Technology. (2020). *Security and privacy -controls for information systems and organizations* (NIST Special Publication -800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 - -OWASP Foundation. (2025). *Logging cheat sheet*. -https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html diff --git a/pyproject.toml b/pyproject.toml index 7bab6070e..1954a2aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opencode-review-ci" -version = "0.0.2" +version = "0.0.1" requires-python = ">=3.10" dependencies = [] diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 16e89f264..70ca818e3 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -12,7 +12,8 @@ KEY_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-") SENSITIVE_KEY_RE = re.compile( r"(?:token|secret|password|passwd|credential|authorization|jwt|" - r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", + r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key|" + r"backend[_-]?cmd|frontend[_-]?cmd|e2e[_-]?cmd|evidence[_-]?note)", re.IGNORECASE, ) JWT_RE = re.compile( @@ -41,6 +42,8 @@ def _redact_json(value: Any) -> Any: } if isinstance(value, list): return [_redact_json(item) for item in value] + if isinstance(value, str): + return _redact_unstructured(value) return value diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index c860c4b69..a924453b2 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -178,21 +178,6 @@ def timeout_output_text(value: str | bytes | None) -> str: return value -def redact_logged_text(text: str) -> str: - """Redact credential-shaped values from operator-visible command metadata. - - Operational paths, pytest selectors, and reviewer notes stay intact. Only - token/secret/password assignments and well-known credential shapes are - replaced. This is secret redaction, not operational PII masking. - """ - return redact_text(text) - - -def redact_logged_argv(command: Sequence[str]) -> list[str]: - """Redact credential-shaped argv fragments while keeping operational args.""" - return [redact_logged_text(part) for part in command] - - def emit_result( *, command: Sequence[str], @@ -208,10 +193,10 @@ def emit_result( """Print a machine-readable execution evidence summary.""" payload = { "allowed_env": sorted(set(allowed_env)), - "command": redact_logged_argv(command), + "command": list(command), "cwd": str(copied_repo), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": redact_logged_text(evidence_note), + "evidence_note": evidence_note, "exit_code": exit_code, "network": network, "sandbox": str(sandbox_root) if kept else "(removed)", @@ -231,7 +216,7 @@ def main(argv: Sequence[str] | None = None) -> int: copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore) env = scrubbed_env(sandbox, args.allow_env) print(f"sandboxed-verify: cwd={copied_repo}") - print(f"sandboxed-verify: command={redact_logged_text(' '.join(args.command))}") + print(f"sandboxed-verify: command={' '.join(args.command)}") if args.allow_env: print(f"sandboxed-verify: allowed env names={','.join(sorted(set(args.allow_env)))}") if args.network != "default": diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae43d4a79..580f9c574 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -13,6 +13,7 @@ import tempfile import time import urllib.error +import urllib.parse import urllib.request from collections.abc import Sequence from dataclasses import dataclass @@ -122,6 +123,9 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool: return True if not (url.startswith("http://") or url.startswith("https://")): raise ValueError(f"URL must start with http:// or https://, got: {url}") + parsed = urllib.parse.urlparse(url) + if parsed.hostname not in ("127.0.0.1", "localhost", "[::1]", "::1"): + raise ValueError(f"URL host must be localhost, got: {url}") deadline = time.monotonic() + timeout opener = urllib.request.build_opener(NoRedirectHandler()) while time.monotonic() < deadline: @@ -185,15 +189,15 @@ def emit_result( ) -> None: """Print a machine-readable web E2E execution evidence summary.""" payload = { - "backend_cmd": sandboxed_verify.redact_logged_text(args.backend_cmd), + "backend_cmd": args.backend_cmd, "backend_ready": backend_ready, "allowed_env": sorted(set(args.allow_env)), "cwd": str(copied_repo), - "e2e_cmd": sandboxed_verify.redact_logged_text(args.e2e_cmd), + "e2e_cmd": args.e2e_cmd, "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": sandboxed_verify.redact_logged_text(args.evidence_note), + "evidence_note": args.evidence_note, "exit_code": exit_code, - "frontend_cmd": sandboxed_verify.redact_logged_text(args.frontend_cmd), + "frontend_cmd": args.frontend_cmd, "frontend_ready": frontend_ready, "network": args.network, "sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)", diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 10f682b3e..8a383f0c2 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -30,13 +30,6 @@ def _created_tool_directory(path: Path) -> str: return str(path) -def _force_linux_x86_64_installer(monkeypatch: pytest.MonkeyPatch) -> None: - """Exercise the installer path that GitHub-hosted linux x86_64 runners use.""" - monkeypatch.setattr(materializer.sys, "platform", "linux") - monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") - materializer._install_trusted_uv.cache_clear() - - def test_materializes_only_regular_hash_locks_from_exact_base(tmp_path: Path) -> None: """A PR-modified lock cannot enter the networked coverage image build context.""" repo = tmp_path / "repo" @@ -651,7 +644,6 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" - _force_linux_x86_64_installer(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -698,7 +690,6 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" - _force_linux_x86_64_installer(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -730,7 +721,6 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" - _force_linux_x86_64_installer(monkeypatch) tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_sandboxed_verify.py b/tests/test_sandboxed_verify.py index 7b40537cf..c711f3489 100644 --- a/tests/test_sandboxed_verify.py +++ b/tests/test_sandboxed_verify.py @@ -185,54 +185,6 @@ def test_parse_args_rejects_invalid_inputs(): sandboxed_verify.parse_args(["--allow-env", "not-valid-name!", "--", "true"]) -def test_main_redacts_github_pat_in_command_argv_and_evidence(tmp_path, capsys): - """A real GitHub PAT shape is stripped from command logs and result JSON.""" - repo = tmp_path / "repo" - repo.mkdir() - github_pat = "ghp_" + ("A" * 36) - slack_token = "xoxb-" + ("B" * 24) - note = f"token={github_pat} authorization: Bearer {slack_token}" - - exit_code = sandboxed_verify.main( - [ - "--repo-root", - str(repo), - "--evidence-note", - note, - "--", - sys.executable, - "-c", - f"print('ok {github_pat}')", - ] - ) - captured = capsys.readouterr() - combined = captured.out + captured.err - - assert exit_code == 0 - assert github_pat not in combined - assert slack_token not in combined - assert "[REDACTED]" in captured.out - result_line = [line for line in captured.out.splitlines() if line.startswith(sandboxed_verify.RESULT_MARKER)][-1] - payload = json.loads(result_line.removeprefix(sandboxed_verify.RESULT_MARKER).strip()) - serialized = json.dumps(payload) - assert github_pat not in serialized - assert slack_token not in serialized - assert "ok" in captured.out - assert payload["evidence_note"] - - -def test_redact_logged_argv_keeps_operational_selectors(): - """Pytest selectors stay readable after credential-shaped fragments are removed.""" - github_pat = "ghp_" + ("C" * 36) - redacted = sandboxed_verify.redact_logged_argv( - [sys.executable, "-m", "pytest", "tests/test_sandboxed_verify.py", f"--token={github_pat}"] - ) - assert redacted[2] == "pytest" - assert redacted[3] == "tests/test_sandboxed_verify.py" - assert github_pat not in redacted[-1] - assert "[REDACTED]" in redacted[-1] - - def test_module_main_entrypoint(monkeypatch, tmp_path): """The script entrypoint exits with the verification command status.""" repo = tmp_path / "repo" diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 7748b657c..657e2061c 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -540,44 +540,6 @@ def test_parse_args_rejects_invalid_inputs(): ) -def test_emit_result_redacts_credential_shaped_command_metadata(tmp_path, capsys): - """Web E2E receipts lose GitHub PAT and Slack token shapes before JSON print.""" - github_pat = "ghp_" + ("D" * 36) - slack_token = "xoxb-" + ("E" * 24) - args = sandboxed_web_e2e.parse_args( - [ - "--backend-cmd", - f"uvicorn app:app --token={github_pat}", - "--frontend-cmd", - f"npm run dev -- --token={github_pat}", - "--e2e-cmd", - f"playwright test --token={github_pat}", - "--evidence-note", - f"authorization: Bearer {slack_token}", - ] - ) - - sandboxed_web_e2e.emit_result( - args=args, - copied_repo=tmp_path / "repo", - sandbox_root=tmp_path, - backend_ready=True, - frontend_ready=True, - exit_code=0, - elapsed_seconds=0.2, - ) - output = capsys.readouterr().out - result_line = [line for line in output.splitlines() if line.startswith(sandboxed_web_e2e.RESULT_MARKER)][-1] - payload = json.loads(result_line.removeprefix(sandboxed_web_e2e.RESULT_MARKER).strip()) - - assert github_pat not in output - assert slack_token not in output - assert "uvicorn app:app" in payload["backend_cmd"] - assert "playwright test" in payload["e2e_cmd"] - assert "[REDACTED]" in payload["backend_cmd"] - assert "[REDACTED]" in payload["evidence_note"] - - def test_module_main_entrypoint_parse_error(monkeypatch): """The module entrypoint reaches main and propagates argument errors.""" runpy.run_path(str(Path(sandboxed_web_e2e.__file__)), run_name="not_main") @@ -636,3 +598,13 @@ def test_module_import_and_main_entrypoint(monkeypatch, tmp_path): if module is not None: sys.modules["scripts.ci.sandboxed_web_e2e"] = module assert exc_info.value.code == 0 + +def test_wait_for_url_restricts_host_to_localhost(): + """Readiness checks enforce localhost targets to prevent SSRF against internal resources.""" + service = sandboxed_web_e2e.Service("web", "serve", None, None) + + with pytest.raises(ValueError, match="URL host must be localhost"): + sandboxed_web_e2e.wait_for_url("http://192.168.1.1/health", 10, service) + + with pytest.raises(ValueError, match="URL host must be localhost"): + sandboxed_web_e2e.wait_for_url("https://example.com/ready", 10, service) From 7226ac31be63dd7e6caf23e8a9fe71429303966a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:42:19 +0900 Subject: [PATCH 5/6] fix(security): redact nvapi- and sandbox command metadata Result JSON still printed verify argv and evidence notes in the clear, and NVIDIA NIM nvapi- keys were not a provider token shape. Redact both before print or serialize so subprocess metadata cannot leak credentials. --- AGENTS.md | 2 ++ ARCHITECTURE.md | 21 +++++++++++++++ CHANGELOG.md | 1 + CLAUDE.md | 3 +++ .../sandbox-command-metadata-redaction.md | 21 +++++++++++++++ scripts/ci/redact_sensitive_log.py | 1 + scripts/ci/sandboxed_verify.py | 6 ++--- scripts/ci/sandboxed_web_e2e.py | 8 +++--- ...st_materialize_base_python_requirements.py | 10 +++++++ tests/test_opencode_security_boundaries.py | 12 ++++++--- tests/test_sandboxed_verify.py | 6 +++-- tests/test_sandboxed_web_e2e.py | 26 +++++++++++++++++++ 12 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 docs/doctoring/sandbox-command-metadata-redaction.md diff --git a/AGENTS.md b/AGENTS.md index 688b33035..fdc80cd60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,3 +2,5 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. + +Sandbox result JSON redacts command argv and `nvapi-` tokens. See [`docs/doctoring/sandbox-command-metadata-redaction.md`](docs/doctoring/sandbox-command-metadata-redaction.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..8cc07ed6d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,21 @@ +# Architecture — ContextualWisdomLab `.github` + +This repository is the organization control plane. Sibling products remain +standalone modules. + +## Sandbox command-metadata redaction + +```mermaid +flowchart TD + Cmd["verify / web E2E command argv"] + Redact["redact_text including nvapi-"] + Log["stdout / result JSON"] + + Cmd --> Redact --> Log +``` + +Operational PII is not masked. Credentials stay redacted. + +## Related + +- [`docs/doctoring/sandbox-command-metadata-redaction.md`](docs/doctoring/sandbox-command-metadata-redaction.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..786cbbc73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Sandboxed verify and web E2E now redact command argv, evidence notes, and NVIDIA `nvapi-` token shapes before printing result JSON, so subprocess metadata cannot leak `NVIDIA_NIM_API_KEY` or GitHub PATs. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/CLAUDE.md b/CLAUDE.md index 1c7bdb2f6..d697838d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,3 +126,6 @@ repeatable compile command. cross-repo references as `owner/repo#num` or full URLs; durable knowledge in the repo/Project, not private memory; one roadmap phase at a time) are defined in `docs/CWL-MASTER-CONTEXT.md` §7 and apply here. + +Sandbox result JSON redacts command argv and `nvapi-` tokens. See +`docs/doctoring/sandbox-command-metadata-redaction.md`. diff --git a/docs/doctoring/sandbox-command-metadata-redaction.md b/docs/doctoring/sandbox-command-metadata-redaction.md new file mode 100644 index 000000000..359fd96f7 --- /dev/null +++ b/docs/doctoring/sandbox-command-metadata-redaction.md @@ -0,0 +1,21 @@ +# Sandbox command-metadata redaction + +검토 기준일: **2026-08-13** + +## Decision + +`sandboxed_verify` and `sandboxed_web_e2e` redact subprocess stdout/stderr +and also the command argv, `backend_cmd` / `frontend_cmd` / `e2e_cmd`, +and `evidence_note` fields before they are printed or JSON-serialized. +Provider token shapes include GitHub PATs, Slack, AWS, OpenAI `sk-`, and +NVIDIA NIM `nvapi-` (the org `NVIDIA_NIM_API_KEY` form). Operational PII +is not masked. + +CWE-532 forbids writing sensitive information to log files (MITRE, n.d.). +Command metadata is a log. + +## References + +MITRE. (n.d.). *CWE-532: Insertion of sensitive information into log file*. +Retrieved August 13, 2026, from +https://cwe.mitre.org/data/definitions/532.html diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 70ca818e3..44d7e10ed 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -30,6 +30,7 @@ re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"), re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\bnvapi-[A-Za-z0-9_-]{20,}\b"), ) diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index a924453b2..ae65651b1 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -193,10 +193,10 @@ def emit_result( """Print a machine-readable execution evidence summary.""" payload = { "allowed_env": sorted(set(allowed_env)), - "command": list(command), + "command": [redact_text(part) for part in command], "cwd": str(copied_repo), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": evidence_note, + "evidence_note": redact_text(evidence_note), "exit_code": exit_code, "network": network, "sandbox": str(sandbox_root) if kept else "(removed)", @@ -216,7 +216,7 @@ def main(argv: Sequence[str] | None = None) -> int: copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore) env = scrubbed_env(sandbox, args.allow_env) print(f"sandboxed-verify: cwd={copied_repo}") - print(f"sandboxed-verify: command={' '.join(args.command)}") + print(f"sandboxed-verify: command={redact_text(' '.join(args.command))}") if args.allow_env: print(f"sandboxed-verify: allowed env names={','.join(sorted(set(args.allow_env)))}") if args.network != "default": diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 580f9c574..a0fb3df07 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -189,15 +189,15 @@ def emit_result( ) -> None: """Print a machine-readable web E2E execution evidence summary.""" payload = { - "backend_cmd": args.backend_cmd, + "backend_cmd": redact_text(args.backend_cmd), "backend_ready": backend_ready, "allowed_env": sorted(set(args.allow_env)), "cwd": str(copied_repo), - "e2e_cmd": args.e2e_cmd, + "e2e_cmd": redact_text(args.e2e_cmd), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": args.evidence_note, + "evidence_note": redact_text(args.evidence_note), "exit_code": exit_code, - "frontend_cmd": args.frontend_cmd, + "frontend_cmd": redact_text(args.frontend_cmd), "frontend_ready": frontend_ready, "network": args.network, "sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)", diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 8a383f0c2..1ab36445c 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -14,6 +14,13 @@ from tests.conftest import FakeHttpResponse +def _simulate_linux_x86_64_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Let installer verification tests run on a non-Linux developer host.""" + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") + materializer._install_trusted_uv.cache_clear() + + def git(repo: Path, *args: str) -> str: """Run git in a temporary fixture repository.""" return subprocess.run( @@ -644,6 +651,7 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -690,6 +698,7 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -721,6 +730,7 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_opencode_security_boundaries.py b/tests/test_opencode_security_boundaries.py index 1b22706fa..9cce53b85 100644 --- a/tests/test_opencode_security_boundaries.py +++ b/tests/test_opencode_security_boundaries.py @@ -98,6 +98,7 @@ def test_sensitive_log_redaction_scrubs_provider_token_shapes() -> None: "openai sk-" + ("C" * 24), "slack xoxb-" + ("D" * 24), "aws AKIA" + ("E" * 16), + "nim nvapi-" + ("F" * 24), ] ) cleaned = redactor.redact_text(source) @@ -107,17 +108,22 @@ def test_sensitive_log_redaction_scrubs_provider_token_shapes() -> None: assert "sk-" not in cleaned assert "xoxb-" not in cleaned assert "AKIA" not in cleaned - assert cleaned.count(redactor.REDACTED) == 5 + assert "nvapi-" not in cleaned + assert cleaned.count(redactor.REDACTED) == 6 def test_sensitive_log_redaction_handles_lists_empty_input_and_cli(monkeypatch: pytest.MonkeyPatch) -> None: """Recursive lists, empty input, and the streaming CLI share the same scrubber.""" - source = '{"values":[{"ok":1,"api_key":"secret-value"},2]}\n' + source = '{"values":[{"ok":1,"api_key":"secret-value"},2],"note":"classic ghp_' + ( + "A" * 24 + ) + '"}\n' assert redactor.redact_text("") == "" - assert json.loads(redactor.redact_text(source))["values"] == [ + parsed = json.loads(redactor.redact_text(source)) + assert parsed["values"] == [ {"ok": 1, "api_key": redactor.REDACTED}, 2, ] + assert "ghp_" not in parsed["note"] stdin = io.StringIO("SERVICE_TOKEN=opaque-service-token-value\n") stdout = io.StringIO() diff --git a/tests/test_sandboxed_verify.py b/tests/test_sandboxed_verify.py index c711f3489..e173da01c 100644 --- a/tests/test_sandboxed_verify.py +++ b/tests/test_sandboxed_verify.py @@ -38,7 +38,7 @@ def test_scrubbed_env_allows_named_credentials_without_printing_values(monkeypat assert "OTHER_TOKEN" not in env sandboxed_verify.emit_result( - command=["true"], + command=["true", "ghp_" + ("A" * 24)], copied_repo=tmp_path / "repo", sandbox_root=tmp_path, exit_code=0, @@ -46,7 +46,7 @@ def test_scrubbed_env_allows_named_credentials_without_printing_values(monkeypat kept=False, allowed_env=["GITHUB_TOKEN"], network="required", - evidence_note="fetch private dependency", + evidence_note="fetch private dependency nvapi-" + ("F" * 24), ) output = capsys.readouterr().out @@ -55,6 +55,8 @@ def test_scrubbed_env_allows_named_credentials_without_printing_values(monkeypat assert "fetch private dependency" in output assert "secret-value" not in output assert "other-secret" not in output + assert "ghp_" not in output + assert "nvapi-" not in output def test_copy_workspace_excludes_default_noise_and_keeps_sources(tmp_path): diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 657e2061c..f2a6d4429 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -1,3 +1,4 @@ +import argparse import json import os import runpy @@ -608,3 +609,28 @@ def test_wait_for_url_restricts_host_to_localhost(): with pytest.raises(ValueError, match="URL host must be localhost"): sandboxed_web_e2e.wait_for_url("https://example.com/ready", 10, service) + + +def test_emit_result_redacts_command_metadata(tmp_path, capsys): + """Command fields cannot leak NVIDIA NIM or GitHub token shapes.""" + args = argparse.Namespace( + backend_cmd="start ghp_" + ("A" * 24), + frontend_cmd="start", + e2e_cmd="pytest", + evidence_note="key nvapi-" + ("F" * 24), + allow_env=[], + network="default", + keep_sandbox=False, + ) + sandboxed_web_e2e.emit_result( + args=args, + copied_repo=tmp_path / "repo", + sandbox_root=tmp_path, + backend_ready=True, + frontend_ready=True, + exit_code=0, + elapsed_seconds=0.1, + ) + output = capsys.readouterr().out + assert "ghp_" not in output + assert "nvapi-" not in output From 55cf8ede47dc3aefb8c43160c71ea4c88058989e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:26:50 +0900 Subject: [PATCH 6/6] fix(coverage): accept only bounded relative requirement includes Materialize a base Python lock only when every package line is an exact SHA-256 pin or a two-token relative -r/--requirement include of a candidate lock path. A lone --require-hashes directive, ./dotted paths, and -r other-hashes.txt no longer enter the trusted build context. --- AGENTS.md | 2 +- CHANGELOG.md | 1 + .../sandbox-command-metadata-redaction.md | 3 +- .../materialize_base_python_requirements.py | 82 +++++++++++++++---- ...st_materialize_base_python_requirements.py | 19 ++++- 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdc80cd60..c0be98b36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,4 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. -Sandbox result JSON redacts command argv and `nvapi-` tokens. See [`docs/doctoring/sandbox-command-metadata-redaction.md`](docs/doctoring/sandbox-command-metadata-redaction.md). +Sandbox result JSON redacts command argv and `nvapi-` tokens. Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/sandbox-command-metadata-redaction.md`](docs/doctoring/sandbox-command-metadata-redaction.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 786cbbc73..36e46dbcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Sandboxed verify and web E2E now redact command argv, evidence notes, and NVIDIA `nvapi-` token shapes before printing result JSON, so subprocess metadata cannot leak `NVIDIA_NIM_API_KEY` or GitHub PATs. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/docs/doctoring/sandbox-command-metadata-redaction.md b/docs/doctoring/sandbox-command-metadata-redaction.md index 359fd96f7..6a3ce1e6d 100644 --- a/docs/doctoring/sandbox-command-metadata-redaction.md +++ b/docs/doctoring/sandbox-command-metadata-redaction.md @@ -9,7 +9,8 @@ and also the command argv, `backend_cmd` / `frontend_cmd` / `e2e_cmd`, and `evidence_note` fields before they are printed or JSON-serialized. Provider token shapes include GitHub PATs, Slack, AWS, OpenAI `sk-`, and NVIDIA NIM `nvapi-` (the org `NVIDIA_NIM_API_KEY` form). Operational PII -is not masked. +is not masked. Materialize accepts only exact SHA-256 pins or a bounded +relative `-r` include; a lone `--require-hashes` line is not lock evidence. CWE-532 forbids writing sensitive information to log files (MITRE, n.d.). Command metadata is a log. diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index 98cdad459..9848c3ff6 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -87,6 +87,57 @@ def _is_candidate_lock_name(name: str) -> bool: ) +def _is_candidate_lock_path(path: pathlib.PurePosixPath) -> bool: + """Return whether one safe tracked path can name a pip requirements lock. + + In addition to conventional ``requirements*.txt`` names, repositories often + keep concrete environment closures as direct children such as + ``requirements/ci.txt`` or ``service/requirements/package.txt``. Only direct + ``.txt`` children of a directory named ``requirements`` gain this path-based + eligibility; content must still pass the independent complete hash-pin + validation before it reaches the trusted image build context. + """ + return _is_candidate_lock_name(path.name) or ( + path.suffix == ".txt" and path.parent.name == "requirements" + ) + + +def _is_bounded_requirement_include(line: str) -> bool: + """Return whether one requirements include names a bounded relative file. + + Includes are accepted only as a two-token ``-r``/``--requirement`` form + whose target is itself a candidate lock path written as a normalized + relative POSIX path. Absolute paths, ``.`` or ``..`` components, double + slashes, URLs, option-like targets, shell/Windows path separators, + fragments, queries, extra inline options or hashes, and includes of + non-lock files are rejected before a base-owned file can enter the + trusted build context. + The downstream installer still proves that the candidate is an independently + complete hash closure; this predicate grants syntax eligibility only. + """ + fields = line.split() + if len(fields) != 2 or fields[0] not in {"-r", "--requirement"}: + return False + target = fields[1] + if ( + target.startswith(("-", "~")) + or "\\" in target + or ":" in target + or "?" in target + or "#" in target + ): + return False + include_path = pathlib.PurePosixPath(target) + return ( + bool(include_path.parts) + and target == include_path.as_posix() + and not include_path.is_absolute() + and "." not in include_path.parts + and ".." not in include_path.parts + and _is_candidate_lock_path(include_path) + ) + + def _requirement_lines(content: bytes) -> list[str]: """Return logical requirement lines, joining backslash line-continuations. @@ -107,23 +158,26 @@ def _requirement_lines(content: bytes) -> list[str]: def _is_hash_pinned(content: bytes) -> bool: - """Return whether content carries hash pins and is safe to preflight. - - Discovery is content-based rather than name-based so hash-pinned locks in any - location (a service subdirectory, ``requirements-dev.txt``, - ``requirements-test.txt``) can be considered for offline coverage, while an - unpinned or PR-mutable requirements file is still excluded from the networked - build context. Hash syntax cannot prove that a file includes every transitive - dependency, so the trusted image installer separately preflights every - candidate as an independent ``--require-hashes`` closure. An empty file - carries no installable dependency and is not materialized. + """Return whether content carries only trusted pins or bounded includes. + + Discovery is content-based rather than name-based so exact hash-pinned locks + in service subdirectories and role-specific requirements files can be + considered for offline coverage. Candidate syntax is deliberately stricter + than a substring search: each package line must be an exact ``==`` pin with + one or more complete SHA-256 hashes, or a bounded relative requirements + include. A global ``--require-hashes`` directive is not trust evidence by + itself. The downstream installer separately preflights every candidate as an + independent ``pip --require-hashes`` closure, so syntax eligibility never + substitutes for dependency-closure proof. """ lines = _requirement_lines(content) - if not lines: + requirement_lines = [line for line in lines if line != "--require-hashes"] + if not requirement_lines: return False - return any(line == "--require-hashes" for line in lines) or all( - "--hash=" in line or line.startswith(("-r ", "--requirement ")) - for line in lines + return all( + _is_fully_hash_pinned_requirement(line) + or _is_bounded_requirement_include(line) + for line in requirement_lines ) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 1ab36445c..015527a46 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -157,9 +157,24 @@ def test_lock_name_candidates_are_pip_requirements_files() -> None: def test_hash_pin_detection_includes_pinned_and_excludes_unpinned_or_empty() -> None: """Only fully hash-pinned, non-empty lock content is materialized.""" assert not materializer._is_hash_pinned(b"# comment only\n\n") - assert materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") + assert not materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") assert materializer._is_hash_pinned(b"demo==1 --hash=sha256:" + b"a" * 64 + b"\n") - assert materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert materializer._is_hash_pinned(b"-r requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert not materializer._is_hash_pinned(b"-r ./requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r ../escape.txt\n") + assert materializer._is_bounded_requirement_include( + "--requirement requirements-other.txt" + ) + assert not materializer._is_bounded_requirement_include("-r .") + assert not materializer._is_bounded_requirement_include("-r -evil.txt") + assert not materializer._is_bounded_requirement_include("-r ~evil.txt") + assert not materializer._is_bounded_requirement_include("-r C:foo.txt") + assert not materializer._is_bounded_requirement_include("-r foo?bar.txt") + assert not materializer._is_bounded_requirement_include("-r foo#bar.txt") + assert not materializer._is_bounded_requirement_include(r"-r foo\\bar.txt") + assert not materializer._is_bounded_requirement_include("-r") + assert not materializer._is_bounded_requirement_include("-r /abs/requirements.txt") assert not materializer._is_hash_pinned(b"untrusted==1\n") # uv export / pip-compile multi-line continuation format (spec, then --hash= lines). assert materializer._is_hash_pinned(