From b643d0c7feeef9ba23ba72f799d0e470c10feedd Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:09:02 +0300 Subject: [PATCH] Mask diagnostics before bounds and honor relative launch PATH --- ouroboros/process_interpreters.py | 11 +- ouroboros/tools/process_facts.py | 11 +- ouroboros/tools/shell_process.py | 8 +- ouroboros/tools/verify.py | 6 +- tests/test_node_runtime_resolver.py | 5 +- tests/test_workflow_process_environment.py | 140 +++++++++++++++++++-- 6 files changed, 156 insertions(+), 25 deletions(-) diff --git a/ouroboros/process_interpreters.py b/ouroboros/process_interpreters.py index bc80e2396..5f096815e 100644 --- a/ouroboros/process_interpreters.py +++ b/ouroboros/process_interpreters.py @@ -598,7 +598,7 @@ def resolve_process_node( # idempotent bootstrap; whoever runs first performs the one mutation). The # snapshot is the base of any attested child-env PATH prepend. bootstrap_process_path() - from ouroboros.tools.process_facts import selected_process_environment + from ouroboros.tools.process_facts import process_path_for_cwd, selected_process_environment path_snapshot = str(selected_process_environment().get("PATH", os.environ.get("PATH", "")) or "") constraint = normalize_task_constraint(effective_constraint) @@ -652,12 +652,11 @@ def resolve_process_node( surface = _surface_for(ctx, binding, constraint) probe_token = requested if trigger == "runtime" else "node" - located = shutil.which(probe_token, path=path_snapshot) or "" + located = shutil.which(probe_token, path=process_path_for_cwd(path_snapshot, work_dir)) or "" if located and not os.path.isabs(located): - # A relative PATH entry resolves against the WORKER cwd here but against - # the command's work_dir at exec time: neither health nor brokenness is - # provable from this process, so never substitute on that evidence — - # run as written (argv and child env stay byte-identical) (T10). + # Search entries are bound to the launch cwd above. If a platform still + # cannot establish an absolute candidate, retain the as-written launch + # rather than substituting a runtime on unprovable evidence (T10). trace = _node_trace( tool=name, requested_interpreter=requested, diff --git a/ouroboros/tools/process_facts.py b/ouroboros/tools/process_facts.py index c3c5b6e05..0d0c7a952 100644 --- a/ouroboros/tools/process_facts.py +++ b/ouroboros/tools/process_facts.py @@ -134,6 +134,14 @@ def selected_process_environment(): return prepared[0] if prepared is not None else {} +def process_path_for_cwd(path, cwd): + """Resolve PATH search entries against the child cwd, preserving lexical paths.""" + return os.pathsep.join( + str(pathlib.Path(part) if pathlib.Path(part).is_absolute() else pathlib.Path(cwd) / part) + for part in path.split(os.pathsep) + ) + + def record_runtime_selection(ctx, argv, cwd, environment): """Non-executing provenance over the actual launch environment; never rewrite argv.""" from ouroboros.workspace_executor import executor_ref_from_ctx, map_host_path @@ -168,8 +176,7 @@ def record_runtime_selection(ctx, argv, cwd, environment): if candidate.is_file() and os.access(candidate, os.X_OK): selected = os.path.abspath(candidate) # lexical venv path, not realpath else: - path = os.pathsep.join(str(pathlib.Path(part) if pathlib.Path(part).is_absolute() else pathlib.Path(cwd) / part) - for part in environment.get("PATH", os.defpath).split(os.pathsep)) + path = process_path_for_cwd(environment.get("PATH", os.defpath), cwd) selected = shutil.which(spelling, path=path) or "" source = "PATH" if not trace or not trace.changed else source if not selected: diff --git a/ouroboros/tools/shell_process.py b/ouroboros/tools/shell_process.py index 53fbaad62..5d71181f6 100644 --- a/ouroboros/tools/shell_process.py +++ b/ouroboros/tools/shell_process.py @@ -167,9 +167,11 @@ from ouroboros.tools.process_facts import describe_returncode as _describe_retur def _format_process_output(stdout: str, stderr: str, *, limit: int = 50_000) -> str: - """Render bounded stdout/stderr sections.""" - stdout_text = str(stdout or "") - stderr_text = str(stderr or "") + """Mask diagnostic copies before bounds can split a selected secret.""" + from ouroboros.tools.process_facts import redact_process_data + + stdout_text = redact_process_data(str(stdout or "")) + stderr_text = redact_process_data(str(stderr or "")) parts: List[str] = [] if stdout_text.strip(): parts.append(f"STDOUT:\n{stdout_text}") diff --git a/ouroboros/tools/verify.py b/ouroboros/tools/verify.py index d35ac162e..5c3112058 100644 --- a/ouroboros/tools/verify.py +++ b/ouroboros/tools/verify.py @@ -64,7 +64,9 @@ _RECEIPT_DECLARED_SUMMARY_CAP = 1000 def _bounded(text: Any, cap: int) -> str: - t = str(text or "").strip() + # Raw output remains available to expected-match evaluation. Only this + # diagnostic copy is masked, before a bound could expose a secret fragment. + t = redact_process_data(str(text or "")).strip() if len(t) <= cap: return t return t[:cap] + f"\n…[truncated {len(t) - cap} of {len(t)} chars]" @@ -325,7 +327,7 @@ def _compare_files_bytes_equal( rc = int(rc_raw) except (TypeError, ValueError): return False, f"bytes_equal: executor cmp returned no exit status for {a_raw} vs {b_raw}" - out = ((res.stdout or "") + ("\n" + res.stderr if res.stderr else "")).strip() + out = redact_process_data((res.stdout or "") + ("\n" + res.stderr if res.stderr else "")).strip() if rc == 0: return True, f"bytes_equal: {a_raw} == {b_raw} (executor cmp)" if rc > 1: diff --git a/tests/test_node_runtime_resolver.py b/tests/test_node_runtime_resolver.py index 140f8fbe9..6059aace1 100644 --- a/tests/test_node_runtime_resolver.py +++ b/tests/test_node_runtime_resolver.py @@ -877,9 +877,8 @@ def test_whitespace_padded_head_is_not_classified(tmp_path, monkeypatch, quiet_b def test_relative_path_which_result_is_a_noop(tmp_path, monkeypatch, quiet_bootstrap): - """T10 pin: a which() hit through a RELATIVE PATH entry is unprovable from - the worker process (exec resolves it against the command cwd instead), so - the resolver must run as written — never substitute bundled node.""" + """T10 pin: if which() still returns a relative candidate after binding + PATH to the launch cwd, keep the unprovable launch as written.""" bundled = _healthy_stub(tmp_path / "bundle" / "node-standalone" / "bin" / "node") monkeypatch.setattr(resolver, "resolve_bundled_node", lambda: str(bundled)) monkeypatch.setattr(resolver.shutil, "which", lambda tok, path=None: "bin/node") diff --git a/tests/test_workflow_process_environment.py b/tests/test_workflow_process_environment.py index 8523f2e54..5ace64b1a 100644 --- a/tests/test_workflow_process_environment.py +++ b/tests/test_workflow_process_environment.py @@ -73,20 +73,108 @@ def test_verify_matches_raw_selected_value_before_receipt_redaction(process_cont assert receipt["expected"] == "***" and secret not in result.text +@pytest.mark.parametrize("tool,padding,suffix", [ + ("run_command", 24982, 60000), + ("run_command", 60000, 24982), + ("run_script", 24982, 60000), + ("run_script", 60000, 24982), + ("verify_and_record", 3990, 60000), + ("verify_and_record", 19990, 60000), +]) +@pytest.mark.parametrize("returncode,stream", [(0, "stdout"), (7, "stderr")]) +def test_selected_secret_is_masked_before_diagnostic_bounds( + process_context, monkeypatch, tool, padding, suffix, returncode, stream, +): + from ouroboros.outcomes import verification_receipts_path + + registry, ctx, workspace, data = process_context + secret = "UNIQUE_CONFIDENTIAL_FRAGMENT_0123456789_END" + monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret}) + program = ( + "import os,sys; " + f"print('x'*{padding}+os.environ['TOKEN']+'y'*{suffix}, file=sys.{stream}); " + f"sys.exit({returncode})" + ) + args = {"cwd": str(workspace), "env_from_settings": {"TOKEN": "CUSTOM_KEY"}} + if tool == "run_script": + args.update(script=program, interpreter=sys.executable) + elif tool == "verify_and_record": + args.update(contract_kind="explicit_command", check=[sys.executable, "-c", program], expected=secret) + else: + args.update(cmd=[sys.executable, "-c", program]) + result = registry.execute_result(tool, args) + rendered = result.text + assert "truncated" in rendered + if tool == "verify_and_record": + receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1]) + assert receipt["returncode"] == returncode and receipt["matched"] is True + assert receipt["status"] == ("fail" if returncode else "pass") + assert len(receipt["summary"]) < 20100 + assert len(rendered) < 6000 + rendered += json.dumps(receipt) + else: + assert result.meta["exit_code"] == returncode + assert result.status == ("error" if returncode else "ok") + assert len(rendered) < 52000 + assert "yyyyyyyyyy" in rendered, "the bounded tail remains available" + assert "xxxxxxxxxx" in rendered, "ordinary output remains available" + assert secret[:10] not in rendered and secret[-10:] not in rendered + + +@pytest.mark.parametrize("match_mode", ["exact", "exact_line", "json_equals"]) +@pytest.mark.parametrize("matches", [True, False]) +def test_verify_secret_masking_preserves_strict_expected_match( + process_context, monkeypatch, match_mode, matches, +): + from ouroboros.outcomes import verification_receipts_path + + registry, ctx, workspace, data = process_context + secret = "synthetic-exact-output-secret" + monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret}) + output = "json.dumps(os.environ['TOKEN'])" if match_mode == "json_equals" else "os.environ['TOKEN']" + expected = secret if matches else "wrong-value" + if match_mode == "json_equals": + expected = json.dumps(expected) + result = registry.execute_result("verify_and_record", { + "contract_kind": "explicit_command", "cwd": str(workspace), + "check": [sys.executable, "-c", f"import os,json; print({output})"], + "expected": expected, "expected_match": match_mode, + "env_from_settings": {"TOKEN": "CUSTOM_KEY"}, + }) + receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1]) + assert receipt["matched"] is matches and receipt["returncode"] == 0 + assert receipt["status"] == ("pass" if matches else "fail") + assert secret not in result.text + json.dumps(receipt) + assert "***" in result.text and "***" in receipt["summary"] + + @pytest.mark.parametrize("failure", ["nonzero", "spawn", "timeout"]) -def test_selected_secret_does_not_escape_failed_process(process_context, monkeypatch, failure): - registry, _ctx, workspace, _data = process_context +@pytest.mark.parametrize("tool", ["run_command", "run_script", "verify_and_record"]) +def test_selected_secret_does_not_escape_failed_process(process_context, monkeypatch, failure, tool): + from ouroboros.outcomes import verification_receipts_path + + registry, ctx, workspace, data = process_context secret = "synthetic-failed-process-secret" monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"CUSTOM_KEY": secret}) - if failure == "spawn": - cmd = [str(workspace / "absent")] + interpreter = str(workspace / secret) if failure == "spawn" else sys.executable + program = "import os,sys,time; print(os.environ['TOKEN'],flush=True); " + ("sys.exit(7)" if failure == "nonzero" else "time.sleep(5)") + args = {"cwd": str(workspace), "timeout_sec": 1, "env_from_settings": {"TOKEN": "CUSTOM_KEY"}} + if tool == "run_script": + args.update(script=program, interpreter=interpreter) + elif tool == "verify_and_record": + args.update(contract_kind="explicit_command", check=[interpreter, "-c", program]) else: - cmd = [sys.executable, "-c", "import os,sys,time; print(os.environ['TOKEN'],flush=True); " + ("sys.exit(7)" if failure == "nonzero" else "time.sleep(5)")] - result = registry.execute_result("run_command", {"cmd": cmd, "cwd": str(workspace), - "timeout_sec": 1, "env_from_settings": {"TOKEN": "CUSTOM_KEY"}}) + args.update(cmd=[interpreter, "-c", program]) + result = registry.execute_result(tool, args) assert secret not in result.text - assert result.status != "ok" - if failure == "nonzero": + if tool == "verify_and_record" and failure != "spawn": + receipt = json.loads(verification_receipts_path(data, ctx.task_id).read_text(encoding="utf-8").splitlines()[-1]) + assert receipt["status"] == "fail" + assert receipt["returncode"] == (7 if failure == "nonzero" else None) + assert secret not in json.dumps(receipt) + else: + assert result.status != "ok" + if failure == "nonzero" and tool != "verify_and_record": assert result.meta["exit_code"] == 7 and "***" in result.text @@ -131,6 +219,40 @@ def test_runtime_uses_explicit_child_path_not_host_path(process_context, monkeyp assert str(binary) in result.text and '"source": "PATH"' in result.text +@pytest.mark.skipif(os.name == "nt", reason="POSIX executable shims; native Windows execution is not exercised") +@pytest.mark.parametrize("backend", ["host", "local"]) +@pytest.mark.parametrize("relative_dir", ["relative-node-bin", ""]) +def test_relative_selected_path_reaches_workspace_node(process_context, monkeypatch, backend, relative_dir): + from ouroboros import process_interpreters + + registry, ctx, workspace, data = process_context + if backend == "local": + ctx.executor_ref = {"type": "local", "workspace_host_path": str(workspace), "workspace_backend_path": "/workspace"} + selected_dir = workspace / relative_dir + selected_dir.mkdir(exist_ok=True) + bundled_dir = data / "bundle" + bundled_dir.mkdir() + for directory, label in ((selected_dir, "chosen-relative-node"), (bundled_dir, "incorrect-bundled-node")): + binary = directory / "node" + binary.write_text( + '#!/bin/sh\nif [ "$1" = "--version" ]; then echo v22.1.0; else echo ' + label + '; fi\n', + encoding="utf-8", + ) + binary.chmod(0o755) + selected_path = os.pathsep.join((relative_dir, "/usr/bin", "/bin")) + monkeypatch.setattr("ouroboros.config.load_settings", lambda: {"OPENAI_BASE_URL": selected_path}) + monkeypatch.setattr(process_interpreters, "resolve_bundled_node", lambda: str(bundled_dir / "node")) + result = registry.execute_result("run_command", {"cmd": ["node", "-e", "unused"], "cwd": str(workspace), + "env_from_settings": {"PATH": "OPENAI_BASE_URL"}}) + assert result.status == "ok" and result.meta["exit_code"] == 0, result.text + assert "chosen-relative-node" in result.text and "incorrect-bundled-node" not in result.text + events = [json.loads(line) for line in (data / "logs/events.jsonl").read_text(encoding="utf-8").splitlines()] + trace = next(event for event in reversed(events) if event.get("type") == "node_runtime_resolution") + assert trace["requested_interpreter"] == trace["resolved_interpreter"] == "node" + assert trace["runtime_path"] == str(selected_dir / "node") + assert trace["path_snapshot"] == selected_path and not trace["env_path_prepend"] + + def test_unknown_wrapper_provenance_never_invents_a_python_path(process_context): registry, _ctx, workspace, _data = process_context if os.name == "nt":