Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@
**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.
## 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`.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

<!-- CWL-ENTRY -->
> **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** <https://github.com/orgs/ContextualWisdomLab/projects/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. 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).
21 changes: 21 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ 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.
- 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.
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
22 changes: 22 additions & 0 deletions docs/doctoring/sandbox-command-metadata-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 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. 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.

## 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
82 changes: 68 additions & 14 deletions scripts/ci/materialize_base_python_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
)


Expand Down
6 changes: 5 additions & 1 deletion scripts/ci/redact_sensitive_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -29,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"),
)


Expand All @@ -41,6 +43,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


Expand Down
19 changes: 12 additions & 7 deletions scripts/ci/sandboxed_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -188,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)",
Expand All @@ -211,21 +216,21 @@ 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":
print(f"sandboxed-verify: network={args.network}")
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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if stdout:
print(stdout, end="" if stdout.endswith("\n") else "\n")
if stderr:
Expand Down
23 changes: 14 additions & 9 deletions scripts/ci/sandboxed_web_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,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"
Expand Down Expand Up @@ -121,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:
Expand Down Expand Up @@ -184,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)",
Expand Down Expand Up @@ -232,14 +237,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:
Expand All @@ -253,7 +258,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,
Expand Down
Loading
Loading