diff --git a/docs/decisions/0004-offline-python-ast-regression-tooling.md b/docs/decisions/0004-offline-python-ast-regression-tooling.md new file mode 100644 index 00000000..a7294bd5 --- /dev/null +++ b/docs/decisions/0004-offline-python-ast-regression-tooling.md @@ -0,0 +1,76 @@ +# ADR 0004: Narrow Python exception for offline Python-AST evidence tests + +Status: accepted for the issue #79 evidence-tooling scope upon merge of PR #103; +the candidate remains subject to independent review and exact-head Codex review. + +## Context and evidence + +[ADR 0001](0001-language-and-boundaries.md) selects Go for the CLI, background +service, scheduler and state reconciliation. That product decision is unchanged. +The [G01 evidence packet](../evidence/g01-recovery-packet.md) already contains a +Python AST-based audit of Python and shell prescriptions. Issue #79 needs tests +of that existing implementation, including Python loop/comprehension targets, +call aliases and definition-time expressions. + +At candidate `387a647355e48d44333954fadf48d5b02335290c`, the standard-library +Python harness reproduced the seven recorded finding classes and passed eight +focused cases after their initial corrections. Independent review then found +additional reader/iterator gaps; [Codex review of that exact candidate](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5325780932) +also identified loader and language-policy gaps. Those findings remain blockers +until their correction or evidence-based disposition is reviewed; the eight-test +result is not comprehensive safety proof. The local interpreter used for this +evidence is CPython 3.14.3; no other interpreter/platform coverage is implied. + +The harness uses Python's standard-library `ast`, `unittest` and local Git +fixtures, without third-party Python packages. Implementing a separate Python +parser in Go would duplicate the language semantics under test or introduce a +parser dependency. A Go wrapper that invokes the same Python checks would not +remove the interpreter dependency. No comparative maintenance or performance +benchmark has been run; this decision rests on testing the existing AST audit +directly and keeping the exception bounded. + +## Decision + +Permit Python only for +`scripts/evidence_packet/issue79_regression_test.py` and its offline regression +fixtures for the existing packet audit. This is not permission to implement +product behavior or general repository tooling in Python. Any broader use needs +a separately reviewed decision. The CLI, daemon and production adapters remain +Go; no Python interpreter or package is bundled into release artifacts. + +The harness must use only the standard library and explicitly selected local +Git fixture operations. Invoke it with `python3 -I -B`: isolated mode ignores +the current directory, `PYTHONPATH` and user-site imports, while `-B` avoids +bytecode artifacts. Record the actual interpreter and test results. Adding +dependencies, automatic hosted execution, or a broader supported interpreter +matrix requires separate review; this ADR does not claim those checks have run. + +## Trust and execution boundaries + +- Python/shell regression specimens remain data for AST/token inspection; they + must not be evaluated or launched. +- The scanner functions are the reviewed implementation under test, not + untrusted executable test data. This harness is not a Python sandbox or a + replacement for source review and runner trust policy. +- Loading packet definitions must reject unreviewed imports, decorators and + non-reviewed definition-time expressions before evaluation. A modified packet + must not acquire an import/decorator/default-expression execution path merely + because it contains a matching scanner fence. +- Local Git fixtures use task-owned temporary repositories and synthetic values. + The harness must not contact GitHub, dispatch workflows, access credentials, + or operate existing runners, Docker, Keychain or launchd. +- Repository/PR review, exact-head identity and live-operation authorization + gates still apply. Neither this exception nor green synthetic tests complete + G01/G02 or establish hostile-code isolation. + +## Consequences and rollback + +Maintainers now have one explicitly scoped interpreter-dependent evidence test. +Its manual focused results must be reported separately from Go/hosted checks; +passing Public CI does not imply this Python harness ran. Reconsider the exception +if the packet audit is extracted or replaced by a reviewed implementation with +equivalent regression coverage. + +Rollback is a reviewed revert of the harness and this exception, retaining Go +product code and unrelated evidence. Do not remove an existing safety check +without an explicit replacement or a documented reopening of the affected gate. diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a6408ed4..9ef833ff 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -5825,7 +5825,13 @@ from pathlib import Path git_query_deadline_seconds = 30 git_query_termination_grace_seconds = 5 git_query_output_max_bytes = 64 * 1024 +git_query_packet_blob_output_max_bytes = 8 * 1024 * 1024 git_query_stream_chunk_bytes = 4096 +issue79_reviewed_evidence_paths = ( + "docs/evidence/g01-recovery-packet.md", + "scripts/evidence_packet/issue79_regression_test.py", + "docs/decisions/0004-offline-python-ast-regression-tooling.md", +) def close_git_query_streams(process): @@ -5962,7 +5968,19 @@ def capture_git_query_output(process, git_command, output_limit, input_bytes=Non selector.close() -def run_bounded_git_query(command, *, cwd, env, input_bytes=None): +def run_bounded_git_query( + command, + *, + cwd, + env, + input_bytes=None, + output_limit=git_query_output_max_bytes, +): + if output_limit not in { + git_query_output_max_bytes, + git_query_packet_blob_output_max_bytes, + }: + raise SystemExit("Git query output budget was not reviewed") process = None try: process = subprocess.Popen( @@ -5976,7 +5994,7 @@ def run_bounded_git_query(command, *, cwd, env, input_bytes=None): ) try: stdout, stderr = capture_git_query_output( - process, command, git_query_output_max_bytes, input_bytes + process, command, output_limit, input_bytes ) except subprocess.TimeoutExpired: terminate_git_query_group(process) @@ -6000,6 +6018,7 @@ def run_bounded_git_query(command, *, cwd, env, input_bytes=None): def git_query(arguments): return [ "git", + "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", @@ -6009,6 +6028,56 @@ def git_query(arguments): ] +def run_bounded_git_packet_blob_query(blob_spec, *, cwd, env): + """Capture only a reviewed issue #79 evidence source under its separate cap.""" + expected_paths = { + "docs/evidence/g01-recovery-packet.md", + "scripts/evidence_packet/issue79_regression_test.py", + "docs/decisions/0004-offline-python-ast-regression-tooling.md", + } + if not isinstance(blob_spec, str) or ":" not in blob_spec: + raise SystemExit("Git evidence blob revision/path was malformed") + revision, path = blob_spec.split(":", 1) + if path not in expected_paths or not ( + revision == "HEAD" + or ( + len(revision) in {40, 64} + and all(character in "0123456789abcdefABCDEF" for character in revision) + ) + ): + raise SystemExit("Git evidence blob query was outside the reviewed revision/path") + command = git_query(["show", blob_spec]) + return run_bounded_git_query( + command, + cwd=cwd, + env=env, + output_limit=git_query_packet_blob_output_max_bytes, + ) + + +def require_packet_head_parity(intent_output, head_blob, worktree_bytes): + """Bind a reviewed issue #79 evidence path and bytes to its HEAD blob.""" + expected_paths = { + b"docs/evidence/g01-recovery-packet.md", + b"scripts/evidence_packet/issue79_regression_test.py", + b"docs/decisions/0004-offline-python-ast-regression-tooling.md", + } + if not isinstance(intent_output, bytes): + raise SystemExit("post-correction evidence intent output was not bytes") + records = intent_output.split(b"\0") + if records[-1] != b"" or len(records) != 2: + raise SystemExit("post-correction evidence intent output was malformed") + record = records[0] + if len(record) < 3 or record[1:2] != b" " or record[2:] not in expected_paths: + raise SystemExit("post-correction evidence index entry was missing or malformed") + if record[:1] in {b"S", b"s", b"h"}: + raise SystemExit("post-correction evidence has skip-worktree or assume-unchanged intent") + if not isinstance(head_blob, bytes) or not isinstance(worktree_bytes, bytes): + raise SystemExit("post-correction evidence byte comparison was not binary") + if head_blob != worktree_bytes: + raise SystemExit("post-correction evidence bytes differ from the current HEAD blob") + + git_transport_override_names = { "GIT_EXEC_PATH", "GIT_SSH", @@ -6064,11 +6133,13 @@ if git_http_tls_environment_overrides: "before authenticated remote parity: " + ", ".join(git_http_tls_environment_overrides) ) +git_child_environment_names = ("PATH", "LANG", "LC_ALL") +if "PATH" not in os.environ or not os.environ["PATH"]: + raise SystemExit("post-correction reviewed PATH is missing") git_environment = { - key: value - for key, value in os.environ.items() - if key not in git_environment_override_names - and not key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")) + key: os.environ[key] + for key in git_child_environment_names + if key in os.environ } git_environment.update( { @@ -6078,6 +6149,30 @@ git_environment.update( } ) +def require_git_invocation_root(invocation_root, env): + """Refuse status evidence if local Git config redirects the worktree.""" + expected_root = Path(invocation_root).resolve() + result = run_bounded_git_query( + git_query(["rev-parse", "--show-toplevel"]), + cwd=expected_root, + env=env, + ) + if result.returncode != 0 or result.stderr: + raise SystemExit("post-correction Git invocation-root query failed") + try: + root_output = result.stdout.decode("utf-8") + if not root_output.endswith("\n") or root_output.count("\n") != 1: + raise ValueError("Git top-level output was not one line") + actual_root = Path(root_output[:-1]).resolve() + except (OSError, RuntimeError, UnicodeDecodeError, ValueError): + raise SystemExit("post-correction Git invocation-root output was malformed") + if actual_root != expected_root: + raise SystemExit("post-correction Git top-level does not match invocation root") + return expected_root + +invocation_root = Path.cwd().resolve() +require_git_invocation_root(invocation_root, git_environment) + expected_origin_url = "https://github.com/1XP-AI/gh-runnerd.git" origin_result = run_bounded_git_query( git_query(["config", "--local", "--get-all", "remote.origin.url"]), @@ -6151,13 +6246,6 @@ if paths: _path, attribute, value = fields[index:index + 3] if attribute != b"filter" or value != b"unspecified": raise SystemExit("post-correction active Git filter attribute is not allowed") -status = run_bounded_git_query( - git_query(["status", "--porcelain=v1", "--untracked-files=all"]), - cwd=Path.cwd(), - env=git_environment, -) -if status.returncode != 0 or status.stderr or status.stdout.decode("utf-8").strip(): - raise SystemExit("post-correction worktree is not clean") local_result = run_bounded_git_query( git_query(["rev-parse", "HEAD"]), cwd=Path.cwd(), @@ -6166,6 +6254,50 @@ local_result = run_bounded_git_query( if local_result.returncode != 0 or local_result.stderr: raise SystemExit("post-correction local head query failed") local = local_result.stdout.decode("utf-8").strip() +for reviewed_path in issue79_reviewed_evidence_paths: + intent_result = run_bounded_git_query( + git_query(["ls-files", "-v", "-z", "--", reviewed_path]), + cwd=Path.cwd(), + env=git_environment, + ) + if intent_result.returncode != 0 or intent_result.stderr: + raise SystemExit("post-correction evidence intent-bit query failed") + reviewed_blob_result = run_bounded_git_packet_blob_query( + f"{local}:{reviewed_path}", + cwd=Path.cwd(), + env=git_environment, + ) + if reviewed_blob_result.returncode != 0 or reviewed_blob_result.stderr: + raise SystemExit("post-correction evidence HEAD blob query failed") + try: + if reviewed_path == "docs/evidence/g01-recovery-packet.md": + reviewed_worktree_bytes = Path( + "docs/evidence/g01-recovery-packet.md" + ).read_bytes() + elif reviewed_path == "scripts/evidence_packet/issue79_regression_test.py": + reviewed_worktree_bytes = Path( + "scripts/evidence_packet/issue79_regression_test.py" + ).read_bytes() + elif reviewed_path == "docs/decisions/0004-offline-python-ast-regression-tooling.md": + reviewed_worktree_bytes = Path( + "docs/decisions/0004-offline-python-ast-regression-tooling.md" + ).read_bytes() + else: + raise SystemExit("post-correction evidence path was not reviewed") + except OSError: + raise SystemExit("post-correction evidence worktree bytes could not be read") + require_packet_head_parity( + intent_result.stdout, + reviewed_blob_result.stdout, + reviewed_worktree_bytes, + ) +status = run_bounded_git_query( + git_query(["status", "--porcelain=v1", "--untracked-files=all"]), + cwd=Path.cwd(), + env=git_environment, +) +if status.returncode != 0 or status.stderr or status.stdout.decode("utf-8").strip(): + raise SystemExit("post-correction worktree is not clean") remote_environment = { key: value for key, value in git_environment.items() @@ -7191,6 +7323,7 @@ loader_assignment_names = { reviewed_shell_path = "/opt/homebrew/bin:/usr/bin:/bin" shell_owned_path_variables = set() shell_pending_owned_bindings = set() +shell_sensitive_variable_names = set() def reviewed_loader_assignment(token): @@ -7243,20 +7376,54 @@ def credential_environment_name(name): ) shell_parameter = re.compile( - r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[^}]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)" + r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[^}]*(?:\}|$))|\$([A-Za-z_][A-Za-z0-9_]*)" ) def shell_sensitive_parameter_violation(tokens): """Reject credential-bearing shell parameter expansions in any argument.""" for token in tokens: + if "${!" in token: + return "indirect shell parameter expansion is not allowed" + if re.search(r"\$\{[^}]*@P\}", token): + return "Bash prompt-expansion transformation is not allowed" for match in shell_parameter.finditer(token): name = match.group(1) or match.group(2) - if credential_environment_name(name): + if ( + credential_environment_name(name) + or name in shell_sensitive_variable_names + ): return ( "credential-bearing shell parameter expansion is not allowed" ) return None +def shell_record_sensitive_assignments(tokens, preserve_existing=False): + """Track credential aliases across assignment-only shell commands.""" + for token in tokens: + if not assignment.fullmatch(token): + continue + name, value = token.split("=", 1) + sensitive = any( + credential_environment_name(match.group(1) or match.group(2)) + or (match.group(1) or match.group(2)) + in shell_sensitive_variable_names + for match in shell_parameter.finditer(value) + ) + if sensitive: + shell_sensitive_variable_names.add(name) + elif not preserve_existing: + shell_sensitive_variable_names.discard(name) + +def shell_git_config_assignment_violation(tokens): + """Keep the reviewed Git child configuration intact across a shell fence.""" + for token in tokens: + if not assignment.fullmatch(token): + continue + name = token.split("=", 1)[0] + if name.startswith("GIT_CONFIG_") and token not in reviewed_shell_export_assignments: + return "unreviewed Git configuration environment assignment" + return None + def fence_details(line): """Normalize Markdown container prefixes before recognizing a fence.""" candidate = line @@ -7389,6 +7556,7 @@ def reviewed_shell_preflight(stripped): def shell_commands(markdown): shell_owned_path_variables.clear() shell_pending_owned_bindings.clear() + shell_sensitive_variable_names.clear() in_shell = False shell_fence = None shell_fence_prefix = "" @@ -7413,6 +7581,7 @@ def shell_commands(markdown): in_shell = True shell_fence = marker shell_fence_prefix = fence_container_prefix(line) + shell_sensitive_variable_names.clear() continue if not in_shell: continue @@ -7432,6 +7601,17 @@ def shell_commands(markdown): command = " ".join(pending) if shell_quote_pending(command): continue + segments, operators = shell_segments_with_operators(command) + for index, segment in enumerate(segments): + previous_operator = operators[index - 1] if index else None + next_operator = operators[index] if index < len(operators) else None + preserve_existing = previous_operator in {"&&", "||", "|", "&"} or ( + index == 0 and next_operator == "|" + ) + if segment and all(assignment.fullmatch(token) for token in segment): + shell_record_sensitive_assignments( + segment, preserve_existing=preserve_existing + ) unsafe_heredocs = non_python_heredoc_delimiters(command) if unsafe_heredocs: raise SystemExit( @@ -7442,10 +7622,20 @@ def shell_commands(markdown): shell_owned_directory_proof(command) if shell_assignment_only(command): if ( + not any( + shell_git_config_assignment_violation(segment) + for segment in shell_token_segments(command) + ) + and not any( shell_command_substitution(segment) for segment in shell_token_segments(command) ) + and not any( + "${!" in token + for segment in shell_token_segments(command) + for token in segment + ) and not shell_process_substitution(command) ): pending = [] @@ -7542,19 +7732,25 @@ def python_heredoc_bodies(markdown): + ": unterminated heredoc body" ) -def shell_token_segments(command): +def shell_segments_with_operators(command): try: lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|") lexer.whitespace_split = True tokens = list(lexer) except ValueError: - return [] + return [], [] segments = [[]] + operators = [] for token in tokens: if token in {";", "&&", "||", "|", "&"}: + operators.append(token) segments.append([]) else: segments[-1].append(token) + return segments, operators + +def shell_token_segments(command): + segments, _operators = shell_segments_with_operators(command) return [segment for segment in segments if segment] def shell_assignment_only(command): @@ -7712,9 +7908,7 @@ def shell_reviewed_reader_path(token): return False if token.startswith(reviewed_reader_path_prefixes): return True - if token.startswith(".") and token not in {".", "./"}: - return False - return True + return False def shell_mkdir_violation(tokens): @@ -7855,7 +8049,7 @@ def shell_trap_violation(tokens, depth=0): for segment in segments: if reviewed_shell_cleanup(segment): continue - violation = forbidden_command(segment, depth + 1) + violation = forbidden_shell_command(segment, depth + 1) if violation: return f"trap handler -> {violation}" return None @@ -7926,6 +8120,8 @@ def git_config_delegation(assignment, *, config_env=False): else: key, value = assignment.split("=", 1) key = key.lower() + if key == "core.worktree": + return "Git core.worktree redirection is not allowed" if key == "core.pager" or key == "pager" or key.startswith("pager."): return "Git pager command delegation is not allowed" if key in {"core.sshcommand", "credential.helper"}: @@ -8025,11 +8221,14 @@ git_read_only_subcommands = { git_config_read_only_options = { "--get", "--get-all", - "--get-regexp", "--get-urlmatch", - "--list", - "-l", - "--name-only", + "--get-regexp", +} +git_config_bounded_regexp_queries = {r"^filter\."} +git_config_reviewed_query_keys = { + "--get": {"core.repositoryformatversion"}, + "--get-all": {"remote.origin.url"}, + "--get-urlmatch": {"http.sslverify"}, } git_config_mutating_options = { "--add", @@ -8047,8 +8246,8 @@ git_config_mutating_options = { } -def git_subcommand(tokens): - """Return the first Git subcommand after global/config options.""" +def git_subcommand_index(tokens): + """Return the first Git subcommand index after global/config options.""" index = 1 while index < len(tokens): token = tokens[index] @@ -8069,9 +8268,28 @@ def git_subcommand(tokens): if token.startswith("-"): index += 1 continue - return executable_basename(token) + return index return None +def git_subcommand(tokens): + """Return the first Git subcommand after global/config options.""" + index = git_subcommand_index(tokens) + return executable_basename(tokens[index]) if index is not None else None + + +def git_config_include_key(key): + """Recognize include directives that can load executable Git config.""" + normalized = key.casefold() + return normalized == "include.path" or re.fullmatch( + r"includeif\..+\.path", normalized + ) is not None + + +def git_config_include_option(token): + """Recognize full and abbreviated positive --includes config options.""" + option = token.split("=", 1)[0].lower() + return len(option) > 2 and "--includes".startswith(option) + def git_filter_attribute_violation(tokens): """Reject Git filters and external attribute configuration before reads.""" @@ -8082,6 +8300,8 @@ def git_filter_attribute_violation(tokens): def config_violation(assignment): key = assignment.split("=", 1)[0].lower() + if git_config_include_key(key): + return "Git configuration includes are not allowed before read-only commands" if key.startswith("filter.") or key in forbidden_keys: return "Git filter/external-attributes configuration is not allowed" return None @@ -8120,11 +8340,20 @@ def git_filter_attribute_violation(tokens): for token in tokens ): return "Git filter/external-attributes configuration is not allowed" + if subcommand == "config" and any( + git_config_include_option(token) for token in tokens[1:] + ): + return "Git configuration includes are not allowed before read-only commands" + if subcommand == "config" and any( + git_config_include_key(token.split("=", 1)[0]) + for token in tokens[1:] + ): + return "Git configuration includes are not allowed before read-only commands" return None def git_diff_path_violation(tokens): - """Require reviewed paths for Git diff --no-index and --output operands.""" + """Require reviewed --no-index inputs and reject Git diff file output.""" if not tokens or executable_basename(tokens[0]) != "git": return None index = 1 @@ -8150,7 +8379,6 @@ def git_diff_path_violation(tokens): return None arguments = tokens[index + 1:] no_index = False - output_paths = [] path_operands = [] after_separator = False position = 0 @@ -8162,13 +8390,8 @@ def git_diff_path_violation(tokens): break if token == "--no-index": no_index = True - elif token == "--output": - if position + 1 >= len(arguments): - return "Git diff --output requires a reviewed path" - output_paths.append(arguments[position + 1]) - position += 1 - elif token.startswith("--output="): - output_paths.append(token.split("=", 1)[1]) + elif token == "--output" or token.startswith("--output="): + return "Git diff --output can overwrite files and is not allowed" elif token.startswith("-"): pass elif not after_separator: @@ -8176,7 +8399,7 @@ def git_diff_path_violation(tokens): position += 1 if no_index and len(path_operands) < 2: return "Git diff --no-index requires two reviewed paths" - paths = output_paths + (path_operands if no_index else []) + paths = path_operands if no_index else [] for path in paths: if path == "__g01_reviewed_dynamic_path__": continue @@ -8190,6 +8413,11 @@ def git_read_only_violation(tokens): if not tokens or executable_basename(tokens[0]) != "git": return None subcommand = git_subcommand(tokens) + if subcommand == "show" and any( + token == "--output" or token.startswith("--output=") + for token in tokens[1:] + ): + return "Git show --output can overwrite files and is not allowed" diff_path_violation = git_diff_path_violation(tokens) if diff_path_violation: return diff_path_violation @@ -8225,17 +8453,59 @@ def git_read_only_violation(tokens): if tokens[index:] != ["ls-remote", "."]: return "Git ls-remote is restricted to the literal local repository form" if subcommand == "config": - options = tokens[1:] + config_index = git_subcommand_index(tokens) + options = tokens[config_index + 1:] if config_index is not None else [] + if any( + option in {"--global", "-g", "--system", "-s", "--worktree", "--show-origin", "--show-scope"} + for option in options + ): + return "Git config query scope/origin is not allowed" if any( option == mutating or option.startswith(mutating + "=") for option in options for mutating in git_config_mutating_options ): return "Git config mutation is not allowed" - if not any(option in git_config_read_only_options for option in options): + if options.count("--local") > 1: + return "Git config query scope is duplicated" + query = [option for option in options if option != "--local"] + if not query or query[0] not in git_config_read_only_options: + return "Git config query must use an approved read-only option" + query_option = query[0] + operands = query[1:] + if query_option == "--get-regexp": + if ( + "--local" not in options + or len(operands) != 1 + or operands[0] not in git_config_bounded_regexp_queries + ): + return "Git config regex query is not a bounded local query" + elif query_option in git_config_reviewed_query_keys: + expected_operands = 1 if query_option in {"--get", "--get-all"} else 2 + if ( + len(operands) != expected_operands + or operands[0] not in git_config_reviewed_query_keys[query_option] + ): + return "Git config query key is not in the reviewed allowlist" + if query_option == "--get-urlmatch" and not operands[1].startswith("https://"): + return "Git config URL-match query requires a literal HTTPS URL" + else: return "Git config query must use an approved read-only option" return None +def git_sensitive_shell_output_violation(tokens): + """Do not print the raw local origin URL from an executable shell query.""" + if not tokens or executable_basename(tokens[0]) != "git": + return None + config_index = git_subcommand_index(tokens) + if config_index is None or executable_basename(tokens[config_index]) != "config": + return None + options = tokens[config_index + 1:] + query = [option for option in options if option != "--local"] + if query == ["--get-all", "remote.origin.url"]: + return "Git remote.origin.url output is not allowed in an executable shell" + return None + unsupported_shell_compound_words = { "case", "esac", "function", "select", "coproc", "for", "while", "until", @@ -8543,6 +8813,8 @@ def awk_command_violation(tokens): or token.startswith("--exec=") or (token.startswith("--exec") and token != "--exec") or re.search(r"\bsystem\s*\(", token) + or re.search(r"\bENVIRON\b", token) + or re.search(r"\bARGV\b", token) or "getline" in token or output_pipe(token) or output_redirection(token) @@ -8551,6 +8823,23 @@ def awk_command_violation(tokens): return None +def jq_command_violation(tokens): + """Reject jq filters that read inherited environment values.""" + if not tokens or executable_basename(tokens[0]).casefold() != "jq": + return None + for token in tokens[1:]: + if token == "-L" or token.startswith("-L") and len(token) > 2: + return "jq external module search paths are not allowed" + if re.search(r"\b(?:include|import)\b", token): + return "jq external module loading is not allowed" + if re.search( + r"(?", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>", "<", "0<"}: + if token in {"<<<", "<", "0<"}: + reviewed_operand = True + position += 2 + continue + if token.startswith((">", "1>", "2>", "&>", "<", "0<")): + position += 1 + continue + if not operand_mode and executable == "rg" and token in {"--glob", "--iglob", "-g"}: + position += 2 + continue + if not operand_mode and executable == "grep" and ( + token == "-d" or (token.startswith("--dir") and "=" not in token) + ): + position += 2 + continue + if not operand_mode and executable in {"grep", "rg"} and token in {"-e", "--regexp"}: + position += 1 + if position >= len(tokens): + return "reader pattern option requires an expression" + expression_consumed = True + elif not operand_mode and option in file_options: + if "=" in token: + path = token.split("=", 1)[1] + else: + position += 1 + if position >= len(tokens): + return "reader file option requires a reviewed path" + path = tokens[position] + if not shell_reviewed_reader_path(path): + return "reader option path is not reviewed or packet-owned" + expression_consumed = True + elif not operand_mode and token.startswith("-f") and "-f" in file_options and len(token) > 2: + if not shell_reviewed_reader_path(token[2:]): + return "reader option path is not reviewed or packet-owned" + expression_consumed = True + elif not operand_mode and (token == "<<<" or token.startswith("-")): + position += 1 continue - if not shell_reviewed_reader_path(token): + elif executable == "tr" or not expression_consumed: + expression_consumed = True + elif (executable == "rg" or recursive_grep) and token in {".", "./"}: + return "recursive readers may not use the repository root as an operand" + elif not shell_reviewed_reader_path(token): return "reader path is not reviewed or packet-owned" + else: + reviewed_operand = True + position += 1 + if executable == "rg" and not reviewed_operand: + return "rg requires an explicit reviewed reader path" return None @@ -8611,10 +8979,74 @@ def go_command_violation(tokens): ) +reviewed_shell_export_assignments = { + "PATH=/opt/homebrew/bin:/usr/bin:/bin", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=core.fsmonitor", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=core.hooksPath", + "GIT_CONFIG_VALUE_1=/dev/null", + "GIT_ATTR_NOSYSTEM=1", +} + + +def shell_environment_builtin_violation(tokens): + """Reject shell builtins that can print inherited variables.""" + if not tokens: + return None + executable = executable_basename(tokens[0]) + if executable == "set": + if tokens == ["set", "-euo", "pipefail"]: + return None + return "shell set is allowed only as the exact reviewed 'set -euo pipefail' form" + if executable == "export": + assignments = tokens[1:] + if not assignments or any( + not assignment.fullmatch(value) + or value not in reviewed_shell_export_assignments + for value in assignments + ): + return "shell export requires explicit reviewed assignments" + return None + + +def git_config_environment_include_violation(tokens): + """Reject include keys injected through Git's numbered config environment.""" + for token in tokens: + if not assignment.fullmatch(token): + continue + name, value = token.split("=", 1) + if name.upper() == "GIT_CONFIG_PARAMETERS": + return "Git configuration parameters are not allowed before read-only commands" + if not re.fullmatch(r"GIT_CONFIG_KEY_[0-9]+", name, re.IGNORECASE): + continue + key = value.split("=", 1)[0] + if git_config_include_key(key): + return "Git configuration includes are not allowed before read-only commands" + if key.casefold() == "core.worktree": + return "Git core.worktree redirection is not allowed" + return None + + +reviewed_absolute_executable_paths = { + "/opt/homebrew/bin/python3", + "/bin/bash", + "/bin/sh", + "/usr/bin/git", + "/usr/bin/env", +} + def forbidden_command(tokens, depth=0): tokens = list(tokens) if not tokens: return None + original_tokens = list(tokens) + git_config_assignment_violation = shell_git_config_assignment_violation(tokens) + if git_config_assignment_violation: + return git_config_assignment_violation sensitive_parameter_violation = shell_sensitive_parameter_violation(tokens) if sensitive_parameter_violation: return sensitive_parameter_violation @@ -8643,7 +9075,14 @@ def forbidden_command(tokens, depth=0): ) tokens = executable_tokens(tokens) if not tokens: + if any(executable_basename(token) == "env" for token in original_tokens): + return "env without a child command can print inherited environment values" return None + if "/" in tokens[0] and tokens[0] not in reviewed_absolute_executable_paths: + return "executable path is outside the reviewed absolute locations" + environment_builtin_violation = shell_environment_builtin_violation(tokens) + if environment_builtin_violation: + return environment_builtin_violation if tokens[0] == "[": return None if tokens[0] == unsupported_env_wrapper_token: @@ -8656,6 +9095,12 @@ def forbidden_command(tokens, depth=0): return "RIPGREP_CONFIG_PATH configuration is not allowed" if unresolved_executable(tokens[0]): return "unresolved or parameter-expanded executable is not allowed" + if executable_basename(tokens[0]) == "git": + environment_include_violation = git_config_environment_include_violation( + original_tokens + ) + if environment_include_violation: + return environment_include_violation trap_violation = shell_trap_violation(tokens, depth) if trap_violation: return trap_violation @@ -8683,6 +9128,9 @@ def forbidden_command(tokens, depth=0): reader_path_violation = shell_reader_path_violation(tokens) if reader_path_violation: return reader_path_violation + jq_violation = jq_command_violation(tokens) + if jq_violation: + return jq_violation awk_violation = awk_command_violation(tokens) if awk_violation: return awk_violation @@ -8697,7 +9145,7 @@ def forbidden_command(tokens, depth=0): return f"{executable} -c nested command-string depth exceeded" if payload is not None: for nested_segment in shell_token_segments(payload): - nested_violation = forbidden_command(nested_segment, depth + 1) + nested_violation = forbidden_shell_command(nested_segment, depth + 1) if nested_violation: return f"{executable} -c -> {nested_violation}" return f"{executable} -c command string" @@ -8745,6 +9193,14 @@ def forbidden_command(tokens, depth=0): return f"{executable} shell executable is not in the reviewed safe allowlist" return None +def forbidden_shell_command(tokens, depth=0): + normalized = executable_tokens(tokens) + if normalized: + sensitive_git_output = git_sensitive_shell_output_violation(normalized) + if sensitive_git_output: + return sensitive_git_output + return forbidden_command(tokens, depth) + python_command_functions = { "os.execv", "os.execve", @@ -8948,2856 +9404,6149 @@ def python_credential_reader_aliases(tree): return aliases -def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen=None): - """Track credential values through aliases without trusting variable names.""" +def python_lexical_scope_chain(scope, parents): + """Return the current scope and its enclosing lexical scopes.""" + chain = [] + seen = set() + while scope is not None and id(scope) not in seen: + seen.add(id(scope)) + chain.append(scope) + if isinstance(scope, ast.Module): + break + scope = python_enclosing_scope(parents.get(scope), parents) + return chain + + +def python_local_function_candidates(name, call, tree, parents): + """Resolve same-name helpers visible from the call's lexical scope.""" + index = getattr(tree, "_issue79_local_function_index", None) + if index is None: + functions_by_scope = {} + aliases_by_scope = {} + for candidate in ast.walk(tree): + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)): + binding_scope = python_enclosing_scope( + parents.get(candidate), parents + ) + functions_by_scope.setdefault( + (id(binding_scope), candidate.name), [] + ).append(candidate) + elif isinstance(candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + binding_scope = python_enclosing_scope(candidate, parents) + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + value = candidate.value + if not isinstance(value, ast.Name): + continue + for target in targets: + if isinstance(target, ast.Name): + aliases_by_scope.setdefault( + (id(binding_scope), target.id), set() + ).add(value.id) + index = (functions_by_scope, aliases_by_scope) + tree._issue79_local_function_index = index + functions_by_scope, aliases_by_scope = index + visible_scopes = set( + python_lexical_scope_chain(python_enclosing_scope(call, parents), parents) + ) + names = {name} + for _ in range(len(aliases_by_scope) + 1): + changed = False + for scope in visible_scopes: + for (binding_scope_id, target_name), source_names in aliases_by_scope.items(): + if binding_scope_id == id(scope) and target_name in names: + for source_name in source_names: + if source_name not in names: + names.add(source_name) + changed = True + if not changed: + break + return [ + candidate + for scope in visible_scopes + for candidate_name in names + for candidate in functions_by_scope.get((id(scope), candidate_name), ()) + ] + + +def python_local_lambda_candidates(name, call, tree, parents): + """Resolve assigned lambdas visible from a local call site.""" + visible_scopes = set( + python_lexical_scope_chain(python_enclosing_scope(call, parents), parents) + ) + assignments_by_scope = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets, value = candidate.targets, candidate.value + elif ( + isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)) + and candidate.value is not None + ): + targets, value = [candidate.target], candidate.value + else: + continue + binding_scope = python_enclosing_scope(candidate, parents) + for target in targets: + if isinstance(target, ast.Name): + assignments_by_scope.setdefault( + (id(binding_scope), target.id), [] + ).append(value) + + def resolve(candidate_name, seen): + if candidate_name in seen: + return [] + seen.add(candidate_name) + candidates = [] + for scope in visible_scopes: + for value in assignments_by_scope.get( + (id(scope), candidate_name), () + ): + if isinstance(value, ast.Lambda): + candidates.append(value) + elif isinstance(value, ast.Name): + candidates.extend(resolve(value.id, set(seen))) + return candidates + + return resolve(name, set()) + + +def python_static_string_values(node, tree): + """Resolve literal strings through simple local assignment aliases.""" + assignments = getattr(tree, "_issue79_string_assignment_index", None) + if assignments is None: + assignments = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets = candidate.targets + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets = [candidate.target] + else: + continue + for target in targets: + if isinstance(target, ast.Name) and candidate.value is not None: + assignments.setdefault(target.id, []).append(candidate.value) + tree._issue79_string_assignment_index = assignments + + def resolve_literal_string_values(value, seen): + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return {value.value} + if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add): + left = resolve_literal_string_values(value.left, seen.copy()) + right = resolve_literal_string_values(value.right, seen.copy()) + if not left or not right or len(left) * len(right) > 16: + return set() + combined = {first + second for first in left for second in right} + return { + candidate for candidate in combined if len(candidate) <= 256 + } + if not isinstance(value, ast.Name) or value.id in seen: + return set() + return set().union(*( + resolve_literal_string_values(candidate, seen | {value.id}) + for candidate in assignments.get(value.id, ()) + )) + + return resolve_literal_string_values(node, set()) + + +def python_static_string_values_from_local_calls(node, tree, parents, seen=None): + """Resolve literal strings returned by visible local helpers without execution.""" if node is None: - return False + return set() if seen is None: seen = set() if id(node) in seen: - return False + return set() seen.add(id(node)) + values = set(python_static_string_values(node, tree)) if isinstance(node, ast.Name): - return node.id in sensitive_names - if isinstance(node, ast.Attribute) and python_dotted_name(node) == "os.environ": - return True - if isinstance(node, ast.Subscript): - if python_dotted_name(node.value) == "os.environ": - key = node.slice.value if isinstance(node.slice, ast.Constant) else None - return key is None or not isinstance(key, str) or credential_environment_name(key) - return python_sensitive_value_expression( - node.value, sensitive_names, tree, parents, seen.copy() - ) - if isinstance(node, ast.Call): - dotted = python_dotted_name(node.func) - if dotted == "os.getenv": - return True - if dotted == "os.environ.get": - key = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None - return key is None or not isinstance(key, str) or credential_environment_name(key) - if ( - isinstance(node.func, ast.Name) - and node.func.id in python_credential_reader_aliases(tree) - ): - key = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None - return key is None or not isinstance(key, str) or credential_environment_name(key) - if dotted == "dict" and any( - isinstance(argument, ast.Attribute) - and python_dotted_name(argument) == "os.environ" - for argument in node.args - ): - return True - if isinstance(node.func, ast.Attribute): - return python_sensitive_value_expression( - node.func.value, sensitive_names, tree, parents, seen.copy() - ) - if dotted in {"dict", "list", "tuple", "set", "str", "bytes", "repr"}: - return any( - python_sensitive_value_expression( - argument, sensitive_names, tree, parents, seen.copy() + assignments = getattr(tree, "_issue79_string_assignment_index", {}) + for value in assignments.get(node.id, ()): + if isinstance(value, ast.Call): + values.update( + python_static_string_values_from_local_calls( + value, tree, parents, seen.copy() + ) + ) + elif isinstance(node, ast.Call): + for returned in python_local_call_return_values(node, tree, parents): + values.update( + python_static_string_values_from_local_calls( + returned, tree, parents, seen.copy() ) - for argument in node.args ) - if isinstance(node, (ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp)): - if any( - isinstance(candidate, ast.Call) - and python_dotted_name(candidate.func) == "credential_environment_name" - for candidate in ast.walk(node) - ): - return False - if any( - isinstance(generator.iter, ast.Attribute) - and ( - python_dotted_name(generator.iter) or "" - ).startswith("os.environ") - for generator in node.generators - ): - return True - if isinstance(node, (ast.BinOp, ast.BoolOp, ast.UnaryOp, ast.IfExp, ast.JoinedStr)): - return any( - python_sensitive_value_expression(child, sensitive_names, tree, parents, seen.copy()) - for child in ast.iter_child_nodes(node) - ) - if isinstance(node, (ast.List, ast.Tuple, ast.Set, ast.Dict)): - return any( - python_sensitive_value_expression(child, sensitive_names, tree, parents, seen.copy()) - for child in ast.iter_child_nodes(node) - ) - return False - + return values -def python_sensitive_value_names(tree, parents): - """Resolve credential aliases and local-helper parameter taint.""" - sensitive_names = set() - assignments = [] - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - assignments.extend((target, node.value) for target in node.targets) - elif isinstance(node, ast.AnnAssign) and node.value is not None: - assignments.append((node.target, node.value)) - elif isinstance(node, ast.NamedExpr): - assignments.append((node.target, node.value)) - def target_names(target): - if isinstance(target, ast.Name): - return [target.id] - if isinstance(target, (ast.Tuple, ast.List)): - names = [] - for element in target.elts: - names.extend(target_names(element)) - return names +def python_local_call_return_values(call, tree, parents): + """Resolve returned expressions from visible local functions, methods, and lambdas.""" + if not isinstance(call, ast.Call): return [] + call_scope = python_enclosing_scope(call, parents) + visible_scopes = set(python_lexical_scope_chain(call_scope, parents)) + index = getattr(tree, "_issue79_local_return_index", None) + if index is None: + assignments_by_scope = {} + methods_by_scope = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets, value = candidate.targets, candidate.value + elif ( + isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)) + and candidate.value is not None + ): + targets, value = [candidate.target], candidate.value + else: + targets = [] + value = None + binding_scope = python_enclosing_scope(candidate, parents) + for target in targets: + if isinstance(target, ast.Name): + assignments_by_scope.setdefault( + (id(binding_scope), target.id), [] + ).append(value) + if isinstance(candidate, ast.ClassDef): + for method in candidate.body: + if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)): + methods_by_scope.setdefault( + ( + id(binding_scope), + candidate.name, + method.name, + ), + [], + ).append(method) + index = (assignments_by_scope, methods_by_scope) + tree._issue79_local_return_index = index + assignments_by_scope, methods_by_scope = index + + def returned_values(function): + if isinstance(function, ast.Lambda): + return [function.body] + return [ + candidate.value + for candidate in ast.walk(function) + if isinstance(candidate, (ast.Return, ast.Yield, ast.YieldFrom)) + and candidate.value is not None + and python_enclosing_scope(candidate, parents) is function + ] - local_functions = { - node.name: node - for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - } - - def function_parameters(function): - positional = list(function.args.posonlyargs) + list(function.args.args) - return positional + list(function.args.kwonlyargs) - - def call_arguments(call, function): - parameters = function_parameters(function) - bound = [] - for index, argument in enumerate(call.args): - if index >= len(parameters): - break - bound.append((parameters[index].arg, argument)) - parameter_by_name = {parameter.arg: parameter.arg for parameter in parameters} - for keyword in call.keywords: - if keyword.arg in parameter_by_name: - bound.append((keyword.arg, keyword.value)) - return bound + def lambda_bindings(name, seen=None): + if seen is None: + seen = set() + if name in seen: + return [] + seen.add(name) + values = [] + for scope in visible_scopes: + for value in assignments_by_scope.get((id(scope), name), ()): + if isinstance(value, ast.Lambda): + values.extend(returned_values(value)) + elif isinstance(value, ast.Name): + values.extend(lambda_bindings(value.id, set(seen))) + return values + + def method_return_values(attribute): + class_names = set() + + def resolve_receiver(value, seen_names=None, seen_nodes=None): + if seen_names is None: + seen_names = set() + if seen_nodes is None: + seen_nodes = set() + if id(value) in seen_nodes: + return + seen_nodes.add(id(value)) + if isinstance(value, ast.Call) and isinstance(value.func, ast.Name): + class_names.update(python_local_class_alias_names( + value.func.id, attribute.attr, visible_scopes, + methods_by_scope, assignments_by_scope, + )) + for returned in python_local_call_return_values(value, tree, parents): + resolve_receiver(returned, seen_names.copy(), seen_nodes.copy()) + elif isinstance(value, ast.Name) and value.id not in seen_names: + seen_names.add(value.id) + if value.id != "self": + binding_scopes = set(python_lexical_scope_chain( + python_enclosing_scope(value, parents), parents + )) or visible_scopes + for scope in binding_scopes: + for assignment in assignments_by_scope.get( + (id(scope), value.id), () + ): + resolve_receiver(assignment, seen_names.copy(), seen_nodes.copy()) + else: + enclosing = call_scope + while enclosing is not None and not isinstance(enclosing, ast.ClassDef): + enclosing = parents.get(enclosing) + if isinstance(enclosing, ast.ClassDef): + class_names.add(enclosing.name) + + resolve_receiver(attribute.value) + return [ + value + for scope in visible_scopes + for class_name in class_names + for method in methods_by_scope.get( + (id(scope), class_name, attribute.attr), () + ) + for value in returned_values(method) + ] - # Iterate assignments and direct local-helper calls to a fixed point. The - # call-site pass closes the exact environment-map laundering gap where a - # helper parameter is later indexed or sent to a sink. - for _ in range(len(assignments) + len(local_functions) + 1): - changed = False - for target, value in assignments: - if not python_sensitive_value_expression( - value, sensitive_names, tree, parents - ): - continue - for name in target_names(target): - if name not in sensitive_names: - sensitive_names.add(name) - changed = True - for node in ast.walk(tree): - if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): - continue - function = local_functions.get(node.func.id) - if function is None: - continue - for parameter, argument in call_arguments(node, function): - if python_sensitive_value_expression( - argument, sensitive_names, tree, parents - ) and parameter not in sensitive_names: - sensitive_names.add(parameter) - changed = True - if not changed: - break - return sensitive_names + def bound_method_bindings(name, seen=None): + if seen is None: + seen = set() + if name in seen: + return [] + seen.add(name) + values = [] + for scope in visible_scopes: + for value in assignments_by_scope.get((id(scope), name), ()): + if isinstance(value, ast.Attribute): + values.extend(method_return_values(value)) + elif ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) == "getattr" + and len(value.args) in {2, 3} + ): + for attribute in python_static_string_values(value.args[1], tree): + values.extend(method_return_values(ast.Attribute( + value=value.args[0], attr=attribute, ctx=ast.Load(), + ))) + if len(value.args) == 3: + fallback = value.args[2] + if isinstance(fallback, ast.Lambda): + values.extend(returned_values(fallback)) + elif isinstance(fallback, ast.Name): + values.extend( + result + for candidate in python_local_function_candidates( + fallback.id, call, tree, parents + ) + for result in returned_values(candidate) + ) + elif isinstance(value, ast.Name): + values.extend(bound_method_bindings(value.id, set(seen))) + return values + + function = call.func + if isinstance(function, ast.Lambda): + return returned_values(function) + if isinstance(function, ast.Name): + values = [ + value + for candidate in python_local_function_candidates( + function.id, call, tree, parents + ) + for value in returned_values(candidate) + ] + return values + lambda_bindings(function.id) + bound_method_bindings(function.id) + if isinstance(function, ast.Attribute): + return method_return_values(function) + return [] + + +def python_local_class_alias_names(name, method_name, scopes, methods_by_scope, assignments_by_scope): + """Resolve scoped class-name aliases for known local method receivers.""" + found = set() + + def visit_expression(expression, seen): + if isinstance(expression, ast.Name): + visit(expression.id, set(seen)) + elif isinstance(expression, ast.IfExp): + visit_expression(expression.body, set(seen)) + visit_expression(expression.orelse, set(seen)) + elif ( + isinstance(expression, ast.Subscript) + and isinstance(expression.value, (ast.List, ast.Tuple)) + ): + index = expression.slice + if isinstance(index, ast.Index): + index = index.value + if isinstance(index, ast.Constant) and isinstance(index.value, int) and not isinstance(index.value, bool): + elements = expression.value.elts + if -len(elements) <= index.value < len(elements): + visit_expression(elements[index.value], set(seen)) + + def visit(candidate, seen): + if candidate in seen: + return + seen.add(candidate) + if any( + (id(scope), candidate, method_name) in methods_by_scope + for scope in scopes + ): + found.add(candidate) + return + for scope in scopes: + for assigned in assignments_by_scope.get((id(scope), candidate), ()): + visit_expression(assigned, set(seen)) + visit(name, set()) + return found -def python_dynamic_execution_bindings(tree): - """Track aliases to built-in dynamic execution primitives conservatively.""" - bindings = set(python_dynamic_execution_names) - unresolved = set() - container_values = {} - def target_names(target): - if isinstance(target, ast.Name): - return [target.id] - if isinstance(target, (ast.Tuple, ast.List)): - names = [] - for element in target.elts: - names.extend(target_names(element)) - return names +def python_local_method_candidates(attribute, call, tree, parents): + """Resolve local methods for known instance and local-factory receivers.""" + if not isinstance(attribute, ast.Attribute): return [] + index = getattr(tree, "_issue79_local_return_index", None) + if index is None: + python_local_call_return_values(call, tree, parents) + index = getattr(tree, "_issue79_local_return_index", None) + if index is None: + return [] + assignments_by_scope, methods_by_scope = index + call_scope = python_enclosing_scope(call, parents) + visible_scopes = set(python_lexical_scope_chain(call_scope, parents)) + if not any( + binding_scope_id == id(scope) and method_name == attribute.attr + for binding_scope_id, _class_name, method_name in methods_by_scope + for scope in visible_scopes + ): + return [] + class_names = set() + + def resolve_receiver(value, seen_names=None, seen_nodes=None): + if seen_names is None: + seen_names = set() + if seen_nodes is None: + seen_nodes = set() + if id(value) in seen_nodes: + return + seen_nodes.add(id(value)) + if isinstance(value, ast.Call) and isinstance(value.func, ast.Name): + class_names.update(python_local_class_alias_names( + value.func.id, attribute.attr, visible_scopes, + methods_by_scope, assignments_by_scope, + )) + for returned in python_local_call_return_values(value, tree, parents): + resolve_receiver(returned, seen_names.copy(), seen_nodes.copy()) + elif isinstance(value, ast.Name) and value.id not in seen_names: + seen_names.add(value.id) + if value.id != "self": + binding_scopes = set(python_lexical_scope_chain( + python_enclosing_scope(value, parents), parents + )) or visible_scopes + for scope in binding_scopes: + for assignment in assignments_by_scope.get( + (id(scope), value.id), () + ): + resolve_receiver( + assignment, seen_names.copy(), seen_nodes.copy() + ) + else: + enclosing = call_scope + while enclosing is not None and not isinstance( + enclosing, ast.ClassDef + ): + enclosing = parents.get(enclosing) + if isinstance(enclosing, ast.ClassDef): + class_names.add(enclosing.name) - assignment_values = [] - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module in {"builtins", "__builtin__"}: - for alias in node.names: - if alias.name in python_dynamic_execution_names: - bindings.add(alias.asname or alias.name) - elif isinstance(node, ast.Assign): - assignment_values.extend((target, node.value) for target in node.targets) - elif isinstance(node, ast.AnnAssign): - assignment_values.append((node.target, node.value)) - elif isinstance(node, ast.NamedExpr): - assignment_values.append((node.target, node.value)) + resolve_receiver(attribute.value) + return [ + method + for scope in visible_scopes + for class_name in class_names + for method in methods_by_scope.get( + (id(scope), class_name, attribute.attr), () + ) + ] - def container_source(node, seen=None): - if seen is None: - seen = set() - if node is None or id(node) in seen: - return None - seen.add(id(node)) - if isinstance(node, (ast.List, ast.Tuple, ast.Dict, ast.Set)): - return node - if isinstance(node, ast.Name) and node.id in container_values: - return container_source(container_values[node.id], seen) - return None - def dynamic_state(node, seen=None): - """Return safe/dynamic/unresolved/unknown for container alias values.""" - if seen is None: - seen = set() - if node is None or id(node) in seen: - return "unknown" - seen.add(id(node)) - if isinstance(node, ast.Name): - if node.id in bindings: - return "dynamic" - if node.id in unresolved: - return "unresolved" - if node.id in container_values: - return dynamic_state(container_values[node.id], seen) - return "unknown" - if isinstance(node, ast.Constant): - return "safe" - if isinstance(node, (ast.List, ast.Tuple, ast.Set)): - states = [dynamic_state(element, seen.copy()) for element in node.elts] - if "dynamic" in states: - return "dynamic" - if "unresolved" in states: - return "unresolved" - if "unknown" in states: - return "unknown" - return "safe" - if isinstance(node, ast.Dict): - states = [ - dynamic_state(element, seen.copy()) - for element in node.values - if element is not None - ] - if "dynamic" in states: - return "dynamic" - if "unresolved" in states: - return "unresolved" - if "unknown" in states: - return "unknown" - return "safe" - if isinstance(node, ast.Subscript): - source = container_source(node.value, seen.copy()) - if source is None: - return "unknown" - key = node.slice - if isinstance(key, ast.Index): - key = key.value - selected = None - if isinstance(source, (ast.List, ast.Tuple)): - if isinstance(key, ast.Constant) and isinstance(key.value, int): - index = key.value - if -len(source.elts) <= index < len(source.elts): - selected = source.elts[index] - elif isinstance(source, ast.Dict): - if isinstance(key, ast.Constant) and isinstance(key.value, str): - for candidate, value in zip(source.keys, source.values): - if ( - isinstance(candidate, ast.Constant) - and candidate.value == key.value - ): - selected = value - break - if selected is not None: - return dynamic_state(selected, seen.copy()) - container_state = dynamic_state(source, seen.copy()) - if container_state in {"dynamic", "unresolved"}: - return "unresolved" - return "unknown" - return "unknown" +def python_local_bound_method_candidates(name, call, tree, parents): + """Resolve assigned bound methods, including static getattr aliases.""" + index = getattr(tree, "_issue79_local_return_index", None) + if index is None: + python_local_call_return_values(call, tree, parents) + index = getattr(tree, "_issue79_local_return_index", None) + assignments_by_scope, _methods_by_scope = index + visible_scopes = set(python_lexical_scope_chain( + python_enclosing_scope(call, parents), parents + )) - for _ in range(len(assignment_values) * 2 + 1): - changed = False - for target, value in assignment_values: - dotted = python_dotted_name(value) - dynamic_getattr = ( - isinstance(value, ast.Call) - and isinstance(value.func, ast.Name) - and value.func.id == "getattr" - and value.args - and python_dotted_name(value.args[0]) in {"builtins", "__builtins__"} - ) - for name in target_names(target): - prior_container = container_values.get(name) - if isinstance(value, (ast.List, ast.Tuple, ast.Dict, ast.Set)) or ( - isinstance(value, ast.Name) and value.id in container_values - ): - if prior_container is not value: - container_values[name] = value - changed = True - state = dynamic_state(value) - if ( - isinstance(value, ast.Name) and value.id in bindings - ) or ( - dotted is not None - and dotted.rsplit(".", 1)[-1] in python_dynamic_execution_names + def methods(candidate_name, seen): + if candidate_name in seen: + return [] + seen.add(candidate_name) + found = [] + for scope in visible_scopes: + for value in assignments_by_scope.get((id(scope), candidate_name), ()): + if isinstance(value, ast.Attribute): + found.extend(python_local_method_candidates( + value, call, tree, parents + )) + elif ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) == "getattr" + and len(value.args) in {2, 3} ): - if name not in bindings: - bindings.add(name) - changed = True - elif state == "dynamic": - if name not in bindings: - bindings.add(name) - changed = True - elif dynamic_getattr and name not in unresolved: - unresolved.add(name) - changed = True - elif state == "unresolved" and name not in unresolved: - unresolved.add(name) - changed = True - if not changed: - break - return bindings, unresolved + for attribute in python_static_string_values(value.args[1], tree): + found.extend(python_local_method_candidates( + ast.Attribute( + value=value.args[0], attr=attribute, + ctx=ast.Load(), + ), call, tree, parents + )) + if len(value.args) == 3 and isinstance(value.args[2], ast.Name): + found.extend(methods(value.args[2].id, set(seen))) + elif len(value.args) == 3 and isinstance(value.args[2], ast.Attribute): + found.extend(python_local_method_candidates( + value.args[2], call, tree, parents + )) + elif isinstance(value, ast.Name): + found.extend(methods(value.id, set(seen))) + elif isinstance(value, ast.Call): + for returned in python_local_call_return_values( + value, tree, parents + ): + if isinstance(returned, ast.Attribute): + found.extend(python_local_method_candidates( + returned, call, tree, parents + )) + return found + return methods(name, set()) -def python_dynamic_execution_target(node, bindings, unresolved): - if isinstance(node, ast.Name): - if node.id in bindings: - return node.id - if node.id in unresolved: - return "unresolved dynamic Python execution alias" - dotted = python_dotted_name(node) - if dotted and dotted.rsplit(".", 1)[-1] in python_dynamic_execution_names: - return dotted.rsplit(".", 1)[-1] - if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "getattr": - if node.args and python_dotted_name(node.args[0]) in {"builtins", "__builtins__"}: - if ( - len(node.args) > 1 - and isinstance(node.args[1], ast.Constant) - and node.args[1].value in python_dynamic_execution_names - ): - return node.args[1].value - return "unresolved dynamic Python execution primitive" - if isinstance(node, ast.Subscript) and python_dotted_name(node.value) in { - "builtins", "__builtins__", - }: - return "unresolved dynamic Python execution primitive" - if isinstance(node, ast.Subscript) and isinstance( - node.value, (ast.List, ast.Tuple, ast.Dict, ast.Set) - ): - return "unresolved dynamic Python execution primitive" - return None +def python_assigned_callable_alias(name, target, tree): + """Conservatively follow local assignment aliases of a known sink/exception.""" + assignments = getattr(tree, "_issue79_callable_alias_index", None) + if assignments is None: + assignments = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + bindings = candidate.targets + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + bindings = [candidate.target] + else: + continue + for binding in bindings: + if isinstance(binding, ast.Name): + assignments.setdefault(binding.id, []).append(candidate.value) + tree._issue79_callable_alias_index = assignments -reviewed_python_compile_source_names = { - "wrapper", - "previous_wrapper", - "current_wrapper", - "prior_wrapper", - "previous", - "packet", - "prefix", - "scanner", - "setup", - "helper_source", -} -reviewed_python_compile_ast_names = { - "module", - "validator", - "process_functions", - "init_helpers", - "selected_nodes", - "selected", - "module_node", - "terminator", - "helper", -} -reviewed_python_compile_slice_bases = reviewed_python_compile_source_names + def matches(value, seen): + if python_dotted_name(value) == target: + return True + if ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) == "getattr" + and len(value.args) in {2, 3} + ): + if any( + f"{python_dotted_name(value.args[0])}.{attribute}" == target + for attribute in python_static_string_values(value.args[1], tree) + ): + return True + if len(value.args) == 3 and matches(value.args[2], seen): + return True + if ( + isinstance(value, ast.Subscript) + and isinstance(value.value, (ast.List, ast.Tuple)) + and isinstance(value.slice, ast.Constant) + and isinstance(value.slice.value, int) + and not isinstance(value.slice.value, bool) + ): + elements = value.value.elts + index = value.slice.value + return -len(elements) <= index < len(elements) and matches( + elements[index], seen + ) + if not isinstance(value, ast.Name) or value.id in seen: + return False + return any( + matches(candidate, seen | {value.id}) + for candidate in assignments.get(value.id, ()) + if candidate is not None + ) + return any( + matches(value, {name}) + for value in assignments.get(name, ()) + if value is not None + ) -def python_compile_provenance(tree): - """Resolve source/AST provenance before permitting static compile/exec.""" - source_names = set() - ast_names = set() - packet_path_names = set() - def target_names(target): - if isinstance(target, ast.Name): - return [target.id] - if isinstance(target, (ast.Tuple, ast.List)): - names = [] - for element in target.elts: - names.extend(target_names(element)) - return names - return [] +def python_assigned_format_alias(name, tree): + """Resolve local aliases of format callables without evaluating them.""" + assignments = getattr(tree, "_issue79_callable_alias_index", None) + if assignments is None: + python_assigned_callable_alias("", "format", tree) + assignments = getattr(tree, "_issue79_callable_alias_index", {}) - def contains_packet_path(node): + def matches(value, seen): + if isinstance(value, ast.Attribute) and value.attr == "format": + return True + if python_dotted_name(value) in {"format", "builtins.format", "str.format"}: + return True + if not isinstance(value, ast.Name) or value.id in seen: + return False return any( - isinstance(candidate, ast.Constant) - and isinstance(candidate.value, str) - and "docs/evidence/g01-recovery-packet.md" in candidate.value - for candidate in ast.walk(node) - ) or any( - isinstance(candidate, ast.Name) and candidate.id in packet_path_names - for candidate in ast.walk(node) + matches(candidate, seen | {value.id}) + for candidate in assignments.get(value.id, ()) + if candidate is not None ) - def packet_path_value(value): - literal_path = ( - isinstance(value, ast.Constant) - and isinstance(value.value, str) - and "docs/evidence/g01-recovery-packet.md" in value.value - ) - path_constructor = ( - isinstance(value, ast.Call) - and python_dotted_name(value.func) in {"Path", "pathlib.Path"} - and contains_packet_path(value) - ) - return literal_path or path_constructor + return any( + matches(value, {name}) + for value in assignments.get(name, ()) + if value is not None + ) - for node in ast.walk(tree): - if not isinstance(node, ast.Assign): - continue - value = node.value - if not packet_path_value(value): - continue - packet_path_names.update(target_names(node.targets[0])) +def python_imported_urlencode_aliases(tree): + """Cache urllib.parse.urlencode import spellings without evaluating the AST.""" + aliases = getattr(tree, "_issue79_urlencode_import_aliases", None) + if aliases is None: + aliases = set() + for candidate in ast.walk(tree): + if isinstance(candidate, ast.ImportFrom): + if candidate.level == 0 and candidate.module == "urllib.parse": + aliases.update( + alias.asname or alias.name + for alias in candidate.names + if alias.name == "urlencode" + ) + elif isinstance(candidate, ast.Import): + for alias in candidate.names: + if alias.name == "urllib.parse": + aliases.add((alias.asname or "urllib.parse") + ".urlencode") + elif alias.name == "urllib": + aliases.add((alias.asname or "urllib") + ".parse.urlencode") + tree._issue79_urlencode_import_aliases = aliases + return aliases - for node in ast.walk(tree): - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - literals = { - candidate.value - for candidate in ast.walk(node) - if isinstance(candidate, ast.Constant) - and isinstance(candidate.value, str) - } - if ( - any('source = Path("docs/evidence/g01-recovery-packet.md")' in value for value in literals) - and any("matches = []" in value for value in literals) - ): - source_names.update(argument.arg for argument in node.args.args) - def source_value(node): - if isinstance(node, ast.Name): - return node.id in source_names - if isinstance(node, ast.Subscript): - return source_value(node.value) - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): - return source_value(node.left) and source_value(node.right) - if isinstance(node, ast.Call): - dotted = python_dotted_name(node.func) - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "replace" - ): - return source_value(node.func.value) - if isinstance(node.func, ast.Attribute) and node.func.attr == "read_text": - receiver = node.func.value - return ( - isinstance(receiver, ast.Call) - and python_dotted_name(receiver.func) in {"Path", "pathlib.Path"} - and receiver.args - and contains_packet_path(receiver) - ) or ( - isinstance(receiver, ast.Name) - and receiver.id in packet_path_names - ) - if dotted == "subprocess.check_output": - return contains_packet_path(node) +def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen=None): + """Track credential values through aliases without trusting variable names.""" + if node is None: return False - - def ast_value(node): - if isinstance(node, ast.Name): - return node.id in ast_names - if isinstance(node, ast.Call): - return python_dotted_name(node.func) in {"ast.parse", "ast.Module"} - if isinstance(node, (ast.List, ast.Tuple)): - return bool(node.elts) and all(ast_value(element) for element in node.elts) + if seen is None: + seen = set() + if id(node) in seen: return False - - assignments = [] - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - assignments.extend((target, node.value) for target in node.targets) - elif isinstance(node, ast.AnnAssign) and node.value is not None: - assignments.append((node.target, node.value)) - elif isinstance(node, ast.NamedExpr): - assignments.append((node.target, node.value)) - tainted_packet_path_names = { - name - for target, value in assignments - for name in target_names(target) - if name in packet_path_names and not packet_path_value(value) - } - packet_path_names.difference_update(tainted_packet_path_names) - for _ in range(len(assignments) + 1): - changed = False - for target, value in assignments: - names = target_names(target) - if source_value(value): - for name in names: - if name not in source_names: - source_names.add(name) - changed = True - if ast_value(value): - for name in names: - if name not in ast_names: - ast_names.add(name) - changed = True - if not changed: - break - invalidated_source_names = { - name - for target, value in assignments - for name in target_names(target) - if name in source_names and not source_value(value) - } - invalidated_ast_names = { - name - for target, value in assignments - for name in target_names(target) - if name in ast_names and not ast_value(value) - } - source_names.difference_update(invalidated_source_names) - ast_names.difference_update(invalidated_ast_names) - return source_names, ast_names - - -def reviewed_python_compile_source(node, provenance=None): - """Permit only packet-derived source slices or provenance-checked AST nodes.""" - source_provenance, ast_provenance = provenance or (set(), set()) + seen.add(id(node)) if isinstance(node, ast.Name): - return ( - node.id in reviewed_python_compile_source_names - and node.id in source_provenance - ) or ( - node.id in reviewed_python_compile_ast_names - and node.id in ast_provenance - ) + if node.id in sensitive_names: + return True + if getattr(tree, "_issue79_member_taint_enabled", False): + return any( + isinstance(value, (ast.Name, ast.Attribute, ast.Subscript)) + and python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in python_join_assignment_index(tree).get(node.id, ()) + ) + return False + if isinstance(node, ast.Attribute) and python_dotted_name(node) == "os.environ": + return True if isinstance(node, ast.Subscript): - if not ( - isinstance(node.value, ast.Name) - and node.value.id in reviewed_python_compile_slice_bases - and node.value.id in source_provenance - and isinstance(node.slice, ast.Slice) + if python_dotted_name(node.value) == "os.environ": + key = node.slice.value if isinstance(node.slice, ast.Constant) else None + return key is None or not isinstance(key, str) or key == "HOME" or credential_environment_name(key) + if getattr(tree, "_issue79_member_taint_enabled", False) and python_sensitive_member_assignment_value( + node, sensitive_names, tree, parents, seen.copy() ): - return False - return all( - part is None or isinstance(part, ast.Name) - for part in (node.slice.lower, node.slice.upper, node.slice.step) - ) - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): - return reviewed_python_compile_source(node.left, provenance) and reviewed_python_compile_source( - node.right, provenance + return True + return python_sensitive_value_expression( + node.value, sensitive_names, tree, parents, seen.copy() ) - if not ( - isinstance(node, ast.Call) - and python_dotted_name(node.func) == "ast.Module" - and not node.args + if isinstance(node, ast.Attribute) and getattr(tree, "_issue79_member_taint_enabled", False) and python_sensitive_member_assignment_value( + node, sensitive_names, tree, parents, seen.copy() ): - return False - keywords = {keyword.arg: keyword.value for keyword in node.keywords} - if set(keywords) != {"body", "type_ignores"}: - return False - type_ignores = keywords["type_ignores"] - if not isinstance(type_ignores, ast.List) or type_ignores.elts: - return False - body = keywords["body"] - if isinstance(body, ast.Name): - return body.id in reviewed_python_compile_ast_names - if not isinstance(body, ast.List) or not body.elts: - return False - for element in body.elts: - if isinstance(element, ast.Name): - if element.id not in reviewed_python_compile_ast_names: - return False - elif not ( - isinstance(element, ast.Subscript) - and isinstance(element.value, ast.Name) - and element.value.id == "functions" - and isinstance(element.slice, ast.Constant) - and isinstance(element.slice.value, str) + return True + if isinstance(node, ast.Call): + dotted = python_dotted_name(node.func) + if dotted == "urllib.parse.urlencode" or dotted in python_imported_urlencode_aliases(tree): + return any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ) + if python_reviewed_string_join_callable(node.func, tree, parents) and any( + python_sensitive_join_argument( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ): + return True + if isinstance(node.func, ast.Name) and ( + node.func.id == "format" + or python_assigned_format_alias(node.func.id, tree) + ): + local_format_callables = python_local_function_candidates( + node.func.id, node, tree, parents + ) + python_local_lambda_candidates(node.func.id, node, tree, parents) + if local_format_callables: + sensitive_return = any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in python_local_call_return_values( + node, tree, parents + ) + ) + if not sensitive_return: + safe_local_format_calls = getattr( + tree, "_issue79_safe_local_format_calls", None + ) + if safe_local_format_calls is None: + safe_local_format_calls = set() + tree._issue79_safe_local_format_calls = safe_local_format_calls + safe_local_format_calls.add(id(node)) + return sensitive_return + if dotted in {"format", "builtins.format"} or ( + isinstance(node.func, ast.Name) + and python_assigned_format_alias(node.func.id, tree) + ): + return any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ) + if dotted == "os.getenv": + return True + if dotted == "os.environ.get": + key = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None + return key is None or not isinstance(key, str) or key == "HOME" or credential_environment_name(key) + if ( + isinstance(node.func, ast.Name) + and node.func.id in python_credential_reader_aliases(tree) + ): + key = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None + return key is None or not isinstance(key, str) or key == "HOME" or credential_environment_name(key) + sensitive_constructors = getattr( + tree, "_issue79_sensitive_constructor_names", None + ) + if sensitive_constructors is None: + sensitive_constructors = { + "dict", "list", "tuple", "set", "str", "bytes", "repr", + } + tree._issue79_sensitive_constructor_names = sensitive_constructors + is_sensitive_constructor = dotted in sensitive_constructors + if not is_sensitive_constructor and isinstance(node.func, ast.Name): + constructor_aliases = getattr( + tree, "_issue79_sensitive_constructor_aliases", None + ) + if constructor_aliases is None: + constructor_aliases = {} + tree._issue79_sensitive_constructor_aliases = constructor_aliases + is_sensitive_constructor = constructor_aliases.get(node.func.id) + if is_sensitive_constructor is None: + is_sensitive_constructor = any( + python_assigned_callable_alias( + node.func.id, constructor, tree + ) + or python_imported_function_alias_is_stable( + node.func.id, "builtins", constructor, tree + ) + for constructor in sensitive_constructors + ) + constructor_aliases[node.func.id] = is_sensitive_constructor + if isinstance(node.func, ast.Attribute) and node.func.attr == "format": + return python_sensitive_value_expression( + node.func.value, sensitive_names, tree, parents, seen.copy() + ) or any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ) + if is_sensitive_constructor and any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ): + return True + if any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in python_local_call_return_values(node, tree, parents) + ): + return True + if dotted == "SystemExit" or ( + isinstance(node.func, ast.Name) + and python_assigned_callable_alias(node.func.id, "SystemExit", tree) + ): + return any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ) + if isinstance(node.func, ast.Name) and node.func.id in { + "enumerate", "filter", "iter", "map", "next", "reversed", + "sorted", "zip", "chain", + }: + return any( + python_sensitive_value_expression( + argument, sensitive_names, tree, parents, seen.copy() + ) + for argument in node.args + ) + if dotted in { + "itertools.chain", + "itertools.chain.from_iterable", + }: + return any( + python_sensitive_value_expression( + argument, sensitive_names, tree, parents, seen.copy() + ) + for argument in node.args + ) + if isinstance(node.func, ast.Attribute): + sensitive_receiver = python_sensitive_value_expression( + node.func.value, sensitive_names, tree, parents, seen.copy() + ) + return sensitive_receiver + if isinstance(node, (ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp)): + if any( + isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == "credential_environment_name" + for candidate in ast.walk(node) ): return False - return True - + return any( + python_sensitive_value_expression( + generator.iter, sensitive_names, tree, parents, seen.copy() + ) + for generator in node.generators + ) + if isinstance(node, (ast.BinOp, ast.BoolOp, ast.UnaryOp, ast.IfExp, ast.JoinedStr)): + return any( + python_sensitive_value_expression(child, sensitive_names, tree, parents, seen.copy()) + for child in ast.iter_child_nodes(node) + ) + if isinstance(node, ast.Starred): + return python_sensitive_value_expression( + node.value, sensitive_names, tree, parents, seen.copy() + ) + if isinstance(node, (ast.List, ast.Tuple, ast.Set, ast.Dict)): + return any( + python_sensitive_value_expression(child, sensitive_names, tree, parents, seen.copy()) + for child in ast.iter_child_nodes(node) + ) + return False -def reviewed_python_exec_call(call, safe_marker, tree=None): - """Allow only the packet's static compile/exec metaprogramming path.""" - if not isinstance(call.func, ast.Name) or call.func.id != "exec": - return False - if not call.args or not isinstance(call.args[0], ast.Call): - return False - compiler = call.args[0] - if not isinstance(compiler.func, ast.Name) or compiler.func.id != "compile": +def python_sensitive_member_key(node): + """Key a simple attribute/item target so later reads retain assigned taint.""" + parts = [] + current = node + while isinstance(current, (ast.Attribute, ast.Subscript)): + if isinstance(current, ast.Attribute): + parts.append("attribute:" + current.attr) + current = current.value + else: + parts.append( + "item:" + ast.dump(current.slice, include_attributes=False) + ) + current = current.value + if not isinstance(current, ast.Name): + return None + parts.append("name:" + current.id) + return "member:" + ":".join(reversed(parts)) + +def python_sensitive_member_assignment_index(tree, parents): + """Index explicit member writes and direct local-helper object aliases.""" + index = getattr(tree, "_issue79_sensitive_member_assignment_index", None) + if index is None: + index = {} + helper_writes = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets, value = candidate.targets, candidate.value + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets, value = [candidate.target], candidate.value + else: + continue + if value is None: + continue + for target in targets: + if not isinstance(target, (ast.Attribute, ast.Subscript)): + continue + key = python_sensitive_member_key(target) + if key is not None: + index.setdefault(key, []).append(value) + scope = python_enclosing_scope(candidate, parents) + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): + parameters = list(scope.args.posonlyargs) + list(scope.args.args) + for position, parameter in enumerate(parameters): + prefix = "member:name:" + parameter.arg + ":" + if key.startswith(prefix): + helper_writes.setdefault(scope.name, []).append( + (scope, position, prefix, key, value) + ) + for call in ast.walk(tree): + if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name): + continue + writes = helper_writes.get(call.func.id, ()) + if not writes: + continue + visible = python_local_function_candidates(call.func.id, call, tree, parents) + for function, position, prefix, key, value in writes: + if function not in visible or position >= len(call.args): + continue + argument = call.args[position] + if isinstance(argument, ast.Name): + alias_key = "member:name:" + argument.id + ":" + key[len(prefix):] + index.setdefault(alias_key, []).append(value) + tree._issue79_sensitive_member_assignment_index = index + return index + +def python_sensitive_member_assignment_value( + node, sensitive_names, tree, parents, seen=None +): + """Propagate taint only from a matching explicit member write.""" + if seen is None: + seen = set() + key = python_sensitive_member_key(node) + if key is None: return False - if len(compiler.args) != 3 or compiler.keywords: + marker = ("member", key) + if marker in seen: return False - provenance = python_compile_provenance(tree) if tree is not None else (set(), set()) - if not reviewed_python_compile_source(compiler.args[0], provenance): + next_seen = seen | {marker} + return any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, next_seen.copy() + ) + for value in python_sensitive_member_assignment_index(tree, parents).get(key, ()) + ) + +def python_shadowed_builtin_names(tree, node, parents): + """Return bindings visible to this call, not unrelated nested scopes.""" + shadowed = getattr(tree, "_issue79_shadowed_builtin_names", None) + if shadowed is None: + shadowed = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store): + scope = python_enclosing_scope(candidate, parents) + shadowed.setdefault(id(scope), set()).add(candidate.id) + elif isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + scope = python_enclosing_scope(parents.get(candidate), parents) + shadowed.setdefault(id(scope), set()).add(candidate.name) + elif isinstance(candidate, ast.arg): + scope = python_enclosing_scope(candidate, parents) + shadowed.setdefault(id(scope), set()).add(candidate.arg) + tree._issue79_shadowed_builtin_names = shadowed + scope = python_enclosing_scope(node, parents) + return set().union( + *(shadowed.get(id(visible), set()) for visible in python_lexical_scope_chain(scope, parents)) + ) + +def python_unshadowed_builtin_call(node, names, tree, parents): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): return False - filename, mode = compiler.args[1:3] return ( - isinstance(filename, ast.Constant) - and isinstance(filename.value, str) - and filename.value.startswith("<") - and filename.value.endswith(">") - and isinstance(mode, ast.Constant) - and mode.value == "exec" + node.func.id in names + and node.func.id not in python_shadowed_builtin_names(tree, node, parents) ) +def python_unshadowed_builtin_reference(node, names, tree, parents): + return ( + isinstance(node, ast.Name) + and node.id in names + and node.id not in python_shadowed_builtin_names(tree, node, parents) + ) -def python_indirect_execution_violation(tree, parents, safe_marker): - """Reject compiled/function-object execution outside reviewed AST probes.""" - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - dotted = python_dotted_name(node.func) - if dotted in python_indirect_execution_constructors: - return ( - "Python heredoc contains an indirect executable function object " - f"{dotted!r} on line {node.lineno}" - ) - if dotted != "compile": - continue - reviewed_parent = any( - isinstance(parent, ast.Call) - and parent.args - and parent.args[0] is node - and reviewed_python_exec_call(parent, safe_marker, tree) - for parent in _python_parent_chain(node, parents) - ) - if not reviewed_parent: - return ( - "Python heredoc contains an unreviewed compile call " - f"on line {node.lineno}" - ) - return None - +def python_join_assignment_index(tree): + """Cache local assignments used by literal joins and their value aliases.""" + assignments = getattr(tree, "_issue79_join_alias_index", None) + if assignments is None: + assignments = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets = candidate.targets + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets = [candidate.target] + else: + continue + for target in targets: + if isinstance(target, ast.Name) and candidate.value is not None: + assignments.setdefault(target.id, []).append(candidate.value) + tree._issue79_join_alias_index = assignments + return assignments -def reviewed_python_import_call(call, safe_marker): - """Allow only literal imports used by packet-only probes.""" - if not isinstance(call.func, ast.Name) or call.func.id != "__import__": - return False - if not call.args or not isinstance(call.args[0], ast.Constant): +def python_reviewed_string_join_callable(value, tree, parents, seen=None): + """Resolve literal-string join receivers and their local callable aliases.""" + if seen is None: + seen = set() + if isinstance(value, ast.Attribute) and value.attr == "join": + receiver = value.value + if python_static_string_values(receiver, tree): + return True + if python_unshadowed_builtin_reference(receiver, {"str"}, tree, parents): + return True + return ( + python_unshadowed_builtin_call(receiver, {"str"}, tree, parents) + and not receiver.args + and not receiver.keywords + ) + if not isinstance(value, ast.Name) or value.id in seen: return False - module = call.args[0].value - return isinstance(module, str) and module in reviewed_python_import_modules - + return any( + python_reviewed_string_join_callable(candidate, tree, parents, seen | {value.id}) + for candidate in python_join_assignment_index(tree).get(value.id, ()) + ) -def python_import_launcher_violation(node): - """Reject command-capable modules reached through a dynamic import chain.""" - for candidate in ast.walk(node): - if not ( - isinstance(candidate, ast.Call) - and isinstance(candidate.func, ast.Name) - and candidate.func.id == "__import__" +def python_sensitive_join_argument(node, sensitive_names, tree, parents, seen=None): + """Track environment value/item iterators through reviewed join wrappers.""" + if seen is None: + seen = set() + if id(node) in seen: + return False + seen.add(id(node)) + if isinstance(node, ast.Name): + return any( + python_sensitive_join_argument( + candidate, sensitive_names, tree, parents, seen.copy() + ) + for candidate in python_join_assignment_index(tree).get(node.id, ()) + ) + if isinstance(node, ast.Call): + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr in {"values", "items"} ): - continue - if not candidate.args or not isinstance(candidate.args[0], ast.Constant): - return "Python __import__ launcher module is unresolved" - module = candidate.args[0].value - if module in python_command_modules: - return ( - "Python __import__ launcher chain reaches command-capable " - f"module {module!r}" + return python_sensitive_value_expression( + node.func.value, sensitive_names, tree, parents, seen.copy() ) - return None - - -def python_module_name(node, modules): - """Resolve a directly imported command-capable module or its alias.""" - if not isinstance(node, ast.Name): - return None - return modules.get(node.id) - + if python_unshadowed_builtin_call(node, {"list", "tuple", "iter", "next"}, tree, parents): + return any( + python_sensitive_join_argument( + argument, sensitive_names, tree, parents, seen.copy() + ) + for argument in node.args + ) + if python_unshadowed_builtin_call(node, {"map"}, tree, parents): + return any( + python_sensitive_join_argument( + argument, sensitive_names, tree, parents, seen.copy() + ) + for argument in node.args[1:] + ) + if isinstance(node, (ast.GeneratorExp, ast.ListComp, ast.SetComp)): + return any( + python_sensitive_join_argument( + generator.iter, sensitive_names, tree, parents, seen.copy() + ) + for generator in node.generators + ) + if isinstance(node, (ast.List, ast.Tuple)): + return any( + python_sensitive_join_argument( + element, sensitive_names, tree, parents, seen.copy() + ) + for element in node.elts + ) + return False -def python_indirect_command(node, modules): - """Classify getattr/__dict__ launcher indirection through known modules.""" - module_node = None - attribute_node = None - attribute_resolution = False - if isinstance(node, ast.Call): - if isinstance(node.func, ast.Name) and node.func.id == "getattr": - if len(node.args) < 2: - return ("unresolved", "getattr") - module_node, attribute_node = node.args[0], node.args[1] - elif ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "__getattribute__" - ): - attribute_resolution = True - if node.keywords: - return ("unresolved", "__getattribute__") - if ( - isinstance(node.func.value, ast.Name) - and node.func.value.id in {"object", "type"} - ): - if len(node.args) < 2: - return ("unresolved", "__getattribute__") - module_node, attribute_node = node.args[0], node.args[1] - elif len(node.args) >= 1: - module_node, attribute_node = node.func.value, node.args[0] - else: - return ("unresolved", "__getattribute__") - else: - return None - elif isinstance(node, ast.Subscript): - if not ( - isinstance(node.value, ast.Attribute) - and node.value.attr == "__dict__" - ): - return None - module_node = node.value.value - attribute_node = node.slice - if isinstance(attribute_node, ast.Index): - attribute_node = attribute_node.value - else: - return None - module_name = python_module_name(module_node, modules) - if module_name not in python_command_modules: - if attribute_resolution: - return ("unresolved", "__getattribute__") - return None - if ( - isinstance(attribute_node, ast.Constant) - and isinstance(attribute_node.value, str) - ): - resolved = f"{module_name}.{attribute_node.value}" - if resolved in python_command_functions: - return ("resolved", resolved) - return ("unresolved", module_name) +def python_sensitive_value_names(tree, parents): + """Resolve credential aliases and local-helper parameter taint.""" + tree._issue79_member_taint_enabled = False + sensitive_names = { + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module == "os" + for alias in node.names + if alias.name == "environ" + } + assignments = [] + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + assignments.extend((target, node.value) for target in node.targets) + elif isinstance(node, ast.AnnAssign) and node.value is not None: + assignments.append((node.target, node.value)) + elif isinstance(node, ast.NamedExpr): + assignments.append((node.target, node.value)) + elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)): + # Values yielded from an environment iterator remain sensitive in + # both loop bodies and comprehension elements. + assignments.append((node.target, node.iter)) + def target_names(target): + if isinstance(target, ast.Name): + return [target.id] + if isinstance(target, ast.Starred): + return target_names(target.value) + if isinstance(target, (ast.Tuple, ast.List)): + names = [] + for element in target.elts: + names.extend(target_names(element)) + return names + return [] -def python_stdin_command(tokens): - """Reject Python source supplied by a pipe, here-string or redirection.""" - tokens = executable_tokens(tokens) - if not tokens or not python_interpreter_token(tokens[0]): - return False - return "-" in tokens[1:] + def function_parameters(function): + positional = list(function.args.posonlyargs) + list(function.args.args) + return positional + list(function.args.kwonlyargs) + def call_arguments(call, function, bound_method=False): + positional_parameters = list(function.args.posonlyargs) + list( + function.args.args + ) + is_static_method = any( + python_dotted_name(decorator) == "staticmethod" + or ( + isinstance(decorator, ast.Name) + and python_assigned_callable_alias( + decorator.id, "staticmethod", tree + ) + ) + for decorator in getattr(function, "decorator_list", ()) + ) + if bound_method and positional_parameters and not is_static_method: + positional_parameters = positional_parameters[1:] + parameters = positional_parameters + list(function.args.kwonlyargs) + bound = [] + for index, argument in enumerate(call.args): + if isinstance(argument, ast.Starred): + for parameter in positional_parameters[index:]: + bound.append((parameter.arg, argument.value)) + elif index < len(positional_parameters): + bound.append((positional_parameters[index].arg, argument)) + if function.args.vararg is not None: + bound.append((function.args.vararg.arg, argument)) + parameter_by_name = {parameter.arg: parameter.arg for parameter in parameters} + for keyword in call.keywords: + if keyword.arg in parameter_by_name: + bound.append((keyword.arg, keyword.value)) + if keyword.arg is None: + # Expanded maps can bind any named parameter. Literal keys are + # handled precisely; unknown keys fail closed. + if isinstance(keyword.value, ast.Dict) and all( + isinstance(key, ast.Constant) and isinstance(key.value, str) + for key in keyword.value.keys + ): + for key, value in zip(keyword.value.keys, keyword.value.values): + if key.value in parameter_by_name: + bound.append((key.value, value)) + elif ( + isinstance(keyword.value, ast.Call) + and python_dotted_name(keyword.value.func) == "dict" + and not keyword.value.args + and all(item.arg is not None for item in keyword.value.keywords) + ): + for item in keyword.value.keywords: + if item.arg in parameter_by_name: + bound.append((item.arg, item.value)) + else: + bound.extend((parameter.arg, keyword.value) for parameter in parameters) + elif function.args.kwarg is not None and keyword.arg not in parameter_by_name: + bound.append((function.args.kwarg.arg, keyword.value)) + if keyword.arg is None and function.args.kwarg is not None: + bound.append((function.args.kwarg.arg, keyword.value)) + return bound -def reviewed_python_heredoc_segment(command, tokens): - """Allow only isolated Python heredocs to reach the AST body pass.""" - if not python_stdin_command(tokens): - return False - normalized = executable_tokens(tokens) - if not normalized or "<<" not in command: - return False - return any( - descriptor["invocation"] is not None - and descriptor["invocation"]["isolated"] - and descriptor["invocation"]["interpreter"] == normalized[0] - for descriptor in heredoc_descriptors(command) + # Iterate assignments and direct local-helper calls to a fixed point. The + # call-site pass closes the exact environment-map laundering gap where a + # helper parameter is later indexed or sent to a sink. + function_count = sum( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + for node in ast.walk(tree) ) + for _ in range(len(assignments) + function_count + 1): + changed = False + for function in ast.walk(tree): + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + positional = list(function.args.posonlyargs) + list(function.args.args) + defaults = list(zip(positional[-len(function.args.defaults):], function.args.defaults)) if function.args.defaults else [] + defaults.extend(zip(function.args.kwonlyargs, function.args.kw_defaults)) + for parameter, default in defaults: + if default is not None and python_sensitive_value_expression( + default, sensitive_names, tree, parents + ) and parameter.arg not in sensitive_names: + sensitive_names.add(parameter.arg) + changed = True + for target, value in assignments: + if not python_sensitive_value_expression( + value, sensitive_names, tree, parents + ): + continue + for name in target_names(target): + if name not in sensitive_names: + sensitive_names.add(name) + changed = True + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + call_values = list(node.args) + [ + keyword.value for keyword in node.keywords + ] + if not any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents + ) + for value in call_values + ): + continue + if isinstance(node.func, ast.Lambda): + candidates = [(node.func, False)] + elif isinstance(node.func, ast.Name): + candidates = [ + (function, False) + for function in python_local_function_candidates( + node.func.id, node, tree, parents + ) + python_local_lambda_candidates( + node.func.id, node, tree, parents + ) + ] + [ + (method, True) + for method in python_local_bound_method_candidates( + node.func.id, node, tree, parents + ) + ] + elif isinstance(node.func, ast.Attribute): + candidates = [ + (method, True) + for method in python_local_method_candidates( + node.func, node, tree, parents + ) + ] + else: + continue + for function, bound_method in candidates: + for parameter, argument in call_arguments( + node, function, bound_method=bound_method + ): + if python_sensitive_value_expression( + argument, sensitive_names, tree, parents + ) and parameter not in sensitive_names: + sensitive_names.add(parameter) + changed = True + if not changed: + break + tree._issue79_member_taint_enabled = True + return sensitive_names -def python_mapping_value(node, modules, functions): - """Resolve a mapping value only when it is a known launcher.""" - indirect = python_indirect_command(node, modules) - if indirect is not None: - return indirect - resolved = python_resolved_name(node, modules, functions) - if resolved in python_command_functions: - return ("resolved", resolved) - if isinstance(node, (ast.Name, ast.Attribute, ast.Call, ast.Subscript)): - return ("unresolved", resolved or "mapping value") - return ("safe", resolved) - - -def python_mapping_bindings(tree, modules, functions): - """Collect literal mapping aliases and preserve unknown keys fail-closed.""" - mappings = {} +def python_dynamic_execution_bindings(tree): + """Track aliases to built-in dynamic execution primitives conservatively.""" + bindings = set(python_dynamic_execution_names) + unresolved = set() + container_values = {} - def mapping_target_names(node): - if isinstance(node, ast.Name): - return [node.id] - if isinstance(node, (ast.Tuple, ast.List)): + def target_names(target): + if isinstance(target, ast.Name): + return [target.id] + if isinstance(target, (ast.Tuple, ast.List)): names = [] - for element in node.elts: - names.extend(mapping_target_names(element)) + for element in target.elts: + names.extend(target_names(element)) return names return [] - def literal_mapping(value): - if not isinstance(value, ast.Dict): - return None - entries = {} - uncertain = False - for key, element in zip(value.keys, value.values): - if not isinstance(key, ast.Constant) or not isinstance(key.value, str): - uncertain = True - continue - resolved = python_mapping_value(element, modules, functions) - if resolved[0] == "unresolved": - uncertain = True - entries[key.value] = None - elif resolved[0] == "resolved": - entries[key.value] = resolved[1] - else: - entries[key.value] = False - return entries, uncertain - - assignments = [] - updates = [] + assignment_values = [] for node in ast.walk(tree): - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Subscript) and isinstance( - target.value, ast.Name - ): - updates.append((target.value.id, target.slice, node.value)) - else: - assignments.append((target, node.value)) + if isinstance(node, ast.ImportFrom) and node.module in {"builtins", "__builtin__"}: + for alias in node.names: + if alias.name in python_dynamic_execution_names: + bindings.add(alias.asname or alias.name) + elif isinstance(node, ast.Assign): + assignment_values.extend((target, node.value) for target in node.targets) elif isinstance(node, ast.AnnAssign): - assignments.append((node.target, node.value)) + assignment_values.append((node.target, node.value)) elif isinstance(node, ast.NamedExpr): - assignments.append((node.target, node.value)) - elif ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "update" - and isinstance(node.func.value, ast.Name) - ): - if node.args and isinstance(node.args[0], ast.Dict): - updates.append((node.func.value.id, None, node.args[0])) - else: - updates.append((node.func.value.id, None, None)) + assignment_values.append((node.target, node.value)) - def merge(previous, current): - if previous is None: - return current - previous_entries, previous_uncertain = previous - current_entries, current_uncertain = current - entries = dict(previous_entries) - uncertain = previous_uncertain or current_uncertain - for key, value in current_entries.items(): - if key in entries and entries[key] != value: + def container_source(node, seen=None): + if seen is None: + seen = set() + if node is None or id(node) in seen: + return None + seen.add(id(node)) + if isinstance(node, (ast.List, ast.Tuple, ast.Dict, ast.Set)): + return node + if isinstance(node, ast.Name) and node.id in container_values: + return container_source(container_values[node.id], seen) + return None + + def dynamic_state(node, seen=None): + """Return safe/dynamic/unresolved/unknown for container alias values.""" + if seen is None: + seen = set() + if node is None or id(node) in seen: + return "unknown" + seen.add(id(node)) + if isinstance(node, ast.Name): + if node.id in bindings: + return "dynamic" + if node.id in unresolved: + return "unresolved" + if node.id in container_values: + return dynamic_state(container_values[node.id], seen) + return "unknown" + if isinstance(node, ast.Constant): + return "safe" + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + states = [dynamic_state(element, seen.copy()) for element in node.elts] + if "dynamic" in states: + return "dynamic" + if "unresolved" in states: + return "unresolved" + if "unknown" in states: + return "unknown" + return "safe" + if isinstance(node, ast.Dict): + states = [ + dynamic_state(element, seen.copy()) + for element in node.values + if element is not None + ] + if "dynamic" in states: + return "dynamic" + if "unresolved" in states: + return "unresolved" + if "unknown" in states: + return "unknown" + return "safe" + if isinstance(node, ast.Subscript): + source = container_source(node.value, seen.copy()) + if source is None: + return "unknown" + key = node.slice + if isinstance(key, ast.Index): + key = key.value + selected = None + if isinstance(source, (ast.List, ast.Tuple)): + if isinstance(key, ast.Constant) and isinstance(key.value, int): + index = key.value + if -len(source.elts) <= index < len(source.elts): + selected = source.elts[index] + elif isinstance(source, ast.Dict): + if isinstance(key, ast.Constant) and isinstance(key.value, str): + for candidate, value in zip(source.keys, source.values): + if ( + isinstance(candidate, ast.Constant) + and candidate.value == key.value + ): + selected = value + break + if selected is not None: + return dynamic_state(selected, seen.copy()) + container_state = dynamic_state(source, seen.copy()) + if container_state in {"dynamic", "unresolved"}: + return "unresolved" + return "unknown" + return "unknown" + + for _ in range(len(assignment_values) * 2 + 1): + changed = False + for target, value in assignment_values: + dotted = python_dotted_name(value) + dynamic_getattr = ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "getattr" + and value.args + and python_dotted_name(value.args[0]) in {"builtins", "__builtins__"} + ) + for name in target_names(target): + prior_container = container_values.get(name) + if isinstance(value, (ast.List, ast.Tuple, ast.Dict, ast.Set)) or ( + isinstance(value, ast.Name) and value.id in container_values + ): + if prior_container is not value: + container_values[name] = value + changed = True + state = dynamic_state(value) if ( - entries[key] in python_command_functions - or value in python_command_functions - or entries[key] is None - or value is None + isinstance(value, ast.Name) and value.id in bindings + ) or ( + dotted is not None + and dotted.rsplit(".", 1)[-1] in python_dynamic_execution_names ): - entries[key] = None - uncertain = True - else: - entries[key] = value - else: - entries[key] = value - return entries, uncertain + if name not in bindings: + bindings.add(name) + changed = True + elif state == "dynamic": + if name not in bindings: + bindings.add(name) + changed = True + elif dynamic_getattr and name not in unresolved: + unresolved.add(name) + changed = True + elif state == "unresolved" and name not in unresolved: + unresolved.add(name) + changed = True + if not changed: + break + return bindings, unresolved + + +def python_dynamic_execution_target(node, bindings, unresolved): + if isinstance(node, ast.Name): + if node.id in bindings: + return node.id + if node.id in unresolved: + return "unresolved dynamic Python execution alias" + dotted = python_dotted_name(node) + if dotted and dotted.rsplit(".", 1)[-1] in python_dynamic_execution_names: + return dotted.rsplit(".", 1)[-1] + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "getattr": + if node.args and python_dotted_name(node.args[0]) in {"builtins", "__builtins__"}: + if ( + len(node.args) > 1 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value in python_dynamic_execution_names + ): + return node.args[1].value + return "unresolved dynamic Python execution primitive" + if isinstance(node, ast.Subscript) and python_dotted_name(node.value) in { + "builtins", "__builtins__", + }: + return "unresolved dynamic Python execution primitive" + if isinstance(node, ast.Subscript) and isinstance( + node.value, (ast.List, ast.Tuple, ast.Dict, ast.Set) + ): + return "unresolved dynamic Python execution primitive" + return None + + +reviewed_python_compile_source_names = { + "wrapper", + "previous_wrapper", + "current_wrapper", + "prior_wrapper", + "previous", + "packet", + "prefix", + "scanner", + "setup", + "helper_source", +} +reviewed_python_compile_ast_names = { + "module", + "validator", + "process_functions", + "init_helpers", + "selected_nodes", + "selected", + "module_node", + "terminator", + "helper", +} +reviewed_python_compile_slice_bases = reviewed_python_compile_source_names + + +def python_compile_provenance(tree): + """Resolve source/AST provenance before permitting static compile/exec.""" + source_names = set() + ast_names = set() + packet_path_names = set() + + def target_names(target): + if isinstance(target, ast.Name): + return [target.id] + if isinstance(target, (ast.Tuple, ast.List)): + names = [] + for element in target.elts: + names.extend(target_names(element)) + return names + return [] + + def contains_packet_path(node): + return any( + isinstance(candidate, ast.Constant) + and isinstance(candidate.value, str) + and "docs/evidence/g01-recovery-packet.md" in candidate.value + for candidate in ast.walk(node) + ) or any( + isinstance(candidate, ast.Name) and candidate.id in packet_path_names + for candidate in ast.walk(node) + ) + + def packet_path_value(value): + literal_path = ( + isinstance(value, ast.Constant) + and isinstance(value.value, str) + and "docs/evidence/g01-recovery-packet.md" in value.value + ) + path_constructor = ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) in {"Path", "pathlib.Path"} + and contains_packet_path(value) + ) + return literal_path or path_constructor + + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + value = node.value + if not packet_path_value(value): + continue + packet_path_names.update(target_names(node.targets[0])) + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + literals = { + candidate.value + for candidate in ast.walk(node) + if isinstance(candidate, ast.Constant) + and isinstance(candidate.value, str) + } + if ( + any('source = Path("docs/evidence/g01-recovery-packet.md")' in value for value in literals) + and any("matches = []" in value for value in literals) + ): + source_names.update(argument.arg for argument in node.args.args) + + def source_value(node): + if isinstance(node, ast.Name): + return node.id in source_names + if isinstance(node, ast.Subscript): + return source_value(node.value) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return source_value(node.left) and source_value(node.right) + if isinstance(node, ast.Call): + dotted = python_dotted_name(node.func) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "replace" + ): + return source_value(node.func.value) + if isinstance(node.func, ast.Attribute) and node.func.attr == "read_text": + receiver = node.func.value + return ( + isinstance(receiver, ast.Call) + and python_dotted_name(receiver.func) in {"Path", "pathlib.Path"} + and receiver.args + and contains_packet_path(receiver) + ) or ( + isinstance(receiver, ast.Name) + and receiver.id in packet_path_names + ) + if dotted == "subprocess.check_output": + return contains_packet_path(node) + return False + + def ast_value(node): + if isinstance(node, ast.Name): + return node.id in ast_names + if isinstance(node, ast.Call): + return python_dotted_name(node.func) in {"ast.parse", "ast.Module"} + if isinstance(node, (ast.List, ast.Tuple)): + return bool(node.elts) and all(ast_value(element) for element in node.elts) + return False + + assignments = [] + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + assignments.extend((target, node.value) for target in node.targets) + elif isinstance(node, ast.AnnAssign) and node.value is not None: + assignments.append((node.target, node.value)) + elif isinstance(node, ast.NamedExpr): + assignments.append((node.target, node.value)) + tainted_packet_path_names = { + name + for target, value in assignments + for name in target_names(target) + if name in packet_path_names and not packet_path_value(value) + } + packet_path_names.difference_update(tainted_packet_path_names) + for _ in range(len(assignments) + 1): + changed = False + for target, value in assignments: + names = target_names(target) + if source_value(value): + for name in names: + if name not in source_names: + source_names.add(name) + changed = True + if ast_value(value): + for name in names: + if name not in ast_names: + ast_names.add(name) + changed = True + if not changed: + break + invalidated_source_names = { + name + for target, value in assignments + for name in target_names(target) + if name in source_names and not source_value(value) + } + invalidated_ast_names = { + name + for target, value in assignments + for name in target_names(target) + if name in ast_names and not ast_value(value) + } + source_names.difference_update(invalidated_source_names) + ast_names.difference_update(invalidated_ast_names) + return source_names, ast_names + + +def reviewed_python_compile_source(node, provenance=None): + """Permit only packet-derived source slices or provenance-checked AST nodes.""" + source_provenance, ast_provenance = provenance or (set(), set()) + if isinstance(node, ast.Name): + return ( + node.id in reviewed_python_compile_source_names + and node.id in source_provenance + ) or ( + node.id in reviewed_python_compile_ast_names + and node.id in ast_provenance + ) + if isinstance(node, ast.Subscript): + if not ( + isinstance(node.value, ast.Name) + and node.value.id in reviewed_python_compile_slice_bases + and node.value.id in source_provenance + and isinstance(node.slice, ast.Slice) + ): + return False + return all( + part is None or isinstance(part, ast.Name) + for part in (node.slice.lower, node.slice.upper, node.slice.step) + ) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return reviewed_python_compile_source(node.left, provenance) and reviewed_python_compile_source( + node.right, provenance + ) + if not ( + isinstance(node, ast.Call) + and python_dotted_name(node.func) == "ast.Module" + and not node.args + ): + return False + keywords = {keyword.arg: keyword.value for keyword in node.keywords} + if set(keywords) != {"body", "type_ignores"}: + return False + type_ignores = keywords["type_ignores"] + if not isinstance(type_ignores, ast.List) or type_ignores.elts: + return False + body = keywords["body"] + if isinstance(body, ast.Name): + return body.id in reviewed_python_compile_ast_names + if not isinstance(body, ast.List) or not body.elts: + return False + for element in body.elts: + if isinstance(element, ast.Name): + if element.id not in reviewed_python_compile_ast_names: + return False + elif not ( + isinstance(element, ast.Subscript) + and isinstance(element.value, ast.Name) + and element.value.id == "functions" + and isinstance(element.slice, ast.Constant) + and isinstance(element.slice.value, str) + ): + return False + return True + + +def reviewed_python_exec_call(call, safe_marker, tree=None): + """Allow only the packet's static compile/exec metaprogramming path.""" + if not isinstance(call.func, ast.Name) or call.func.id != "exec": + return False + if not call.args or not isinstance(call.args[0], ast.Call): + return False + compiler = call.args[0] + if not isinstance(compiler.func, ast.Name) or compiler.func.id != "compile": + return False + if len(compiler.args) != 3 or compiler.keywords: + return False + provenance = python_compile_provenance(tree) if tree is not None else (set(), set()) + if not reviewed_python_compile_source(compiler.args[0], provenance): + return False + filename, mode = compiler.args[1:3] + return ( + isinstance(filename, ast.Constant) + and isinstance(filename.value, str) + and filename.value.startswith("<") + and filename.value.endswith(">") + and isinstance(mode, ast.Constant) + and mode.value == "exec" + ) + + +def python_indirect_execution_violation(tree, parents, safe_marker): + """Reject compiled/function-object execution outside reviewed AST probes.""" + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + dotted = python_dotted_name(node.func) + if dotted in python_indirect_execution_constructors: + return ( + "Python heredoc contains an indirect executable function object " + f"{dotted!r} on line {node.lineno}" + ) + if dotted != "compile": + continue + reviewed_parent = any( + isinstance(parent, ast.Call) + and parent.args + and parent.args[0] is node + and reviewed_python_exec_call(parent, safe_marker, tree) + for parent in _python_parent_chain(node, parents) + ) + if not reviewed_parent: + return ( + "Python heredoc contains an unreviewed compile call " + f"on line {node.lineno}" + ) + return None + + +def reviewed_python_import_call(call, safe_marker): + """Allow only literal imports used by packet-only probes.""" + if not isinstance(call.func, ast.Name) or call.func.id != "__import__": + return False + if not call.args or not isinstance(call.args[0], ast.Constant): + return False + module = call.args[0].value + return isinstance(module, str) and module in reviewed_python_import_modules + + +def python_import_launcher_violation(node): + """Reject command-capable modules reached through a dynamic import chain.""" + for candidate in ast.walk(node): + if not ( + isinstance(candidate, ast.Call) + and isinstance(candidate.func, ast.Name) + and candidate.func.id == "__import__" + ): + continue + if not candidate.args or not isinstance(candidate.args[0], ast.Constant): + return "Python __import__ launcher module is unresolved" + module = candidate.args[0].value + if module in python_command_modules: + return ( + "Python __import__ launcher chain reaches command-capable " + f"module {module!r}" + ) + return None + + +def python_module_name(node, modules): + """Resolve a directly imported command-capable module or its alias.""" + if not isinstance(node, ast.Name): + return None + return modules.get(node.id) + + +def python_indirect_command(node, modules): + """Classify getattr/__dict__ launcher indirection through known modules.""" + module_node = None + attribute_node = None + attribute_resolution = False + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id == "getattr": + if len(node.args) < 2: + return ("unresolved", "getattr") + module_node, attribute_node = node.args[0], node.args[1] + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "__getattribute__" + ): + attribute_resolution = True + if node.keywords: + return ("unresolved", "__getattribute__") + if ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in {"object", "type"} + ): + if len(node.args) < 2: + return ("unresolved", "__getattribute__") + module_node, attribute_node = node.args[0], node.args[1] + elif len(node.args) >= 1: + module_node, attribute_node = node.func.value, node.args[0] + else: + return ("unresolved", "__getattribute__") + else: + return None + elif isinstance(node, ast.Subscript): + if not ( + isinstance(node.value, ast.Attribute) + and node.value.attr == "__dict__" + ): + return None + module_node = node.value.value + attribute_node = node.slice + if isinstance(attribute_node, ast.Index): + attribute_node = attribute_node.value + else: + return None + module_name = python_module_name(module_node, modules) + if module_name not in python_command_modules: + if attribute_resolution: + return ("unresolved", "__getattribute__") + return None + if ( + isinstance(attribute_node, ast.Constant) + and isinstance(attribute_node.value, str) + ): + resolved = f"{module_name}.{attribute_node.value}" + if resolved in python_command_functions: + return ("resolved", resolved) + return ("unresolved", module_name) + + +def python_stdin_command(tokens): + """Reject Python source supplied by a pipe, here-string or redirection.""" + tokens = executable_tokens(tokens) + if not tokens or not python_interpreter_token(tokens[0]): + return False + return "-" in tokens[1:] + + +def reviewed_python_heredoc_segment(command, tokens): + """Allow only isolated Python heredocs to reach the AST body pass.""" + if not python_stdin_command(tokens): + return False + normalized = executable_tokens(tokens) + if not normalized or "<<" not in command: + return False + return any( + descriptor["invocation"] is not None + and descriptor["invocation"]["isolated"] + and descriptor["invocation"]["interpreter"] == normalized[0] + for descriptor in heredoc_descriptors(command) + ) + + +def python_mapping_value(node, modules, functions): + """Resolve a mapping value only when it is a known launcher.""" + indirect = python_indirect_command(node, modules) + if indirect is not None: + return indirect + resolved = python_resolved_name(node, modules, functions) + if resolved in python_command_functions: + return ("resolved", resolved) + if isinstance(node, (ast.Name, ast.Attribute, ast.Call, ast.Subscript)): + return ("unresolved", resolved or "mapping value") + return ("safe", resolved) + + +def python_mapping_bindings(tree, modules, functions): + """Collect literal mapping aliases and preserve unknown keys fail-closed.""" + mappings = {} + + def mapping_target_names(node): + if isinstance(node, ast.Name): + return [node.id] + if isinstance(node, (ast.Tuple, ast.List)): + names = [] + for element in node.elts: + names.extend(mapping_target_names(element)) + return names + return [] + + def literal_mapping(value): + if not isinstance(value, ast.Dict): + return None + entries = {} + uncertain = False + for key, element in zip(value.keys, value.values): + if not isinstance(key, ast.Constant) or not isinstance(key.value, str): + uncertain = True + continue + resolved = python_mapping_value(element, modules, functions) + if resolved[0] == "unresolved": + uncertain = True + entries[key.value] = None + elif resolved[0] == "resolved": + entries[key.value] = resolved[1] + else: + entries[key.value] = False + return entries, uncertain + + assignments = [] + updates = [] + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Subscript) and isinstance( + target.value, ast.Name + ): + updates.append((target.value.id, target.slice, node.value)) + else: + assignments.append((target, node.value)) + elif isinstance(node, ast.AnnAssign): + assignments.append((node.target, node.value)) + elif isinstance(node, ast.NamedExpr): + assignments.append((node.target, node.value)) + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "update" + and isinstance(node.func.value, ast.Name) + ): + if node.args and isinstance(node.args[0], ast.Dict): + updates.append((node.func.value.id, None, node.args[0])) + else: + updates.append((node.func.value.id, None, None)) + + def merge(previous, current): + if previous is None: + return current + previous_entries, previous_uncertain = previous + current_entries, current_uncertain = current + entries = dict(previous_entries) + uncertain = previous_uncertain or current_uncertain + for key, value in current_entries.items(): + if key in entries and entries[key] != value: + if ( + entries[key] in python_command_functions + or value in python_command_functions + or entries[key] is None + or value is None + ): + entries[key] = None + uncertain = True + else: + entries[key] = value + else: + entries[key] = value + return entries, uncertain + + for _ in range(len(assignments) + 1): + changed = False + for target, value in assignments: + mapping = literal_mapping(value) + if mapping is None and isinstance(value, ast.Name): + mapping = mappings.get(value.id) + if mapping is None: + continue + for name in mapping_target_names(target): + merged = merge(mappings.get(name), mapping) + if mappings.get(name) != merged: + mappings[name] = merged + changed = True + for name, key, value in updates: + if value is None: + mapping = ({}, True) + elif key is None: + mapping = literal_mapping(value) + if mapping is None: + mapping = ({}, True) + else: + resolved = python_mapping_value(value, modules, functions) + if resolved[0] == "resolved": + entry = resolved[1] + elif resolved[0] == "unresolved": + entry = None + else: + entry = False + if isinstance(key, ast.Constant) and isinstance(key.value, str): + mapping = ({key.value: entry}, False) + else: + mapping = ({"__unknown__": entry}, True) + merged = merge(mappings.get(name), mapping) + if mappings.get(name) != merged: + mappings[name] = merged + changed = True + if not changed: + break + return mappings + + +def python_mapping_lookup_alias_violation(tree, mappings): + """Reject aliases that invoke lookup methods on launcher-bearing maps.""" + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + assignments = [] + bindings = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + assignments.append( + (target, node.value, python_enclosing_scope(node, parents)) + ) + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None: + assignments.append( + (node.target, node.value, python_enclosing_scope(node, parents)) + ) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + scope = python_enclosing_scope(node, parents) + bindings.add((id(scope), node.id)) + elif isinstance(node, ast.arg): + scope = python_enclosing_scope(node, parents) + bindings.add((id(scope), node.arg)) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + scope = python_enclosing_scope(parents.get(node), parents) + bindings.add((id(scope), node.name)) + elif isinstance(node, ast.Import): + scope = python_enclosing_scope(node, parents) + for imported in node.names: + bindings.add((id(scope), imported.asname or imported.name.split(".", 1)[0])) + elif isinstance(node, ast.ImportFrom): + scope = python_enclosing_scope(node, parents) + for imported in node.names: + bindings.add((id(scope), imported.asname or imported.name)) + + aliases = {} + + def nearest_binding(name, scope): + for visible_scope in python_lexical_scope_chain(scope, parents): + key = (id(visible_scope), name) + if key in bindings: + return key + return None + + def launcher_bearing(mapping_name): + if mapping_name not in mappings: + return False + entries, _uncertain = mappings[mapping_name] + return any( + value in python_command_functions for value in entries.values() + ) or any( + key.rsplit(".", 1)[-1].lower() in python_command_leaf_names + for key in entries + ) + + for _ in range(len(assignments) + 1): + changed = False + for target, value, scope in assignments: + if not isinstance(target, ast.Name): + continue + mapping_name = None + if ( + isinstance(value, ast.Attribute) + and value.attr in {"get", "pop", "__getitem__"} + and isinstance(value.value, ast.Name) + and value.value.id in mappings + ): + mapping_name = value.value.id + elif ( + isinstance(value, ast.Call) + and ( + python_dotted_name(value.func) == "getattr" + or ( + isinstance(value.func, ast.Name) + and python_assigned_callable_alias( + value.func.id, "getattr", tree + ) + ) + ) + and len(value.args) in {2, 3} + and isinstance(value.args[0], ast.Name) + and value.args[0].id in mappings + and python_static_string_values_from_local_calls( + value.args[1], tree, parents + ) & {"get", "pop", "__getitem__"} + ): + mapping_name = value.args[0].id + elif isinstance(value, ast.Name): + source_binding = nearest_binding(value.id, scope) + mapping_name = aliases.get(source_binding) + target_binding = (id(scope), target.id) + if mapping_name is None or not launcher_bearing(mapping_name): + continue + if aliases.get(target_binding) != mapping_name: + aliases[target_binding] = mapping_name + changed = True + if not changed: + break + + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + call_scope = python_enclosing_scope(node, parents) + binding = nearest_binding(node.func.id, call_scope) + if binding in aliases: + return ( + "Python unresolved command-capable mapping lookup alias " + f"{node.func.id!r} is not allowed on line {node.lineno}" + ) + return None + + +def python_mapping_command(node, mappings, modules, functions): + """Classify mapping/subscript launcher calls, including `.get` indirection.""" + mapping_name = None + key_node = None + if isinstance(node, ast.Subscript): + if isinstance(node.value, ast.Name): + mapping_name = node.value.id + elif isinstance(node.value, ast.Dict): + return ("unresolved", "mapping literal") + else: + return ("unresolved", "mapping subscript") + key_node = node.slice + if isinstance(key_node, ast.Index): + key_node = key_node.value + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in {"get", "__getitem__"} + ): + if isinstance(node.func.value, ast.Name): + mapping_name = node.func.value.id + else: + return ("unresolved", "mapping literal") + key_node = node.args[0] if node.args else None + else: + return None + if mapping_name not in mappings: + return None + entries, uncertain = mappings[mapping_name] + known_command = any(value in python_command_functions for value in entries.values()) + command_named_key = any( + key.rsplit(".", 1)[-1].lower() in python_command_leaf_names + for key in entries + ) + if not known_command and not command_named_key: + return None + if not isinstance(key_node, ast.Constant) or not isinstance(key_node.value, str): + return ("unresolved", mapping_name) + if key_node.value not in entries: + return ("unresolved", mapping_name) + resolved = entries[key_node.value] + if resolved in python_command_functions: + return ("resolved", resolved) + if resolved is None or uncertain: + return ("unresolved", mapping_name) + return ("safe", resolved) + + +def python_call_derived_command_alias(node, modules): + """Treat call-returned module lookups as unresolved launcher aliases.""" + if not isinstance(node, ast.Call): + return False + if python_indirect_command(node, modules) is not None: + return True + if isinstance(node.func, ast.Name) and node.func.id == "vars" and node.args: + return python_module_name(node.args[0], modules) in python_command_modules + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr in {"get", "__getitem__"} + ): + receiver = node.func.value + if isinstance(receiver, ast.Attribute) and receiver.attr == "__dict__": + return python_module_name(receiver.value, modules) in python_command_modules + if ( + isinstance(receiver, ast.Call) + and isinstance(receiver.func, ast.Name) + and receiver.func.id == "vars" + and receiver.args + ): + return python_module_name(receiver.args[0], modules) in python_command_modules + return False + + +def python_import_bindings(tree): + modules = {} + functions = {} + unresolved = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in python_command_modules: + modules[alias.asname or alias.name] = alias.name + elif isinstance(node, ast.ImportFrom) and node.module in python_command_modules: + for alias in node.names: + if alias.name == "*": + unresolved.append(node.lineno) + continue + imported = f"{node.module}.{alias.name}" + if imported in python_command_functions: + functions[alias.asname or alias.name] = imported + assignments = [ + node for node in ast.walk(tree) + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.NamedExpr)) + ] + + def assignment_parts(node): + if isinstance(node, ast.Assign): + return node.targets, node.value + if isinstance(node, ast.AnnAssign): + return [node.target], node.value + if isinstance(node, ast.NamedExpr): + return [node.target], node.value + return [node.target], None + + def target_names(target): + if isinstance(target, ast.Name): + return [target] + if isinstance(target, ast.Starred): + return target_names(target.value) + if isinstance(target, (ast.Tuple, ast.List)): + names = [] + for element in target.elts: + names.extend(target_names(element)) + return names + return [] + + def binding_pairs(target, value): + """Recursively pair destructured targets with their source values.""" + if isinstance(target, ast.Name): + return [(target, value)] + if isinstance(target, ast.Starred): + return binding_pairs(target.value, value) + if not isinstance(target, (ast.Tuple, ast.List)): + return [] + if not isinstance(value, (ast.Tuple, ast.List)): + return [(name, None) for name in target_names(target)] + pairs = [] + for index, element in enumerate(target.elts): + source = value.elts[index] if index < len(value.elts) else None + pairs.extend(binding_pairs(element, source)) + return pairs + + assignment_bindings = [] + for node in assignments: + targets, value = assignment_parts(node) + for target in targets: + destructured = not isinstance(target, ast.Name) + assignment_bindings.extend( + (bound_target, bound_value, destructured) + for bound_target, bound_value in binding_pairs(target, value) + ) + + assigned_values = {} + for target, value, _destructured in assignment_bindings: + if isinstance(target, ast.Name) and value is not None: + assigned_values.setdefault(target.id, []).append(value) + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + + iterable_bindings = [] + starred_iterable_aliases = set() + for node in ast.walk(tree): + if isinstance(node, (ast.For, ast.AsyncFor)): + target, value = node.target, node.iter + elif isinstance(node, ast.comprehension): + target, value = node.target, node.iter + else: + continue + if any(isinstance(candidate, ast.Starred) for candidate in ast.walk(target)): + starred_iterable_aliases.update( + bound_target.id for bound_target in target_names(target) + ) + iterable_bindings.extend( + (bound_target, value) + for bound_target in target_names(target) + ) + + # Only names that could denote a command-capable launcher are tracked. + # Ordinary direct callable assignments (for example `validator = + # namespace.get`) remain outside this map. Destructured assignments are + # tracked conservatively because an unresolved element cannot be proven + # non-launching; such names remain mapped to None and fail closed. + candidate_aliases = set() + + def iterable_may_contain_launcher( + value, seen_names=None, seen_nodes=None, seen_functions=None + ): + if value is None: + return False + if seen_names is None: + seen_names = set() + if seen_nodes is None: + seen_nodes = set() + if seen_functions is None: + seen_functions = set() + if id(value) in seen_nodes: + return False + seen_nodes.add(id(value)) + if isinstance(value, ast.Name): + resolved = python_resolved_name(value, modules, functions) + if resolved in python_command_functions or value.id in candidate_aliases: + return True + if value.id in modules: + return False + if value.id in functions and functions[value.id] is None: + return True + if value.id in seen_names: + return False + seen_names.add(value.id) + return any( + iterable_may_contain_launcher( + candidate, + set(seen_names), + set(seen_nodes), + set(seen_functions), + ) + for candidate in assigned_values.get(value.id, ()) + ) + if isinstance(value, ast.Attribute): + resolved = python_resolved_name(value, modules, functions) + if resolved in python_command_functions: + return True + dotted = python_dotted_name(value) + if dotted and dotted.rsplit(".", 1)[-1] in python_command_leaf_names: + root = dotted.split(".", 1)[0] + return root in modules or root in candidate_aliases or root in functions + return False + if python_call_derived_command_alias(value, modules): + return True + if isinstance(value, ast.Call): + for candidate in python_local_call_return_values(value, tree, parents): + if iterable_may_contain_launcher( + candidate, + set(seen_names), + set(seen_nodes), + seen_functions | {python_dotted_name(value.func) or "call"}, + ): + return True + if ( + isinstance(value.func, ast.Attribute) + and value.func.attr in {"get", "items", "keys", "values", "pop"} + ): + return iterable_may_contain_launcher( + value.func.value, + set(seen_names), + set(seen_nodes), + set(seen_functions), + ) + if ( + isinstance(value.func, ast.Name) + and value.func.id in { + "dict", "enumerate", "iter", "list", "reversed", "set", + "filter", "map", "sorted", "tuple", "zip", + } + ): + return any( + iterable_may_contain_launcher( + argument, + set(seen_names), + set(seen_nodes), + set(seen_functions), + ) + for argument in value.args + ) + if ( + isinstance(value.func, ast.Attribute) + and python_dotted_name(value.func) == "dict.fromkeys" + ): + return any( + iterable_may_contain_launcher( + argument, + set(seen_names), + set(seen_nodes), + set(seen_functions), + ) + for argument in value.args + ) + if isinstance(value, ast.Starred): + return iterable_may_contain_launcher( + value.value, + set(seen_names), + set(seen_nodes), + set(seen_functions), + ) + if isinstance( + value, + ( + ast.List, + ast.Tuple, + ast.Set, + ast.Dict, + ast.ListComp, + ast.SetComp, + ast.DictComp, + ast.GeneratorExp, + ast.Subscript, + ast.IfExp, + ast.BinOp, + ast.BoolOp, + ), + ): + return any( + iterable_may_contain_launcher( + child, + set(seen_names), + set(seen_nodes), + set(seen_functions), + ) + for child in ast.iter_child_nodes(value) + if not isinstance( + child, + (ast.expr_context, ast.operator, ast.unaryop, ast.boolop, ast.cmpop), + ) + ) + return False + + candidate_bindings = [ + (target, value, destructured, False) + for target, value, destructured in assignment_bindings + ] + [ + (target, value, False, True) + for target, value in iterable_bindings + ] + for _ in range(len(candidate_bindings) + 1): + changed = False + for target, value, destructured, iterable_target in candidate_bindings: + if not isinstance(target, ast.Name): + continue + dotted = python_dotted_name(value) if value is not None else None + commandish = bool( + dotted and dotted.rsplit(".", 1)[-1] in python_command_leaf_names + ) + if ( + destructured + or commandish + or ( + value is not None + and iterable_may_contain_launcher(value) + ) + or python_call_derived_command_alias(value, modules) + or target.id in modules + or target.id in functions + or (isinstance(value, ast.Name) and value.id in candidate_aliases) + or (isinstance(value, ast.Name) and value.id in modules) + or (isinstance(value, ast.Name) and value.id in functions) + or ( + ( + iterable_target + or isinstance( + value, + ( + ast.List, + ast.Tuple, + ast.Set, + ast.Dict, + ast.ListComp, + ast.SetComp, + ast.DictComp, + ast.GeneratorExp, + ast.Subscript, + ), + ) + ) + and iterable_may_contain_launcher(value) + ) + ) and target.id not in candidate_aliases: + candidate_aliases.add(target.id) + changed = True + if not changed: + break + for alias in candidate_aliases: + modules.pop(alias, None) + functions[alias] = ( + unresolved_starred_launcher_container + if alias in starred_iterable_aliases + else None + ) + + def resolve_binding(node): + dotted = python_dotted_name(node) + if dotted is None: + return None + if dotted in functions: + return functions[dotted] + parts = dotted.split(".") + module = modules.get(parts[0]) + if module is not None and len(parts) > 1: + return ".".join([module, *parts[1:]]) + if parts[0] in functions and functions[parts[0]] is None: + return None + return dotted + + # Resolve alias chains such as `launch = subprocess.run` and + # `again = launch` without trusting their source order. Unknown or + # shadowed assignment targets remain mapped to None and are rejected when + # called below. + for _ in range(len(assignment_bindings) + 1): + changed = False + for target, value, _destructured in sorted( + assignment_bindings, + key=lambda item: (item[0].lineno, item[0].col_offset), + ): + if value is None: + continue + resolved = resolve_binding(value) + if resolved not in python_command_functions: + continue + if target.id not in candidate_aliases: + continue + if functions.get(target.id) != resolved: + functions[target.id] = resolved + changed = True + if not changed: + break + # A command-capable assignment that cannot be resolved remains mapped to + # None and is rejected when called below. + return modules, functions, unresolved + + +def python_resolved_name(node, modules, functions): + dotted = python_dotted_name(node) + if dotted is None: + return None + if dotted in functions: + return functions[dotted] + parts = dotted.split(".") + if parts[0] in functions and functions[parts[0]] is None: + return None + module = modules.get(parts[0]) + if module is not None and len(parts) > 1: + return ".".join([module, *parts[1:]]) + return dotted + + +def python_class_command_attribute_violation(tree, modules, functions): + """Reject unresolved conditional and instance/class launcher attributes.""" + attributes = {} + + def record(target, value): + if not isinstance(target, ast.Attribute): + return + attribute_name = python_dotted_name(target) + if ( + attribute_name is None + or attribute_name.split(".", 1)[0] in python_command_modules + ): + return + resolved = python_resolved_name(value, modules, functions) + if resolved in python_command_functions: + attributes[attribute_name] = resolved + + def conditional_command_reference(value): + if not isinstance(value, ast.IfExp): + return None + for branch in (value.body, value.orelse): + resolved = python_resolved_name(branch, modules, functions) + if resolved in python_command_functions: + return resolved + dotted = python_dotted_name(branch) + if dotted and dotted.rsplit(".", 1)[-1] in python_command_leaf_names: + return dotted + return None + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Attribute): + record(target, node.value) + if conditional_command_reference(node.value) is not None: + return ( + "Python conditional process launcher assignment is not allowed " + f"on line {node.lineno}" + ) + elif isinstance(node, ast.AnnAssign): + record(node.target, node.value) + if conditional_command_reference(node.value) is not None: + return ( + "Python conditional process launcher assignment is not allowed " + f"on line {node.lineno}" + ) + elif isinstance(node, ast.NamedExpr): + if conditional_command_reference(node.value) is not None: + return ( + "Python conditional process launcher assignment is not allowed " + f"on line {node.lineno}" + ) + elif isinstance(node, ast.ClassDef): + if node.name in python_command_modules: + continue + for statement in node.body: + if isinstance(statement, ast.Assign): + for target in statement.targets: + if isinstance(target, ast.Name): + resolved = python_resolved_name(statement.value, modules, functions) + if resolved in python_command_functions: + attributes[f"{node.name}.{target.id}"] = resolved + elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): + resolved = python_resolved_name(statement.value, modules, functions) + if resolved in python_command_functions: + attributes[f"{node.name}.{statement.target.id}"] = resolved + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.IfExp): + return ( + "Python conditional process launcher target is not allowed " + f"on line {node.lineno}" + ) + dotted = python_dotted_name(node.func) + if dotted in attributes: + return ( + "Python process launcher stored on a class attribute is not allowed " + f"({dotted!r} resolves to {attributes[dotted]!r}) on line {node.lineno}" + ) + return None + + +unresolved_starred_launcher_container = "__g01_unresolved_starred_launcher_container__" + + +def python_call_target(node, modules, functions): + """Resolve callable dunder invocation back to its launcher receiver.""" + if isinstance(node, ast.Subscript): + resolved_container = python_resolved_name(node.value, modules, functions) + if resolved_container == unresolved_starred_launcher_container: + return "unresolved", python_dotted_name(node.value) or "starred launcher container" + if not (isinstance(node, ast.Attribute) and node.attr == "__call__"): + return "normal", python_resolved_name(node, modules, functions) + receiver = node.value + resolved = python_resolved_name(receiver, modules, functions) + if resolved in python_command_functions: + return "resolved", resolved + receiver_dotted = python_dotted_name(receiver) + if ( + (isinstance(receiver, ast.Name) and receiver.id in functions) + or ( + receiver_dotted is not None + and receiver_dotted.rsplit(".", 1)[-1] in python_command_leaf_names + ) + ): + return "unresolved", receiver_dotted or resolved or "__call__" + return "normal", python_resolved_name(node, modules, functions) + + +reviewed_guarded_child_keywords = { + "check", + "capture_output", + "text", + "universal_newlines", + "encoding", + "errors", + "cwd", + "env", +} +python_process_override_keywords = {"executable", "shell", "preexec_fn"} + + +def python_guarded_child_kwargs_violation(tree, modules, functions): + """Reject process overrides at every recognized process launcher.""" + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + resolved = python_resolved_name(node.func, modules, functions) + if resolved not in python_command_functions and resolved != "run_go_child": + continue + for keyword in node.keywords: + if keyword.arg in python_process_override_keywords: + name = keyword.arg or "**kwargs" + return ( + "Python process launcher has an unsupported process-creation " + f"override {name!r} on line {node.lineno}" + ) + if resolved == "run_go_child" and ( + keyword.arg is None or keyword.arg not in reviewed_guarded_child_keywords + ): + name = keyword.arg or "**kwargs" + return ( + "Python guarded Go-child call has an unsupported process-creation " + f"keyword {name!r} on line {node.lineno}" + ) + return None + + +def python_command_argument(call): + if call.args: + return call.args[0] + for keyword in call.keywords: + if keyword.arg in {"args", "cmd", "command"}: + return keyword.value + return None + +def python_literal_command(node): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return "shell", node.value + if isinstance(node, (ast.List, ast.Tuple)) and node.elts: + values = [] + for element in node.elts: + if not isinstance(element, ast.Constant) or not isinstance(element.value, str): + return ("argv-dynamic", values) if values else ("argv-dynamic", []) + values.append(element.value) + return "argv", values + return None + + +def python_literal_bindings(tree): + """Resolve only simple string/list bindings before command inspection.""" + bindings = {} + + def value(node): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Name) and node.id in bindings: + return bindings[node.id] + if isinstance(node, (ast.List, ast.Tuple)): + resolved = [] + for element in node.elts: + item = value(element) + if item is None: + return None + resolved.append(item) + return resolved + return None + assignments = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Assign) + ] for _ in range(len(assignments) + 1): changed = False - for target, value in assignments: - mapping = literal_mapping(value) - if mapping is None and isinstance(value, ast.Name): - mapping = mappings.get(value.id) - if mapping is None: + for node in assignments: + resolved = value(node.value) + if resolved is None: continue - for name in mapping_target_names(target): - merged = merge(mappings.get(name), mapping) - if mappings.get(name) != merged: - mappings[name] = merged + for target in node.targets: + if isinstance(target, ast.Name) and bindings.get(target.id) != resolved: + bindings[target.id] = resolved changed = True - for name, key, value in updates: - if value is None: - mapping = ({}, True) - elif key is None: - mapping = literal_mapping(value) - if mapping is None: - mapping = ({}, True) - else: - resolved = python_mapping_value(value, modules, functions) - if resolved[0] == "resolved": - entry = resolved[1] - elif resolved[0] == "unresolved": - entry = None - else: - entry = False - if isinstance(key, ast.Constant) and isinstance(key.value, str): - mapping = ({key.value: entry}, False) - else: - mapping = ({"__unknown__": entry}, True) - merged = merge(mappings.get(name), mapping) - if mappings.get(name) != merged: - mappings[name] = merged - changed = True if not changed: break - return mappings + return bindings -def python_mapping_command(node, mappings, modules, functions): - """Classify mapping/subscript launcher calls, including `.get` indirection.""" - mapping_name = None - key_node = None - if isinstance(node, ast.Subscript): - if isinstance(node.value, ast.Name): - mapping_name = node.value.id - elif isinstance(node.value, ast.Dict): - return ("unresolved", "mapping literal") - else: - return ("unresolved", "mapping subscript") - key_node = node.slice - if isinstance(key_node, ast.Index): - key_node = key_node.value - elif ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr in {"get", "__getitem__"} - ): - if isinstance(node.func.value, ast.Name): - mapping_name = node.func.value.id - else: - return ("unresolved", "mapping literal") - key_node = node.args[0] if node.args else None - else: - return None - if mapping_name not in mappings: - return None - entries, uncertain = mappings[mapping_name] - known_command = any(value in python_command_functions for value in entries.values()) - command_named_key = any( - key.rsplit(".", 1)[-1].lower() in python_command_leaf_names - for key in entries +reviewed_dynamic_function_names = { + "run_cgo_version_child", + "run_go_child", + "run_bounded_git_filter_query", + "run_bounded_git_status", + "git_source_control_entries", + "git_worktree_matches_pinned_blobs", + "recheck_reviewed_source_checkout", + "package_initialization_guard", + "run_bounded_git_query", + "reject_toolexec_go_child", + "reject_overlay_go_child", + "reject_modfile_go_child", + "reject_selector_guard_go_child", + "observe_go_child", + "observe_prior_go_child", + "reject_current_go_child", + "stop_at_go_child", + "reject_go_child", + "stop_at_git", + "reject_check_output", + "reject_run", + "stop_at_go", + "run_git_probe", + "__init__", +} + + +def enclosing_python_function(node, parents): + for parent in _python_parent_chain(node, parents): + if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): + return parent.name + return None + + +reviewed_python_helper_required_calls = { + "run_cgo_version_child": {"Popen", "capture_cgo_version_output"}, + "run_go_child": {"Popen", "capture_go_child_output"}, + "run_bounded_git_filter_query": {"Popen", "capture_git_filter_output"}, + "run_bounded_git_status": {"Popen", "capture_git_status_output"}, + "run_bounded_git_query": {"Popen", "capture_git_query_output"}, + "git_source_control_entries": {"run", "git_command"}, + "git_worktree_matches_pinned_blobs": {"run", "git_command", "read_bytes"}, + "package_initialization_guard": {"git_worktree_matches_pinned_blobs", "run_go_child"}, + "recheck_reviewed_source_checkout": {"run", "git_worktree_matches_pinned_blobs"}, + "reject_toolexec_go_child": {"real_run"}, + "reject_overlay_go_child": {"real_run"}, + "reject_modfile_go_child": {"real_run"}, + "reject_selector_guard_go_child": {"real_run"}, + "observe_go_child": {"real_run"}, + "observe_prior_go_child": {"real_run"}, + "reject_current_go_child": {"real_run"}, + "stop_at_go_child": {"real_run"}, + "reject_go_child": {"real_run"}, + "stop_at_git": {"real_check_output"}, + "reject_check_output": {"real_check_output"}, + "reject_run": {"real_run"}, + "stop_at_go": {"real_run"}, + "run_git_probe": {"run"}, + "__init__": {"real_popen"}, +} + + +reviewed_python_helper_allowed_launchers = { + "run_cgo_version_child": {"subprocess.Popen"}, + "run_go_child": {"subprocess.Popen"}, + "run_bounded_git_filter_query": {"subprocess.Popen"}, + "run_bounded_git_status": {"subprocess.Popen"}, + "run_bounded_git_query": {"subprocess.Popen"}, + "reject_toolexec_go_child": {"real_run"}, + "reject_overlay_go_child": {"real_run"}, + "reject_modfile_go_child": {"real_run"}, + "reject_selector_guard_go_child": {"real_run"}, + "observe_go_child": {"real_run"}, + "observe_prior_go_child": {"real_run"}, + "reject_current_go_child": {"real_run"}, + "stop_at_go_child": {"real_run"}, + "reject_go_child": {"real_run"}, + "stop_at_git": {"real_check_output"}, + "reject_check_output": {"real_check_output"}, + "reject_run": {"real_run"}, + "stop_at_go": {"real_run"}, + "run_git_probe": {"subprocess.run"}, + "__init__": {"real_popen"}, +} +reviewed_python_helper_allowed_argv = { + "run_cgo_version_child": {"command"}, + "run_go_child": {"go_command"}, + "run_bounded_git_filter_query": {"command"}, + "run_bounded_git_status": {"command"}, + "run_bounded_git_query": {"command"}, + "reject_toolexec_go_child": {"args"}, + "reject_overlay_go_child": {"args"}, + "reject_modfile_go_child": {"args"}, + "reject_selector_guard_go_child": {"args"}, + "observe_go_child": {"args"}, + "observe_prior_go_child": {"args"}, + "reject_current_go_child": {"args"}, + "stop_at_go_child": {"args"}, + "reject_go_child": {"args"}, + "stop_at_git": {"args"}, + "reject_check_output": {"args"}, + "reject_run": {"args"}, + "stop_at_go": {"args"}, + "run_git_probe": {"args"}, + "__init__": {"actual"}, +} + + +def reviewed_python_helper_definition(node, parents): + """Require a reviewed helper's body to contain its actual primitive calls.""" + function_name = enclosing_python_function(node, parents) + required = reviewed_python_helper_required_calls.get(function_name) + if required is None: + return False + calls = set() + function_node = next( + ( + parent + for parent in _python_parent_chain(node, parents) + if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)) + ), + None, ) - if not known_command and not command_named_key: - return None - if not isinstance(key_node, ast.Constant) or not isinstance(key_node.value, str): - return ("unresolved", mapping_name) - if key_node.value not in entries: - return ("unresolved", mapping_name) - resolved = entries[key_node.value] - if resolved in python_command_functions: - return ("resolved", resolved) - if resolved is None or uncertain: - return ("unresolved", mapping_name) - return ("safe", resolved) + if function_node is None: + return False + for call in ast.walk(function_node): + if not isinstance(call, ast.Call): + continue + if isinstance(call.func, ast.Name): + calls.add(call.func.id) + elif isinstance(call.func, ast.Attribute): + calls.add(call.func.attr) + return required.issubset(calls) -def python_call_derived_command_alias(node, modules): - """Treat call-returned module lookups as unresolved launcher aliases.""" - if not isinstance(node, ast.Call): +def reviewed_python_helper_launcher(node, parents): + """Bind a dynamic exemption to the helper's reviewed launcher call.""" + function_name = enclosing_python_function(node, parents) + allowed = reviewed_python_helper_allowed_launchers.get(function_name) + if allowed is None: return False - if python_indirect_command(node, modules) is not None: - return True - if isinstance(node.func, ast.Name) and node.func.id == "vars" and node.args: - return python_module_name(node.args[0], modules) in python_command_modules - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr in {"get", "__getitem__"} - ): - receiver = node.func.value - if isinstance(receiver, ast.Attribute) and receiver.attr == "__dict__": - return python_module_name(receiver.value, modules) in python_command_modules - if ( - isinstance(receiver, ast.Call) - and isinstance(receiver.func, ast.Name) - and receiver.func.id == "vars" - and receiver.args - ): - return python_module_name(receiver.args[0], modules) in python_command_modules - return False + dotted = python_dotted_name(node.func) + raw_name = node.func.id if isinstance(node.func, ast.Name) else None + return dotted in allowed or raw_name in allowed -def python_import_bindings(tree): - modules = {} - functions = {} - unresolved = [] - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name in python_command_modules: - modules[alias.asname or alias.name] = alias.name - elif isinstance(node, ast.ImportFrom) and node.module in python_command_modules: - for alias in node.names: - if alias.name == "*": - unresolved.append(node.lineno) - continue - imported = f"{node.module}.{alias.name}" - if imported in python_command_functions: - functions[alias.asname or alias.name] = imported - assignments = [ - node for node in ast.walk(tree) - if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.NamedExpr)) - ] +def _python_parent_chain(node, parents): + parent = parents.get(node) + while parent is not None: + yield parent + if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): + # Continue yielding outer functions for narrowly scoped helpers. + pass + parent = parents.get(parent) - def assignment_parts(node): - if isinstance(node, ast.Assign): - return node.targets, node.value - if isinstance(node, ast.AnnAssign): - return [node.target], node.value - if isinstance(node, ast.NamedExpr): - return [node.target], node.value - return [node.target], None - def target_names(target): - if isinstance(target, ast.Name): - return [target] - if isinstance(target, ast.Starred): - return target_names(target.value) - if isinstance(target, (ast.Tuple, ast.List)): - names = [] - for element in target.elts: - names.extend(target_names(element)) - return names - return [] +python_process_signal_functions = { + "os.kill", + "os.killpg", + "signal.raise_signal", + "signal.pthread_kill", +} +reviewed_python_signal_helpers = { + "owned_go_process_group_exists", + "terminate_go_child_group", + "git_query_group_exists", + "terminate_git_query_group", +} +reviewed_python_signal_helper_calls = { + "owned_go_process_group_exists": set(), + "terminate_go_child_group": { + "wait_for_owned_go_process_group_exit", + "close_go_child_streams", + }, + "git_query_group_exists": set(), + "terminate_git_query_group": { + "wait_for_git_query_group_exit", + "close_git_query_streams", + }, +} - def binding_pairs(target, value): - """Recursively pair destructured targets with their source values.""" - if isinstance(target, ast.Name): - return [(target, value)] - if isinstance(target, ast.Starred): - return binding_pairs(target.value, value) - if not isinstance(target, (ast.Tuple, ast.List)): - return [] - if not isinstance(value, (ast.Tuple, ast.List)): - return [(name, None) for name in target_names(target)] - pairs = [] - for index, element in enumerate(target.elts): - source = value.elts[index] if index < len(value.elts) else None - pairs.extend(binding_pairs(element, source)) - return pairs - assignment_bindings = [] - for node in assignments: - targets, value = assignment_parts(node) - for target in targets: - destructured = not isinstance(target, ast.Name) - assignment_bindings.extend( - (bound_target, bound_value, destructured) - for bound_target, bound_value in binding_pairs(target, value) - ) +def reviewed_python_signal_helper_body(tree, function_name): + """Require the exact reviewed signal-helper shape, not just its name.""" + function_node = next( + ( + node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == function_name + ), + None, + ) + if function_node is None: + return False + if ( + len(function_node.args.args) != 1 + or function_node.args.args[0].arg != "process" + or function_node.args.vararg is not None + or function_node.args.kwarg is not None + or function_node.args.kwonlyargs + ): + return False + kill_signals = set() + observed_calls = set() + for candidate in ast.walk(function_node): + if isinstance(candidate, ast.Call): + dotted = python_dotted_name(candidate.func) + if dotted: + observed_calls.add(dotted.rsplit(".", 1)[-1]) + if dotted != "os.killpg": + continue + if len(candidate.args) != 2 or candidate.keywords: + return False + target, signal_value = candidate.args + if not ( + isinstance(target, ast.Attribute) + and target.attr == "pid" + and isinstance(target.value, ast.Name) + and target.value.id == "process" + ): + return False + if isinstance(signal_value, ast.Constant) and signal_value.value == 0: + kill_signals.add("0") + else: + signal_name = python_dotted_name(signal_value) + if signal_name in {"signal.SIGTERM", "signal.SIGKILL"}: + kill_signals.add(signal_name) + else: + return False + if function_name.endswith("_group_exists"): + return kill_signals == {"0"} and "killpg" in observed_calls + return ( + kill_signals == {"signal.SIGTERM", "signal.SIGKILL"} + and reviewed_python_signal_helper_calls[function_name].issubset(observed_calls) + ) - # Only names that could denote a command-capable launcher are tracked. - # Ordinary direct callable assignments (for example `validator = - # namespace.get`) remain outside this map. Destructured assignments are - # tracked conservatively because an unresolved element cannot be proven - # non-launching; such names remain mapped to None and fail closed. - candidate_aliases = set() - for _ in range(len(assignment_bindings) + 1): - changed = False - for target, value, destructured in assignment_bindings: - if not isinstance(target, ast.Name): + +def reviewed_python_signal_popen_provenance(tree, function_name): + """Require a reviewed helper call fed by an owned new-session Popen.""" + related_calls = {function_name} + if function_name.endswith("_group_exists"): + related_calls.update( + { + "wait_for_owned_go_process_group_exit", + "wait_for_git_query_group_exit", + "terminate_go_child_group", + "terminate_git_query_group", + } + ) + for function_node in ast.walk(tree): + if not isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + owned_process = False + for candidate in ast.walk(function_node): + if not isinstance(candidate, ast.Assign): continue - dotted = python_dotted_name(value) if value is not None else None - commandish = bool( - dotted and dotted.rsplit(".", 1)[-1] in python_command_leaf_names + if not any( + isinstance(target, ast.Name) and target.id == "process" + for target in candidate.targets + ): + continue + value = candidate.value + if not ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) == "subprocess.Popen" + ): + continue + start_session = next( + ( + keyword.value + for keyword in value.keywords + if keyword.arg == "start_new_session" + ), + None, ) + if isinstance(start_session, ast.Constant) and start_session.value is True: + owned_process = True + break + if not owned_process: + continue + for candidate in ast.walk(function_node): + if not isinstance(candidate, ast.Call): + continue + if python_dotted_name(candidate.func) not in related_calls: + continue if ( - destructured - or commandish - or python_call_derived_command_alias(value, modules) - or target.id in modules - or target.id in functions - or (isinstance(value, ast.Name) and value.id in candidate_aliases) - or (isinstance(value, ast.Name) and value.id in modules) - or (isinstance(value, ast.Name) and value.id in functions) - ) and target.id not in candidate_aliases: - candidate_aliases.add(target.id) - changed = True - if not changed: - break - for alias in candidate_aliases: - modules.pop(alias, None) - functions[alias] = None + len(candidate.args) == 1 + and not candidate.keywords + and isinstance(candidate.args[0], ast.Name) + and candidate.args[0].id == "process" + ): + return True + return False - def resolve_binding(node): - dotted = python_dotted_name(node) - if dotted is None: - return None - if dotted in functions: - return functions[dotted] - parts = dotted.split(".") - module = modules.get(parts[0]) - if module is not None and len(parts) > 1: - return ".".join([module, *parts[1:]]) - if parts[0] in functions and functions[parts[0]] is None: - return None - return dotted - # Resolve alias chains such as `launch = subprocess.run` and - # `again = launch` without trusting their source order. Unknown or - # shadowed assignment targets remain mapped to None and are rejected when - # called below. - for _ in range(len(assignment_bindings) + 1): - changed = False - for target, value, _destructured in sorted( - assignment_bindings, - key=lambda item: (item[0].lineno, item[0].col_offset), +def reviewed_python_signal_target(node, tree, parents, dotted): + """Prove only the packet's private process-group fixture targets.""" + if dotted == "os.killpg": + if not node.args or not ( + isinstance(node.args[0], ast.Attribute) + and node.args[0].attr == "pid" + and isinstance(node.args[0].value, ast.Name) + and node.args[0].value.id == "process" ): - if value is None: - continue - resolved = resolve_binding(value) - if resolved not in python_command_functions: + return False + function_name = enclosing_python_function(node, parents) + if ( + function_name in reviewed_python_signal_helpers + and reviewed_python_signal_helper_body(tree, function_name) + and reviewed_python_signal_popen_provenance(tree, function_name) + ): + return True + for parent in _python_parent_chain(node, parents): + if not isinstance(parent, ast.For): continue - if target.id not in candidate_aliases: + if not ( + isinstance(parent.target, ast.Name) + and parent.target.id == "process" + and isinstance(parent.iter, ast.Name) + and parent.iter.id == "active_process" + ): continue - if functions.get(target.id) != resolved: - functions[target.id] = resolved - changed = True - if not changed: - break - # A command-capable assignment that cannot be resolved remains mapped to - # None and is rejected when called below. - return modules, functions, unresolved - - -def python_resolved_name(node, modules, functions): - dotted = python_dotted_name(node) - if dotted is None: - return None - if dotted in functions: - return functions[dotted] - parts = dotted.split(".") - if parts[0] in functions and functions[parts[0]] is None: - return None - module = modules.get(parts[0]) - if module is not None and len(parts) > 1: - return ".".join([module, *parts[1:]]) - return dotted - - -def python_class_command_attribute_violation(tree, modules, functions): - """Reject unresolved conditional and instance/class launcher attributes.""" - attributes = {} - - def record(target, value): - if not isinstance(target, ast.Attribute): - return - attribute_name = python_dotted_name(target) - if ( - attribute_name is None - or attribute_name.split(".", 1)[0] in python_command_modules + return True + return False + if dotted == "os.kill": + if not node.args or not ( + isinstance(node.args[0], ast.Name) + and node.args[0].id == "child_pid" ): - return - resolved = python_resolved_name(value, modules, functions) - if resolved in python_command_functions: - attributes[attribute_name] = resolved - - def conditional_command_reference(value): - if not isinstance(value, ast.IfExp): - return None - for branch in (value.body, value.orelse): - resolved = python_resolved_name(branch, modules, functions) - if resolved in python_command_functions: - return resolved - dotted = python_dotted_name(branch) - if dotted and dotted.rsplit(".", 1)[-1] in python_command_leaf_names: - return dotted - return None - - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Attribute): - record(target, node.value) - if conditional_command_reference(node.value) is not None: - return ( - "Python conditional process launcher assignment is not allowed " - f"on line {node.lineno}" - ) - elif isinstance(node, ast.AnnAssign): - record(node.target, node.value) - if conditional_command_reference(node.value) is not None: - return ( - "Python conditional process launcher assignment is not allowed " - f"on line {node.lineno}" - ) - elif isinstance(node, ast.NamedExpr): - if conditional_command_reference(node.value) is not None: - return ( - "Python conditional process launcher assignment is not allowed " - f"on line {node.lineno}" - ) - elif isinstance(node, ast.ClassDef): - if node.name in python_command_modules: + return False + for candidate in ast.walk(tree): + if not isinstance(candidate, ast.Assign): continue - for statement in node.body: - if isinstance(statement, ast.Assign): - for target in statement.targets: - if isinstance(target, ast.Name): - resolved = python_resolved_name(statement.value, modules, functions) - if resolved in python_command_functions: - attributes[f"{node.name}.{target.id}"] = resolved - elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): - resolved = python_resolved_name(statement.value, modules, functions) - if resolved in python_command_functions: - attributes[f"{node.name}.{statement.target.id}"] = resolved - - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if isinstance(node.func, ast.IfExp): - return ( - "Python conditional process launcher target is not allowed " - f"on line {node.lineno}" - ) - dotted = python_dotted_name(node.func) - if dotted in attributes: - return ( - "Python process launcher stored on a class attribute is not allowed " - f"({dotted!r} resolves to {attributes[dotted]!r}) on line {node.lineno}" - ) - return None - - -def python_call_target(node, modules, functions): - """Resolve callable dunder invocation back to its launcher receiver.""" - if not (isinstance(node, ast.Attribute) and node.attr == "__call__"): - return "normal", python_resolved_name(node, modules, functions) - receiver = node.value - resolved = python_resolved_name(receiver, modules, functions) - if resolved in python_command_functions: - return "resolved", resolved - receiver_dotted = python_dotted_name(receiver) - if ( - (isinstance(receiver, ast.Name) and receiver.id in functions) - or ( - receiver_dotted is not None - and receiver_dotted.rsplit(".", 1)[-1] in python_command_leaf_names - ) - ): - return "unresolved", receiver_dotted or resolved or "__call__" - return "normal", python_resolved_name(node, modules, functions) + if not any( + isinstance(target, ast.Name) and target.id == "child_pid" + for target in candidate.targets + ): + continue + value = candidate.value + if not ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) == "int" + and len(value.args) == 1 + and isinstance(value.args[0], ast.Call) + and python_dotted_name(value.args[0].func) == "marker.read_text" + ): + continue + return True + return False + return False -reviewed_guarded_child_keywords = { - "check", - "capture_output", - "text", - "universal_newlines", - "encoding", - "errors", - "cwd", - "env", -} -python_process_override_keywords = {"executable", "shell", "preexec_fn"} +def python_process_signal_violation(tree, parents): + """Reject unowned process signals and broad targets before execution.""" + module_names = { + "os": python_assigned_module_names(tree, "os"), + "signal": python_assigned_module_names(tree, "signal"), + } + aliases = { + alias.asname or alias.name: f"{node.module}.{alias.name}" + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module in module_names + for alias in node.names + if f"{node.module}.{alias.name}" in python_process_signal_functions + } + def signal_name(value): + if isinstance(value, ast.Name): + return aliases.get(value.id) + if isinstance(value, ast.Attribute) and isinstance(value.value, ast.Name): + for module, names in module_names.items(): + if value.value.id in names: + canonical = module + "." + value.attr + if canonical in python_process_signal_functions: + return canonical + return None -def python_guarded_child_kwargs_violation(tree, modules, functions): - """Reject process overrides at every recognized process launcher.""" + changed = True + while changed: + changed = False + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets, value = candidate.targets, candidate.value + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets, value = [candidate.target], candidate.value + else: + continue + canonical = signal_name(value) + if canonical is None: + continue + for target in targets: + if isinstance(target, ast.Name) and aliases.get(target.id) != canonical: + aliases[target.id] = canonical + changed = True for node in ast.walk(tree): if not isinstance(node, ast.Call): continue - resolved = python_resolved_name(node.func, modules, functions) - if resolved not in python_command_functions and resolved != "run_go_child": + import_launcher_violation = python_import_launcher_violation(node) + if import_launcher_violation: + return f"{import_launcher_violation} on line {node.lineno}" + dotted = signal_name(node.func) or python_dotted_name(node.func) + if dotted not in python_process_signal_functions: continue - for keyword in node.keywords: - if keyword.arg in python_process_override_keywords: - name = keyword.arg or "**kwargs" - return ( - "Python process launcher has an unsupported process-creation " - f"override {name!r} on line {node.lineno}" - ) - if resolved == "run_go_child" and ( - keyword.arg is None or keyword.arg not in reviewed_guarded_child_keywords - ): - name = keyword.arg or "**kwargs" - return ( - "Python guarded Go-child call has an unsupported process-creation " - f"keyword {name!r} on line {node.lineno}" - ) - return None - - -def python_command_argument(call): - if call.args: - return call.args[0] - for keyword in call.keywords: - if keyword.arg in {"args", "cmd", "command"}: - return keyword.value - return None - -def python_literal_command(node): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - return "shell", node.value - if isinstance(node, (ast.List, ast.Tuple)) and node.elts: - values = [] - for element in node.elts: - if not isinstance(element, ast.Constant) or not isinstance(element.value, str): - return ("argv-dynamic", values) if values else ("argv-dynamic", []) - values.append(element.value) - return "argv", values + if reviewed_python_signal_target(node, tree, parents, dotted): + continue + return ( + "Python heredoc contains an unowned process-signal call " + f"{dotted!r} on line {node.lineno}" + ) return None -def python_literal_bindings(tree): - """Resolve only simple string/list bindings before command inspection.""" - bindings = {} +def reviewed_python_git_builder(node): + """Allow only the packet's fixed read-only Git argv builder.""" + if not isinstance(node, ast.Call) or python_dotted_name(node.func) != "git_command": + return False + if len(node.args) != 1 or node.keywords: + return False + arguments = node.args[0] + if not isinstance(arguments, (ast.List, ast.Tuple)) or not arguments.elts: + return False + first = arguments.elts[0] + return isinstance(first, ast.Constant) and first.value in git_read_only_subcommands - def value(node): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - return node.value - if isinstance(node, ast.Name) and node.id in bindings: - return bindings[node.id] - if isinstance(node, (ast.List, ast.Tuple)): - resolved = [] - for element in node.elts: - item = value(element) - if item is None: - return None - resolved.append(item) - return resolved - return None - assignments = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Assign) - ] - for _ in range(len(assignments) + 1): - changed = False - for node in assignments: - resolved = value(node.value) - if resolved is None: +def reviewed_python_static_loop_binding(node, tree): + """Recognize literal command loops without granting a marker blanket trust.""" + if not isinstance(node, ast.Name): + return False + for loop in ast.walk(tree): + if not isinstance(loop, ast.For) or not isinstance(loop.target, ast.Name): + continue + if loop.target.id != node.id or not isinstance(loop.iter, (ast.List, ast.Tuple)): + continue + if not loop.iter.elts: + continue + for item in loop.iter.elts: + if not isinstance(item, (ast.List, ast.Tuple)) or not item.elts: + break + first = item.elts[0] + if not isinstance(first, ast.Constant) or first.value not in {"git", "go"}: + break + words = [] + for element in item.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + words.append(element.value) + elif ( + isinstance(element, ast.Call) + and python_dotted_name(element.func) == "str" + and len(element.args) == 1 + and not element.keywords + ): + words.append("") + else: + break + else: + if words[0] == "git" and tuple(words) in { + ("git", "init", "-q"), + ("git", "config", "core.fsmonitor", ""), + }: + continue + if words[0] == "git" and forbidden_command(words) is not None: + break continue - for target in node.targets: - if isinstance(target, ast.Name) and bindings.get(target.id) != resolved: - bindings[target.id] = resolved - changed = True - if not changed: break - return bindings + else: + return True + return False -reviewed_dynamic_function_names = { - "run_cgo_version_child", - "run_go_child", - "run_bounded_git_filter_query", - "run_bounded_git_status", - "git_source_control_entries", - "git_worktree_matches_pinned_blobs", - "recheck_reviewed_source_checkout", - "package_initialization_guard", - "run_bounded_git_query", - "reject_toolexec_go_child", - "reject_overlay_go_child", - "reject_modfile_go_child", - "reject_selector_guard_go_child", - "observe_go_child", - "observe_prior_go_child", - "reject_current_go_child", - "stop_at_go_child", - "reject_go_child", - "stop_at_git", - "reject_check_output", - "reject_run", - "stop_at_go", - "run_git_probe", - "__init__", -} +def reviewed_python_case_args(node, tree): + """Recognize only the literal case['args'] fixture table.""" + if not isinstance(node, ast.Subscript): + return False + if not isinstance(node.value, ast.Name) or node.value.id != "case": + return False + key = node.slice.value if isinstance(node.slice, ast.Constant) else None + if key != "args": + return False + for loop in ast.walk(tree): + if not ( + isinstance(loop, ast.For) + and isinstance(loop.target, ast.Name) + and loop.target.id == "case" + and isinstance(loop.iter, ast.Name) + and loop.iter.id == "cases" + ): + continue + for assignment in ast.walk(tree): + if not isinstance(assignment, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "cases" + for target in assignment.targets + ): + continue + if not isinstance(assignment.value, (ast.List, ast.Tuple)): + continue + entries = assignment.value.elts + if entries and all( + isinstance(entry, ast.Dict) + and any( + isinstance(key_node, ast.Constant) + and key_node.value == "args" + and isinstance(value_node, (ast.List, ast.Tuple)) + and value_node.elts + and isinstance(value_node.elts[0], ast.Constant) + and value_node.elts[0].value == "go" + for key_node, value_node in zip(entry.keys, entry.values) + ) + for entry in entries + ): + return True + return False -def enclosing_python_function(node, parents): - for parent in _python_parent_chain(node, parents): - if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): - return parent.name - return None +reviewed_python_dynamic_path_names = { + "module_dir", + "relative_path", + "parent", + "parent_sha", + "parent_head", + "prior_head", + "starting_head", + "packet_path", + "source_path", +} -reviewed_python_helper_required_calls = { - "run_cgo_version_child": {"Popen", "capture_cgo_version_output"}, - "run_go_child": {"Popen", "capture_go_child_output"}, - "run_bounded_git_filter_query": {"Popen", "capture_git_filter_output"}, - "run_bounded_git_status": {"Popen", "capture_git_status_output"}, - "run_bounded_git_query": {"Popen", "capture_git_query_output"}, - "git_source_control_entries": {"run", "git_command"}, - "git_worktree_matches_pinned_blobs": {"run", "git_command", "read_bytes"}, - "package_initialization_guard": {"git_worktree_matches_pinned_blobs", "run_go_child"}, - "recheck_reviewed_source_checkout": {"run", "git_worktree_matches_pinned_blobs"}, - "reject_toolexec_go_child": {"real_run"}, - "reject_overlay_go_child": {"real_run"}, - "reject_modfile_go_child": {"real_run"}, - "reject_selector_guard_go_child": {"real_run"}, - "observe_go_child": {"real_run"}, - "observe_prior_go_child": {"real_run"}, - "reject_current_go_child": {"real_run"}, - "stop_at_go_child": {"real_run"}, - "reject_go_child": {"real_run"}, - "stop_at_git": {"real_check_output"}, - "reject_check_output": {"real_check_output"}, - "reject_run": {"real_run"}, - "stop_at_go": {"real_run"}, - "run_git_probe": {"run"}, - "__init__": {"real_popen"}, -} +def reviewed_python_dynamic_path_value(node, tree=None, parents=None, seen=None): + """Prove only immutable packet/repository path components in a Git argv.""" + if seen is None: + seen = set() + if id(node) in seen: + return False + seen.add(id(node)) + if isinstance(node, ast.Constant): + return isinstance(node.value, str) + if isinstance(node, ast.Name): + if node.id not in reviewed_python_dynamic_path_names: + return False + if tree is None or parents is None: + return True + scope = python_enclosing_scope(node, parents) + assignments = [] + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets = candidate.targets + elif isinstance(candidate, ast.AnnAssign): + targets = [candidate.target] + elif isinstance(candidate, ast.NamedExpr): + targets = [candidate.target] + else: + continue + if python_enclosing_scope(candidate, parents) is not scope: + continue + if any( + isinstance(target, ast.Name) and target.id == node.id + for target in targets + ): + assignments.append(candidate.value) + return bool(assignments) and all( + reviewed_python_dynamic_path_value(value, tree, parents, seen.copy()) + for value in assignments + ) + if isinstance(node, ast.JoinedStr): + return all( + isinstance(value, ast.Constant) + or ( + isinstance(value, ast.FormattedValue) + and reviewed_python_dynamic_path_value( + value.value, tree, parents, seen.copy() + ) + ) + for value in node.values + ) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return reviewed_python_dynamic_path_value( + node.left, tree, parents, seen.copy() + ) and reviewed_python_dynamic_path_value( + node.right, tree, parents, seen.copy() + ) + return False -reviewed_python_helper_allowed_launchers = { - "run_cgo_version_child": {"subprocess.Popen"}, - "run_go_child": {"subprocess.Popen"}, - "run_bounded_git_filter_query": {"subprocess.Popen"}, - "run_bounded_git_status": {"subprocess.Popen"}, - "run_bounded_git_query": {"subprocess.Popen"}, - "reject_toolexec_go_child": {"real_run"}, - "reject_overlay_go_child": {"real_run"}, - "reject_modfile_go_child": {"real_run"}, - "reject_selector_guard_go_child": {"real_run"}, - "observe_go_child": {"real_run"}, - "observe_prior_go_child": {"real_run"}, - "reject_current_go_child": {"real_run"}, - "stop_at_go_child": {"real_run"}, - "reject_go_child": {"real_run"}, - "stop_at_git": {"real_check_output"}, - "reject_check_output": {"real_check_output"}, - "reject_run": {"real_run"}, - "stop_at_go": {"real_run"}, - "run_git_probe": {"subprocess.run"}, - "__init__": {"real_popen"}, -} -reviewed_python_helper_allowed_argv = { - "run_cgo_version_child": {"command"}, - "run_go_child": {"go_command"}, - "run_bounded_git_filter_query": {"command"}, - "run_bounded_git_status": {"command"}, - "run_bounded_git_query": {"command"}, - "reject_toolexec_go_child": {"args"}, - "reject_overlay_go_child": {"args"}, - "reject_modfile_go_child": {"args"}, - "reject_selector_guard_go_child": {"args"}, - "observe_go_child": {"args"}, - "observe_prior_go_child": {"args"}, - "reject_current_go_child": {"args"}, - "stop_at_go_child": {"args"}, - "reject_go_child": {"args"}, - "stop_at_git": {"args"}, - "reject_check_output": {"args"}, - "reject_run": {"args"}, - "stop_at_go": {"args"}, - "run_git_probe": {"args"}, - "__init__": {"actual"}, -} +def reviewed_python_dynamic_git_call(argument, tree=None, parents=None): + """Allow only read-only Git argv with reviewed immutable path components.""" + if not isinstance(argument, (ast.List, ast.Tuple)) or len(argument.elts) < 2: + return False + subcommand_index = 1 + while ( + subcommand_index < len(argument.elts) + and isinstance(argument.elts[subcommand_index], ast.Constant) + and argument.elts[subcommand_index].value in {"-P", "--no-pager"} + ): + subcommand_index += 1 + if subcommand_index >= len(argument.elts): + return False + first, second = argument.elts[0], argument.elts[subcommand_index] + if not ( + isinstance(first, ast.Constant) + and first.value == "git" + and isinstance(second, ast.Constant) + and second.value in git_read_only_subcommands + ): + return False + reconstructed = [] + for element in argument.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + reconstructed.append(element.value) + elif reviewed_python_dynamic_path_value(element, tree, parents): + reconstructed.append("__g01_reviewed_dynamic_path__") + else: + return False + if ( + git_command_delegation(reconstructed) is not None + or git_diff_path_violation(reconstructed) is not None + or git_filter_attribute_violation(reconstructed) is not None + or git_read_only_violation(reconstructed) is not None + ): + return False + return all( + isinstance(element, ast.Constant) + and isinstance(element.value, str) + or reviewed_python_dynamic_path_value(element, tree, parents) + for element in argument.elts[subcommand_index + 1:] + ) -def reviewed_python_helper_definition(node, parents): - """Require a reviewed helper's body to contain its actual primitive calls.""" +def reviewed_python_dynamic_call( + node, argument, tree, parents, literal_bindings, resolved=None +): + """Allow explicit packet-owned forwarding, never a marker by itself.""" + if ( + isinstance(argument, (ast.List, ast.Tuple)) + and len(argument.elts) == 4 + and isinstance(argument.elts[0], ast.Attribute) + and python_dotted_name(argument.elts[0]) == "sys.executable" + and isinstance(argument.elts[1], ast.Constant) + and argument.elts[1].value == "-I" + and isinstance(argument.elts[2], ast.Constant) + and argument.elts[2].value == "-c" + and isinstance(argument.elts[3], ast.Constant) + and argument.elts[3].value == "import json; print(json.__file__)" + ): + return True + if reviewed_python_git_builder(argument): + return True + if reviewed_python_case_args(argument, tree): + return True + if reviewed_python_static_loop_binding(argument, tree): + return True + if isinstance(argument, (ast.List, ast.Tuple)): + prefix = [] + for element in argument.elts: + if not isinstance(element, ast.Constant) or not isinstance(element.value, str): + break + prefix.append(element.value) + if reviewed_python_synthetic_git_fixture(node, prefix, tree, parents): + return True + if reviewed_python_dynamic_git_call(argument, tree, parents): + return True + if isinstance(argument, ast.Name) and argument.id in literal_bindings: + return True function_name = enclosing_python_function(node, parents) - required = reviewed_python_helper_required_calls.get(function_name) - if required is None: + if function_name not in reviewed_dynamic_function_names: return False - calls = set() - function_node = next( - ( - parent - for parent in _python_parent_chain(node, parents) - if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)) - ), - None, - ) - if function_node is None: + if not reviewed_python_helper_definition(node, parents): return False - for call in ast.walk(function_node): - if not isinstance(call, ast.Call): - continue - if isinstance(call.func, ast.Name): - calls.add(call.func.id) - elif isinstance(call.func, ast.Attribute): - calls.add(call.func.attr) - return required.issubset(calls) + if not reviewed_python_helper_launcher(node, parents): + return False + if isinstance(argument, ast.Starred): + argument = argument.value + if isinstance(argument, ast.Name): + return argument.id in reviewed_python_helper_allowed_argv.get(function_name, set()) + return False -def reviewed_python_helper_launcher(node, parents): - """Bind a dynamic exemption to the helper's reviewed launcher call.""" - function_name = enclosing_python_function(node, parents) - allowed = reviewed_python_helper_allowed_launchers.get(function_name) - if allowed is None: +reviewed_synthetic_git_fixture_argv = { + ("git", "init", "-q"), + ("git", "status", "--porcelain=v1"), + ("git", "add", "source.go"), + ("git", "add", "experiments/g01-scaleset/source.go"), + ( + "git", + "-c", + "user.name=probe", + "-c", + "user.email=probe@example.invalid", + "commit", + "-q", + "-m", + "source", + ), + ( + "git", + "update-index", + "--no-skip-worktree", + "--no-assume-unchanged", + "source.go", + ), + ( + "git", + "update-index", + "--no-skip-worktree", + "--no-assume-unchanged", + "experiments/g01-scaleset/source.go", + ), +} + + +def temporary_directory_call(node): + """Recognize the reviewed literal TemporaryDirectory constructor forms.""" + if not isinstance(node, ast.Call) or node.args or node.keywords: return False dotted = python_dotted_name(node.func) - raw_name = node.func.id if isinstance(node.func, ast.Name) else None - return dotted in allowed or raw_name in allowed + if dotted in {"tempfile.TemporaryDirectory", "TemporaryDirectory"}: + return True + return ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "TemporaryDirectory" + and isinstance(node.func.value, ast.Call) + and isinstance(node.func.value.func, ast.Name) + and node.func.value.func.id == "__import__" + and len(node.func.value.args) == 1 + and isinstance(node.func.value.args[0], ast.Constant) + and node.func.value.args[0].value == "tempfile" + and not node.func.value.keywords + ) -def _python_parent_chain(node, parents): +def temporary_directory_binding(node, parents): + """Return the local binding only inside a literal TemporaryDirectory block.""" parent = parents.get(node) while parent is not None: - yield parent - if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): - # Continue yielding outer functions for narrowly scoped helpers. - pass + if isinstance(parent, (ast.With, ast.AsyncWith)): + for item in parent.items: + context = item.context_expr + if not ( + temporary_directory_call(context) + and isinstance(item.optional_vars, ast.Name) + ): + continue + return item.optional_vars.id parent = parents.get(parent) + return None -python_process_signal_functions = { - "os.kill", - "os.killpg", - "signal.raise_signal", - "signal.pthread_kill", -} -reviewed_python_signal_helpers = { - "owned_go_process_group_exists", - "terminate_go_child_group", - "git_query_group_exists", - "terminate_git_query_group", -} -reviewed_python_signal_helper_calls = { - "owned_go_process_group_exists": set(), - "terminate_go_child_group": { - "wait_for_owned_go_process_group_exit", - "close_go_child_streams", - }, - "git_query_group_exists": set(), - "terminate_git_query_group": { - "wait_for_git_query_group_exit", - "close_git_query_streams", - }, -} +def temporary_directory_bindings(node, tree, parents): + """Resolve only direct Path aliases of the active TemporaryDirectory binding.""" + binding = temporary_directory_binding(node, parents) + bindings = {binding} if binding is not None else set() + changed = True + while changed: + changed = False + for candidate in ast.walk(tree): + if temporary_directory_binding(candidate, parents) != binding: + continue + if not isinstance(candidate, ast.Assign): + continue + value = candidate.value + source = None + if isinstance(value, ast.Name): + source = value.id + elif ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) in {"Path", "pathlib.Path"} + and len(value.args) == 1 + and not value.keywords + and isinstance(value.args[0], ast.Name) + ): + source = value.args[0].id + if source not in bindings: + continue + for target in candidate.targets: + if isinstance(target, ast.Name) and target.id not in bindings: + bindings.add(target.id) + changed = True + return bindings -def reviewed_python_signal_helper_body(tree, function_name): - """Require the exact reviewed signal-helper shape, not just its name.""" - function_node = next( - ( - node - for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - and node.name == function_name - ), - None, - ) - if function_node is None: +def temporary_path_component_safe(value): + """Reject absolute and parent-traversing path components conservatively.""" + if not isinstance(value, str) or value.startswith("/"): return False - if ( - len(function_node.args.args) != 1 - or function_node.args.args[0].arg != "process" - or function_node.args.vararg is not None - or function_node.args.kwarg is not None - or function_node.args.kwonlyargs + normalized = value.replace("\\", "/") + return ".." not in normalized.split("/") + + +def reviewed_source_snapshot_path(node, tree, parents, seen=None): + """Prove paths belong to the wrapper's private immutable source snapshot.""" + if seen is None: + seen = set() + if node is None or id(node) in seen: + return False + seen.add(id(node)) + current = node + while current is not None and not isinstance( + current, (ast.FunctionDef, ast.AsyncFunctionDef) ): + current = parents.get(current) + if current is None or current.name != "create_immutable_source_snapshot": return False - kill_signals = set() - observed_calls = set() - for candidate in ast.walk(function_node): - if isinstance(candidate, ast.Call): - dotted = python_dotted_name(candidate.func) - if dotted: - observed_calls.add(dotted.rsplit(".", 1)[-1]) - if dotted != "os.killpg": + if isinstance(node, ast.Name) and node.id == "snapshot_root": + for candidate in ast.walk(current): + if not isinstance(candidate, ast.Assign): continue - if len(candidate.args) != 2 or candidate.keywords: - return False - target, signal_value = candidate.args + if not any( + isinstance(target, ast.Name) + and target.id == "snapshot_root" + for target in candidate.targets + ): + continue + value = candidate.value if not ( - isinstance(target, ast.Attribute) - and target.attr == "pid" - and isinstance(target.value, ast.Name) - and target.value.id == "process" + isinstance(value, ast.Call) + and python_dotted_name(value.func) in {"Path", "pathlib.Path"} + and len(value.args) == 1 + and not value.keywords + and isinstance(value.args[0], ast.Attribute) + and value.args[0].attr == "name" + and isinstance(value.args[0].value, ast.Name) + and value.args[0].value.id == "snapshot_directory" ): - return False - if isinstance(signal_value, ast.Constant) and signal_value.value == 0: - kill_signals.add("0") - else: - signal_name = python_dotted_name(signal_value) - if signal_name in {"signal.SIGTERM", "signal.SIGKILL"}: - kill_signals.add(signal_name) - else: - return False - if function_name.endswith("_group_exists"): - return kill_signals == {"0"} and "killpg" in observed_calls - return ( - kill_signals == {"signal.SIGTERM", "signal.SIGKILL"} - and reviewed_python_signal_helper_calls[function_name].issubset(observed_calls) - ) + continue + for source_assignment in ast.walk(current): + if not isinstance(source_assignment, ast.Assign): + continue + if any( + isinstance(target, ast.Name) + and target.id == "snapshot_directory" + for target in source_assignment.targets + ) and temporary_directory_call(source_assignment.value): + return True + return False + if isinstance(node, ast.Name): + for candidate in ast.walk(current): + if not isinstance(candidate, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == node.id + for target in candidate.targets + ): + continue + if reviewed_source_snapshot_path( + candidate.value, tree, parents, seen.copy() + ): + return True + return False + if isinstance(node, ast.Attribute) and node.attr == "parent": + return reviewed_source_snapshot_path(node.value, tree, parents, seen) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): + if not reviewed_source_snapshot_path(node.left, tree, parents, seen): + return False + if isinstance(node.right, ast.Constant): + return temporary_path_component_safe(node.right.value) + return isinstance(node.right, ast.Name) and node.right.id in { + "relative_path", + "relative_directory", + "directory", + } + return False + + +python_filesystem_mutating_methods = { + "chmod", + "chown", + "hardlink_to", + "link_to", + "mkdir", + "makedirs", + "move", + "open", + "rename", + "replace", + "rmdir", + "symlink_to", + "touch", + "unlink", + "write_bytes", + "write_text", +} +python_filesystem_mutating_functions = { + "os.chmod", + "os.chflags", + "os.chown", + "os.fchmodat", + "os.fchownat", + "os.lchmod", + "os.lchflags", + "os.lchown", + "os.link", + "os.makedirs", + "os.mkdir", + "os.mkfifo", + "os.mknod", + "os.remove", + "os.rename", + "os.replace", + "os.rmdir", + "os.symlink", + "os.setxattr", + "os.lsetxattr", + "os.removexattr", + "os.lremovexattr", + "os.truncate", + "os.unlink", + "os.utime", + "os.utime_ns", + "shutil.copy", + "shutil.copy2", + "shutil.copyfile", + "shutil.copymode", + "shutil.copystat", + "shutil.copytree", + "shutil.make_archive", + "shutil.move", + "shutil.rmtree", + "shutil.unpack_archive", +} +python_reviewed_os_calls = { + "os.close", + "os.environ.clear", + "os.environ.get", + "os.environ.items", + "os.environ.pop", + "os.environ.update", + "os.fdopen", + "os.fork", + "os.fsdecode", + "os.getenv", + "os.getpgrp", + "os.getpid", + "os.kill", + "os.killpg", + "os._exit", + "os.path", + "os.pipe", + "os.read", + "os.set_blocking", + "os.waitpid", + "os.write", +} -def reviewed_python_signal_popen_provenance(tree, function_name): - """Require a reviewed helper call fed by an owned new-session Popen.""" - related_calls = {function_name} - if function_name.endswith("_group_exists"): - related_calls.update( - { - "wait_for_owned_go_process_group_exit", - "wait_for_git_query_group_exit", - "terminate_go_child_group", - "terminate_git_query_group", - } - ) - for function_node in ast.walk(tree): - if not isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)): + +def python_assigned_module_names(tree, module): + """Follow direct and positional-destructured module assignment aliases.""" + names = { + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + if alias.name == module + } + + def bind(target, value): + if isinstance(target, ast.Name) and isinstance(value, ast.Name): + if value.id in names and target.id not in names: + names.add(target.id) + return True + if isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (ast.Tuple, ast.List)): + if len(target.elts) == len(value.elts): + return any([bind(part, source) for part, source in zip(target.elts, value.elts)]) + return False + + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + changed = any([bind(target, node.value) for target in node.targets]) or changed + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): + changed = bind(node.target, node.value) or changed + elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)): + iterable = node.iter + if ( + isinstance(iterable, ast.Call) + and isinstance(iterable.func, ast.Name) + and iterable.func.id == "filter" + and len(iterable.args) == 2 + and isinstance(iterable.args[0], ast.Constant) + and iterable.args[0].value is None + and not iterable.keywords + ): + iterable = iterable.args[1] + if ( + isinstance(iterable, ast.Call) + and isinstance(iterable.func, ast.Name) + and iterable.func.id in {"tuple", "list", "set", "iter", "reversed", "sorted"} + and len(iterable.args) == 1 + and not iterable.keywords + ): + iterable = iterable.args[0] + if isinstance(iterable, (ast.Tuple, ast.List, ast.Set)): + changed = any([bind(node.target, item) for item in iterable.elts]) or changed + return names + + +def python_unknown_os_call_violation(tree): + """Fail closed for OS calls whose path/effect surface is not reviewed.""" + os_names = python_assigned_module_names(tree, "os") + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in os_names + and node.attr == "__dict__" + ) or ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and ( + node.func.id in {"vars", "getattr"} + or python_assigned_callable_alias(node.func.id, "vars", tree) + or python_assigned_callable_alias(node.func.id, "getattr", tree) + ) + and node.args + and any( + isinstance(candidate, ast.Name) and candidate.id in os_names + for candidate in ast.walk(node.args[0]) + ) + ): + return "Python heredoc accesses an OS module dictionary" + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in os_names + and node.value.id != "os" + and node.attr in {"environ", "getenv"} + ): + return "Python heredoc accesses environment values through an OS module alias" + for node in ast.walk(tree): + if not isinstance(node, ast.Call): continue - owned_process = False - for candidate in ast.walk(function_node): - if not isinstance(candidate, ast.Assign): - continue - if not any( - isinstance(target, ast.Name) and target.id == "process" - for target in candidate.targets - ): - continue - value = candidate.value - if not ( - isinstance(value, ast.Call) - and python_dotted_name(value.func) == "subprocess.Popen" - ): - continue - start_session = next( - ( - keyword.value - for keyword in value.keywords - if keyword.arg == "start_new_session" - ), - None, + dotted = python_dotted_name(node.func) + if not dotted or "." not in dotted: + continue + root, remainder = dotted.split(".", 1) + if root not in os_names: + continue + canonical = "os." + remainder + if root != "os" and canonical in python_filesystem_mutating_functions: + return ( + "Python heredoc calls a filesystem mutator through an OS module alias " + f"{dotted!r} on line {node.lineno}" ) - if isinstance(start_session, ast.Constant) and start_session.value is True: - owned_process = True - break - if not owned_process: + if ( + canonical in python_filesystem_mutating_functions + or canonical in python_reviewed_os_calls + or canonical.startswith("os.path.") + or canonical.startswith("os.environ.") + ): continue - for candidate in ast.walk(function_node): - if not isinstance(candidate, ast.Call): - continue - if python_dotted_name(candidate.func) not in related_calls: - continue - if ( - len(candidate.args) == 1 - and not candidate.keywords - and isinstance(candidate.args[0], ast.Name) - and candidate.args[0].id == "process" - ): - return True - return False + return ( + "Python heredoc contains an unreviewed OS call with possible path/effect " + f"surface {dotted!r} on line {node.lineno}" + ) + return None -def reviewed_python_signal_target(node, tree, parents, dotted): - """Prove only the packet's private process-group fixture targets.""" - if dotted == "os.killpg": - if not node.args or not ( - isinstance(node.args[0], ast.Attribute) - and node.args[0].attr == "pid" - and isinstance(node.args[0].value, ast.Name) - and node.args[0].value.id == "process" +def python_subprocess_os_reexport_violation(tree): + """Do not let subprocess's imported os module bypass direct os guards.""" + subprocess_names = { + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + if alias.name == "subprocess" + } + name_bind_counts = {} + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + name_bind_counts[node.id] = name_bind_counts.get(node.id, 0) + 1 + literal_dicts = { + target.id: node.value + for node in ast.walk(tree) + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict) + for target in node.targets + if isinstance(target, ast.Name) and name_bind_counts[target.id] == 1 + } + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "subprocess" and any( + alias.name == "os" for alias in node.names ): + return "Python heredoc imports the unreviewed subprocess.os re-export" + changed = True + while changed: + changed = False + def may_refer_to_subprocess(value): + if isinstance(value, ast.Name): + return value.id in subprocess_names + if isinstance(value, (ast.Tuple, ast.List, ast.Set)): + return any(may_refer_to_subprocess(item) for item in value.elts) + if isinstance(value, ast.Dict): + return any(may_refer_to_subprocess(item) for item in value.values) + if isinstance(value, ast.Call): + if ( + isinstance(value.func, ast.Attribute) + and value.func.attr == "copy" + and may_refer_to_subprocess(value.func.value) + ): + return True + if ( + isinstance(value.func, ast.Name) + and value.func.id in {"dict", "list", "tuple", "set"} + and any(may_refer_to_subprocess(item) for item in value.args) + ): + return True + if isinstance(value, (ast.BinOp, ast.BoolOp)): + operands = ( + [value.left, value.right] + if isinstance(value, ast.BinOp) + else value.values + ) + return any(may_refer_to_subprocess(item) for item in operands) + if isinstance(value, ast.Subscript): + if ( + isinstance(value.value, ast.Name) + and value.value.id in literal_dicts + and isinstance(value.slice, ast.Constant) + ): + source = literal_dicts[value.value.id] + matching_values = [ + item + for key, item in zip(source.keys, source.values) + if isinstance(key, ast.Constant) and key.value == value.slice.value + ] + if matching_values: + return any(may_refer_to_subprocess(item) for item in matching_values) + return False + return may_refer_to_subprocess(value.value) + if isinstance(value, ast.IfExp): + return may_refer_to_subprocess(value.body) or may_refer_to_subprocess(value.orelse) + if isinstance(value, ast.NamedExpr): + return may_refer_to_subprocess(value.value) return False - function_name = enclosing_python_function(node, parents) + + def bind_subprocess_target(target, value): + nonlocal changed + if isinstance(target, ast.Name): + if may_refer_to_subprocess(value) and target.id not in subprocess_names: + subprocess_names.add(target.id) + changed = True + elif isinstance(target, (ast.Tuple, ast.List)): + if isinstance(value, (ast.Tuple, ast.List)): + for element, source in zip(target.elts, value.elts): + bind_subprocess_target(element, source) + elif may_refer_to_subprocess(value): + for element in target.elts: + bind_subprocess_target(element, value) + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, (ast.Attribute, ast.Subscript)) and may_refer_to_subprocess(node.value): + return "Python heredoc stores the subprocess module in an unreviewed object" + bind_subprocess_target(target, node.value) + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): + bind_subprocess_target(node.target, node.value) + for node in ast.walk(tree): + reviewed_base_names = {"Exception", "ValueError"} if ( - function_name in reviewed_python_signal_helpers - and reviewed_python_signal_helper_body(tree, function_name) - and reviewed_python_signal_popen_provenance(tree, function_name) + (isinstance(node, ast.Name) and node.id in reviewed_base_names and isinstance(node.ctx, ast.Store)) + or (isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name in reviewed_base_names) + or (isinstance(node, ast.arg) and node.arg in reviewed_base_names) + or (isinstance(node, ast.ExceptHandler) and node.name in reviewed_base_names) + or ( + isinstance(node, (ast.Import, ast.ImportFrom)) + and any((alias.asname or alias.name) in reviewed_base_names for alias in node.names) + ) ): - return True - for parent in _python_parent_chain(node, parents): - if not isinstance(parent, ast.For): - continue - if not ( - isinstance(parent.target, ast.Name) - and parent.target.id == "process" - and isinstance(parent.iter, ast.Name) - and parent.iter.id == "active_process" - ): - continue - return True - return False - if dotted == "os.kill": - if not node.args or not ( - isinstance(node.args[0], ast.Name) - and node.args[0].id == "child_pid" + return "Python heredoc shadows a reviewed built-in exception base" + if isinstance(node, ast.ClassDef) and ( + node.keywords + or any( + not isinstance(base, ast.Name) + or base.id not in {"Exception", "ValueError"} + for base in node.bases + ) + ): + return "Python heredoc declares an unreviewed class base or metaclass" + if ( + (isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == "type") + or (isinstance(node, ast.Name) and node.id == "type" and isinstance(node.ctx, ast.Store)) + or (isinstance(node, ast.arg) and node.arg == "type") + or ( + isinstance(node, (ast.Import, ast.ImportFrom)) + and any(alias.asname == "type" for alias in node.names) + ) + ): + return "Python heredoc shadows the reviewed built-in type constructor" + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and ( + node.value.id == "type" + or python_assigned_callable_alias(node.value.id, "type", tree) + ) + ): + return "Python heredoc accesses an unreviewed runtime metatype attribute" + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and ( + node.func.id == "type" + or python_assigned_callable_alias(node.func.id, "type", tree) + ) + ): + reviewed_fake_os = ( + node.func.id == "type" + and not node.keywords + and len(node.args) == 3 + and isinstance(node.args[0], ast.Constant) + and node.args[0].value == "FakeOS" + and isinstance(node.args[1], ast.Tuple) + and not node.args[1].elts + and isinstance(node.args[2], ast.Dict) + and len(node.args[2].keys) == 1 + and isinstance(node.args[2].keys[0], ast.Constant) + and node.args[2].keys[0].value == "environ" + and isinstance(node.args[2].values[0], ast.Dict) + and all( + isinstance(item, ast.Constant) and isinstance(item.value, str) + for item in node.args[2].values[0].values + ) + ) + if not reviewed_fake_os: + return "Python heredoc constructs or obtains an unreviewed runtime type" + if ( + isinstance(node, ast.Attribute) + and node.attr == "__class__" + ): + return "Python heredoc obtains an unreviewed runtime class" + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and ( + node.func.id in {"getattr", "vars"} + or python_assigned_callable_alias(node.func.id, "getattr", tree) + or python_assigned_callable_alias(node.func.id, "vars", tree) + ) + and node.args + and isinstance(node.args[0], ast.Name) + and ( + node.args[0].id == "dict" + or python_assigned_callable_alias(node.args[0].id, "dict", tree) + ) + ): + return "Python heredoc dynamically accesses a dictionary mutator descriptor" + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and ( + node.value.id == "dict" + or python_assigned_callable_alias(node.value.id, "dict", tree) + ) + ): + return "Python heredoc accesses an unreviewed dictionary type attribute" + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in subprocess_names + and node.attr in {"update", "setdefault", "__setitem__", "__ior__", "clear", "pop", "popitem"} + ): + return "Python heredoc mutates a subprocess-bearing container indirectly" + if ( + isinstance(node, ast.AugAssign) + and isinstance(node.target, ast.Name) + and node.target.id in subprocess_names + ): + return "Python heredoc rebinds a subprocess-bearing container indirectly" + if subprocess_names and isinstance(node, ast.Attribute) and node.attr == "os": + return "Python heredoc accesses an unreviewed OS module re-export" + if ( + subprocess_names + and + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and ( + node.func.id == "getattr" + or python_assigned_callable_alias(node.func.id, "getattr", tree) + ) + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value == "os" + ): + return "Python heredoc dynamically accesses an OS module re-export" + if ( + subprocess_names + and + isinstance(node, ast.Subscript) + and isinstance(node.slice, ast.Constant) + and node.slice.value == "os" + and ( + (isinstance(node.value, ast.Attribute) and node.value.attr == "__dict__") + or ( + isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and ( + node.value.func.id == "vars" + or python_assigned_callable_alias(node.value.func.id, "vars", tree) + ) + ) + ) + ): + return "Python heredoc looks up an OS module through a module dictionary" + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): + if node.value.id in subprocess_names and node.attr in { + "os", "__dict__", "__getattribute__" + }: + return "Python heredoc accesses an unreviewed subprocess module re-export" + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and ( + node.func.id in {"getattr", "vars"} + or python_assigned_callable_alias(node.func.id, "getattr", tree) + or python_assigned_callable_alias(node.func.id, "vars", tree) + ) + and node.args + and may_refer_to_subprocess(node.args[0]) + ): + return "Python heredoc dynamically accesses a subprocess module re-export" + return None + + +def python_filesystem_mutator_alias_violation(tree, parents): + """Reject extracted mutator methods unless their receiver is temp-owned.""" + shutil_names = python_assigned_module_names(tree, "shutil") + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in shutil_names + and node.attr != "which" + and "shutil." + node.attr not in python_filesystem_mutating_functions + ): + return "Python heredoc accesses an unreviewed shutil entry point" + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in shutil_names + and node.value.id != "shutil" + and "shutil." + node.attr in python_filesystem_mutating_functions + ): + return "Python heredoc accesses a filesystem mutator through a shutil module alias" + for node in ast.walk(tree): + if isinstance(node, ast.Import) and any( + alias.name in {"os", "shutil"} and alias.asname is not None + for alias in node.names + ): + return "Python heredoc aliases a filesystem-capable module" + if isinstance(node, ast.ImportFrom) and node.module in {"os", "shutil"} and any( + alias.name == "*" or f"{node.module}.{alias.name}" in python_filesystem_mutating_functions + for alias in node.names ): - return False - for candidate in ast.walk(tree): - if not isinstance(candidate, ast.Assign): + return "Python heredoc imports an unreviewed filesystem mutator" + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and python_dotted_name(node) in python_filesystem_mutating_functions + and not ( + isinstance(parents.get(node), ast.Call) + and parents[node].func is node + ) + ): + return ( + "Python heredoc stores or passes an unowned filesystem mutator " + f"{python_dotted_name(node)!r} on line {node.lineno}" + ) + for node in ast.walk(tree): + assignments = [] + if isinstance(node, ast.Assign): + assignments.extend((target, node.value) for target in node.targets) + elif isinstance(node, ast.AnnAssign): + assignments.append((node.target, node.value)) + elif isinstance(node, ast.NamedExpr): + assignments.append((node.target, node.value)) + for target, value in assignments: + if not isinstance(target, ast.Name): continue - if not any( - isinstance(target, ast.Name) and target.id == "child_pid" - for target in candidate.targets + receiver = None + method = None + if ( + isinstance(value, ast.Attribute) + and value.attr in python_filesystem_mutating_methods ): - continue - value = candidate.value - if not ( + receiver = value.value + method = value.attr + elif ( isinstance(value, ast.Call) - and python_dotted_name(value.func) == "int" - and len(value.args) == 1 - and isinstance(value.args[0], ast.Call) - and python_dotted_name(value.args[0].func) == "marker.read_text" + and isinstance(value.func, ast.Name) + and value.func.id == "getattr" + and len(value.args) == 2 + and isinstance(value.args[1], ast.Constant) + and value.args[1].value in python_filesystem_mutating_methods + ): + receiver = value.args[0] + method = value.args[1].value + elif ( + isinstance(value, ast.Attribute) + and python_dotted_name(value) in python_filesystem_mutating_functions ): + return ( + "Python heredoc extracts an unowned filesystem mutator " + f"{python_dotted_name(value)!r} into alias {target.id!r} " + f"on line {node.lineno}" + ) + if receiver is None: continue - return True - return False - return False - - -def python_process_signal_violation(tree, parents): - """Reject unowned process signals and broad targets before execution.""" - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - import_launcher_violation = python_import_launcher_violation(node) - if import_launcher_violation: - return f"{import_launcher_violation} on line {node.lineno}" - dotted = python_dotted_name(node.func) - if dotted not in python_process_signal_functions: - continue - if reviewed_python_signal_target(node, tree, parents, dotted): - continue - return ( - "Python heredoc contains an unowned process-signal call " - f"{dotted!r} on line {node.lineno}" - ) + if not temporary_path_expression(receiver, tree, parents): + return ( + "Python heredoc extracts an unowned filesystem mutator " + f"{method!r} into alias {target.id!r} on line {node.lineno}" + ) return None -def reviewed_python_git_builder(node): - """Allow only the packet's fixed read-only Git argv builder.""" - if not isinstance(node, ast.Call) or python_dotted_name(node.func) != "git_command": - return False - if len(node.args) != 1 or node.keywords: - return False - arguments = node.args[0] - if not isinstance(arguments, (ast.List, ast.Tuple)) or not arguments.elts: +def python_path_receiver_expression(node, tree, parents, seen=None): + """Recognize Path-like receivers through constructors and annotations.""" + if node is None: return False - first = arguments.elts[0] - return isinstance(first, ast.Constant) and first.value in git_read_only_subcommands - - -def reviewed_python_static_loop_binding(node, tree): - """Recognize literal command loops without granting a marker blanket trust.""" - if not isinstance(node, ast.Name): + if seen is None: + seen = set() + if id(node) in seen: return False - for loop in ast.walk(tree): - if not isinstance(loop, ast.For) or not isinstance(loop.target, ast.Name): - continue - if loop.target.id != node.id or not isinstance(loop.iter, (ast.List, ast.Tuple)): - continue - if not loop.iter.elts: - continue - for item in loop.iter.elts: - if not isinstance(item, (ast.List, ast.Tuple)) or not item.elts: - break - first = item.elts[0] - if not isinstance(first, ast.Constant) or first.value not in {"git", "go"}: - break - words = [] - for element in item.elts: - if isinstance(element, ast.Constant) and isinstance(element.value, str): - words.append(element.value) - elif ( - isinstance(element, ast.Call) - and python_dotted_name(element.func) == "str" - and len(element.args) == 1 - and not element.keywords - ): - words.append("") - else: - break - else: - if words[0] == "git" and tuple(words) in { - ("git", "init", "-q"), - ("git", "config", "core.fsmonitor", ""), - }: - continue - if words[0] == "git" and forbidden_command(words) is not None: - break - continue - break - else: + seen.add(id(node)) + if temporary_path_expression(node, tree, parents): + return True + if isinstance(node, ast.Call): + dotted = python_dotted_name(node.func) + path_aliases = getattr(tree, "_issue79_path_constructor_aliases", None) + if path_aliases is None: + path_aliases = python_path_constructor_aliases(tree) + tree._issue79_path_constructor_aliases = path_aliases + module_aliases, constructor_aliases = path_aliases + if isinstance(node.func, ast.Name) and node.func.id in constructor_aliases: return True - return False - - -def reviewed_python_case_args(node, tree): - """Recognize only the literal case['args'] fixture table.""" - if not isinstance(node, ast.Subscript): - return False - if not isinstance(node.value, ast.Name) or node.value.id != "case": - return False - key = node.slice.value if isinstance(node.slice, ast.Constant) else None - if key != "args": - return False - for loop in ast.walk(tree): - if not ( - isinstance(loop, ast.For) - and isinstance(loop.target, ast.Name) - and loop.target.id == "case" - and isinstance(loop.iter, ast.Name) - and loop.iter.id == "cases" + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "Path" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in module_aliases ): - continue - for assignment in ast.walk(tree): - if not isinstance(assignment, ast.Assign): + return True + if ( + dotted in { + "Path.cwd", + "pathlib.Path.cwd", + "Path.home", + "pathlib.Path.home", + } + or ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "cwd" + and isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "Path" + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id in module_aliases + ) + ) and ( + not node.args + and not node.keywords + ): + return True + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr in {"expanduser", "joinpath", "resolve"} + ): + return python_path_receiver_expression(node.func.value, tree, parents, seen) + if any( + python_path_receiver_expression( + returned, tree, parents, seen.copy() + ) + for returned in python_local_call_return_values(node, tree, parents) + ): + return True + return False + if isinstance(node, ast.Name): + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets = candidate.targets + value = candidate.value + elif isinstance(candidate, ast.AnnAssign) and candidate.value is not None: + targets = [candidate.target] + value = candidate.value + elif isinstance(candidate, ast.NamedExpr): + targets = [candidate.target] + value = candidate.value + else: continue if not any( - isinstance(target, ast.Name) and target.id == "cases" - for target in assignment.targets + isinstance(target, ast.Name) and target.id == node.id + for target in targets ): continue - if not isinstance(assignment.value, (ast.List, ast.Tuple)): - continue - entries = assignment.value.elts - if entries and all( - isinstance(entry, ast.Dict) - and any( - isinstance(key_node, ast.Constant) - and key_node.value == "args" - and isinstance(value_node, (ast.List, ast.Tuple)) - and value_node.elts - and isinstance(value_node.elts[0], ast.Constant) - and value_node.elts[0].value == "go" - for key_node, value_node in zip(entry.keys, entry.values) - ) - for entry in entries - ): + if python_path_receiver_expression(value, tree, parents, seen): return True + if python_path_typed_parameter(node, tree, parents): + return True + return node.id.casefold().endswith(("path", "file", "directory", "dir", "root")) and not python_unassigned_path_parameter(node, parents) + if isinstance(node, ast.Attribute): + return python_path_receiver_expression(node.value, tree, parents, seen) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): + return python_path_receiver_expression(node.left, tree, parents, seen) return False -reviewed_python_dynamic_path_names = { - "module_dir", - "relative_path", - "parent", - "parent_sha", - "parent_head", - "prior_head", - "starting_head", - "packet_path", - "source_path", -} - - -def reviewed_python_dynamic_path_value(node, tree=None, parents=None, seen=None): - """Prove only immutable packet/repository path components in a Git argv.""" +def temporary_path_expression(node, tree, parents, seen=None): + """Prove a path stays below the active literal TemporaryDirectory.""" + if node is None: + return False if seen is None: seen = set() - if id(node) in seen: + if reviewed_source_snapshot_path(node, tree, parents, seen.copy()): + return True + binding = temporary_directory_binding(node, parents) + if binding is None: return False - seen.add(id(node)) - if isinstance(node, ast.Constant): - return isinstance(node.value, str) + bindings = temporary_directory_bindings(node, tree, parents) if isinstance(node, ast.Name): - if node.id not in reviewed_python_dynamic_path_names: + if node.id in bindings: + assignments = [] + for candidate in ast.walk(tree): + targets = [] + value = None + if isinstance(candidate, ast.Assign): + targets = candidate.targets + value = candidate.value + elif isinstance(candidate, ast.AnnAssign): + targets = [candidate.target] + value = candidate.value + elif isinstance(candidate, ast.NamedExpr): + targets = [candidate.target] + value = candidate.value + elif isinstance(candidate, ast.AugAssign): + targets = [candidate.target] + if ( + temporary_directory_binding(candidate, parents) == binding + and any( + isinstance(target, ast.Name) and target.id == node.id + for target in targets + ) + ): + assignments.append(value) + if not assignments: + return True + return all( + value is not None + and temporary_path_expression(value, tree, parents, seen) + for value in assignments + ) + if node.id in seen: return False - if tree is None or parents is None: - return True - scope = python_enclosing_scope(node, parents) - assignments = [] + seen.add(node.id) + assignment_values = [] for candidate in ast.walk(tree): + targets = [] + value = None if isinstance(candidate, ast.Assign): targets = candidate.targets + value = candidate.value elif isinstance(candidate, ast.AnnAssign): targets = [candidate.target] + value = candidate.value elif isinstance(candidate, ast.NamedExpr): targets = [candidate.target] - else: - continue - if python_enclosing_scope(candidate, parents) is not scope: + value = candidate.value + elif isinstance(candidate, ast.AugAssign): + targets = [candidate.target] + if temporary_directory_binding(candidate, parents) != binding: continue if any( isinstance(target, ast.Name) and target.id == node.id for target in targets ): - assignments.append(candidate.value) - return bool(assignments) and all( - reviewed_python_dynamic_path_value(value, tree, parents, seen.copy()) - for value in assignments - ) - if isinstance(node, ast.JoinedStr): - return all( - isinstance(value, ast.Constant) - or ( - isinstance(value, ast.FormattedValue) - and reviewed_python_dynamic_path_value( - value.value, tree, parents, seen.copy() - ) + assignment_values.append(value) + if assignment_values: + return all( + temporary_path_expression(value, tree, parents, seen) + for value in assignment_values ) - for value in node.values - ) - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): - return reviewed_python_dynamic_path_value( - node.left, tree, parents, seen.copy() - ) and reviewed_python_dynamic_path_value( - node.right, tree, parents, seen.copy() - ) - return False - - -def reviewed_python_dynamic_git_call(argument, tree=None, parents=None): - """Allow only read-only Git argv with reviewed immutable path components.""" - if not isinstance(argument, (ast.List, ast.Tuple)) or len(argument.elts) < 2: - return False - subcommand_index = 1 - while ( - subcommand_index < len(argument.elts) - and isinstance(argument.elts[subcommand_index], ast.Constant) - and argument.elts[subcommand_index].value in {"-P", "--no-pager"} - ): - subcommand_index += 1 - if subcommand_index >= len(argument.elts): - return False - first, second = argument.elts[0], argument.elts[subcommand_index] - if not ( - isinstance(first, ast.Constant) - and first.value == "git" - and isinstance(second, ast.Constant) - and second.value in git_read_only_subcommands - ): return False - reconstructed = [] - for element in argument.elts: - if isinstance(element, ast.Constant) and isinstance(element.value, str): - reconstructed.append(element.value) - elif reviewed_python_dynamic_path_value(element, tree, parents): - reconstructed.append("__g01_reviewed_dynamic_path__") - else: - return False - if ( - git_command_delegation(reconstructed) is not None - or git_diff_path_violation(reconstructed) is not None - or git_filter_attribute_violation(reconstructed) is not None - or git_read_only_violation(reconstructed) is not None - ): + if isinstance(node, ast.Attribute) and node.attr == "parent": return False - return all( - isinstance(element, ast.Constant) - and isinstance(element.value, str) - or reviewed_python_dynamic_path_value(element, tree, parents) - for element in argument.elts[subcommand_index + 1:] - ) - - -def reviewed_python_dynamic_call( - node, argument, tree, parents, literal_bindings, resolved=None -): - """Allow explicit packet-owned forwarding, never a marker by itself.""" - if ( - isinstance(argument, (ast.List, ast.Tuple)) - and len(argument.elts) == 4 - and isinstance(argument.elts[0], ast.Attribute) - and python_dotted_name(argument.elts[0]) == "sys.executable" - and isinstance(argument.elts[1], ast.Constant) - and argument.elts[1].value == "-I" - and isinstance(argument.elts[2], ast.Constant) - and argument.elts[2].value == "-c" - and isinstance(argument.elts[3], ast.Constant) - and argument.elts[3].value == "import json; print(json.__file__)" - ): - return True - if reviewed_python_git_builder(argument): - return True - if reviewed_python_case_args(argument, tree): - return True - if reviewed_python_static_loop_binding(argument, tree): - return True - if isinstance(argument, (ast.List, ast.Tuple)): - prefix = [] - for element in argument.elts: - if not isinstance(element, ast.Constant) or not isinstance(element.value, str): - break - prefix.append(element.value) - if reviewed_python_synthetic_git_fixture(node, prefix, tree, parents): - return True - if reviewed_python_dynamic_git_call(argument, tree, parents): + if isinstance(node, ast.Call): + dotted = python_dotted_name(node.func) + if dotted in {"Path", "pathlib.Path"} and len(node.args) == 1 and not node.keywords: + return temporary_path_expression(node.args[0], tree, parents, seen) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "joinpath" + and temporary_path_expression(node.func.value, tree, parents, seen) + and all( + isinstance(argument, ast.Constant) + and isinstance(argument.value, str) + and temporary_path_component_safe(argument.value) + for argument in node.args + ) + and not node.keywords + ): return True - if isinstance(argument, ast.Name) and argument.id in literal_bindings: - return True - function_name = enclosing_python_function(node, parents) - if function_name not in reviewed_dynamic_function_names: - return False - if not reviewed_python_helper_definition(node, parents): return False - if not reviewed_python_helper_launcher(node, parents): - return False - if isinstance(argument, ast.Starred): - argument = argument.value - if isinstance(argument, ast.Name): - return argument.id in reviewed_python_helper_allowed_argv.get(function_name, set()) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): + return ( + temporary_path_expression(node.left, tree, parents, seen) + and ( + ( + isinstance(node.right, ast.Constant) + and isinstance(node.right.value, str) + and temporary_path_component_safe(node.right.value) + ) + or ( + isinstance(node.right, ast.Name) + and node.right.id in { + "relative_path", + "relative_directory", + "directory", + } + and any( + isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)) + and parent.name == "create_immutable_source_snapshot" + for parent in _python_parent_chain(node, parents) + ) + ) + ) + ) return False -reviewed_synthetic_git_fixture_argv = { - ("git", "init", "-q"), - ("git", "status", "--porcelain=v1"), - ("git", "add", "source.go"), - ("git", "add", "experiments/g01-scaleset/source.go"), - ( - "git", - "-c", - "user.name=probe", - "-c", - "user.email=probe@example.invalid", - "commit", - "-q", - "-m", - "source", - ), - ( - "git", - "update-index", - "--no-skip-worktree", - "--no-assume-unchanged", - "source.go", - ), - ( - "git", - "update-index", - "--no-skip-worktree", - "--no-assume-unchanged", - "experiments/g01-scaleset/source.go", - ), +def python_filesystem_mutation_violation(tree, parents): + """Reject filesystem mutations unless their path is temp-owned.""" + alias_violation = python_filesystem_mutator_alias_violation(tree, parents) + if alias_violation: + return alias_violation + open_aliases = python_open_aliases(tree) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + dotted = python_dotted_name(node.func) + mutation = False + path_arguments = [] + if isinstance(node.func, ast.Attribute) and node.func.attr in python_filesystem_mutating_methods: + if ( + node.func.attr == "replace" + and not python_path_receiver_expression(node.func.value, tree, parents) + ): + continue + mutation = True + path_arguments = [node.func.value] + if node.func.attr in { + "hardlink_to", + "link_to", + "symlink_to", + "move", + "rename", + "replace", + }: + path_arguments.extend(node.args[:1]) + elif dotted in python_filesystem_mutating_functions: + mutation = True + path_arguments = list(node.args[:2]) + elif dotted == "open" or ( + isinstance(node.func, ast.Name) and node.func.id in open_aliases + ): + unpacked_keyword = any(keyword.arg is None for keyword in node.keywords) + if unpacked_keyword: + mutation = True + mode_keyword = next( + (keyword.value for keyword in node.keywords if keyword.arg == "mode"), + None, + ) + has_mode = len(node.args) > 1 or any( + keyword.arg == "mode" for keyword in node.keywords + ) + mode = node.args[1] if len(node.args) > 1 else mode_keyword + if not has_mode and not unpacked_keyword: + continue + if unpacked_keyword: + mode = None + elif not isinstance(mode, ast.Constant) or not isinstance(mode.value, str): + mutation = True + else: + mutation = any(flag in mode.value for flag in ("w", "a", "x", "+")) + path_arguments = list(node.args[:1]) + elif dotted == "tempfile.TemporaryDirectory": + continue + elif dotted in { + "tempfile.NamedTemporaryFile", + "tempfile.TemporaryFile", + "tempfile.SpooledTemporaryFile", + "tempfile.mkstemp", + "tempfile.mkdtemp", + }: + mutation = True + directory = next( + (keyword.value for keyword in node.keywords if keyword.arg == "dir"), + None, + ) + path_arguments = [directory] if directory is not None else [] + elif dotted == "io.FileIO": + path = node.args[0] if node.args else next( + ( + keyword.value + for keyword in node.keywords + if keyword.arg in {"file", "name"} + ), + None, + ) + mode = node.args[1] if len(node.args) > 1 else next( + (keyword.value for keyword in node.keywords if keyword.arg == "mode"), + None, + ) + if mode is None: + if path is None or not python_reviewed_read_path(path, tree, parents): + mutation = True + elif isinstance(mode, ast.Constant) and isinstance(mode.value, str): + if any(flag in mode.value for flag in ("w", "a", "x", "+")): + mutation = True + elif path is None or not python_reviewed_read_path(path, tree, parents): + mutation = True + else: + mutation = True + path_arguments = [path] if path is not None else [] + if not mutation: + continue + if path_arguments and all( + temporary_path_expression(argument, tree, parents) + for argument in path_arguments + ): + continue + return ( + "Python heredoc contains an unreviewed filesystem mutation " + f"{dotted or ''!r} on line {node.lineno}" + ) + return None + + +python_reviewed_path_values = { + "/opt/homebrew/bin:/usr/bin:/bin", + "/usr/bin:/bin", + "/usr/bin", +} +python_reviewed_path_names = { + "reviewed_path", + "reviewed_shell_path", + "inherited_path_previous", +} +python_reviewed_environment_names = { + "env", + "go_env", + "base_env", + "git_environment", + "remote_environment", + "child_environment", + "child_env", + "probe_env", + "setup_env", + "safe_env", + "isolated_environment", + "git_env", } -def temporary_directory_call(node): - """Recognize the reviewed literal TemporaryDirectory constructor forms.""" - if not isinstance(node, ast.Call) or node.args or node.keywords: - return False - dotted = python_dotted_name(node.func) - if dotted in {"tempfile.TemporaryDirectory", "TemporaryDirectory"}: - return True - return ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "TemporaryDirectory" - and isinstance(node.func.value, ast.Call) - and isinstance(node.func.value.func, ast.Name) - and node.func.value.func.id == "__import__" - and len(node.func.value.args) == 1 - and isinstance(node.func.value.args[0], ast.Constant) - and node.func.value.args[0].value == "tempfile" - and not node.func.value.keywords - ) +def python_path_value_allowed(node): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value in python_reviewed_path_values + return isinstance(node, ast.Name) and node.id in python_reviewed_path_names -def temporary_directory_binding(node, parents): - """Return the local binding only inside a literal TemporaryDirectory block.""" - parent = parents.get(node) - while parent is not None: - if isinstance(parent, (ast.With, ast.AsyncWith)): - for item in parent.items: - context = item.context_expr - if not ( - temporary_directory_call(context) - and isinstance(item.optional_vars, ast.Name) - ): - continue - return item.optional_vars.id - parent = parents.get(parent) +def python_enclosing_scope(node, parents): + """Resolve assignments against the nearest function/module scope.""" + current = node + while current is not None: + if isinstance( + current, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.Module), + ): + return current + current = parents.get(current) return None -def temporary_directory_bindings(node, tree, parents): - """Resolve only direct Path aliases of the active TemporaryDirectory binding.""" - binding = temporary_directory_binding(node, parents) - bindings = {binding} if binding is not None else set() - changed = True - while changed: - changed = False - for candidate in ast.walk(tree): - if temporary_directory_binding(candidate, parents) != binding: - continue - if not isinstance(candidate, ast.Assign): - continue - value = candidate.value - source = None - if isinstance(value, ast.Name): - source = value.id - elif ( - isinstance(value, ast.Call) - and python_dotted_name(value.func) in {"Path", "pathlib.Path"} - and len(value.args) == 1 - and not value.keywords - and isinstance(value.args[0], ast.Name) - ): - source = value.args[0].id - if source not in bindings: - continue - for target in candidate.targets: - if isinstance(target, ast.Name) and target.id not in bindings: - bindings.add(target.id) - changed = True - return bindings - - -def temporary_path_component_safe(value): - """Reject absolute and parent-traversing path components conservatively.""" - if not isinstance(value, str) or value.startswith("/"): - return False - normalized = value.replace("\\", "/") - return ".." not in normalized.split("/") - - -def reviewed_source_snapshot_path(node, tree, parents, seen=None): - """Prove paths belong to the wrapper's private immutable source snapshot.""" +def python_environment_mapping_state(node, tree, seen=None, parents=None): + """Return safe/unsafe/unknown for literal child-environment PATH maps.""" if seen is None: seen = set() + if parents is None: + parents = {} if node is None or id(node) in seen: - return False + return "unknown" seen.add(id(node)) - current = node - while current is not None and not isinstance( - current, (ast.FunctionDef, ast.AsyncFunctionDef) - ): - current = parents.get(current) - if current is None or current.name != "create_immutable_source_snapshot": - return False - if isinstance(node, ast.Name) and node.id == "snapshot_root": - for candidate in ast.walk(current): - if not isinstance(candidate, ast.Assign): - continue - if not any( - isinstance(target, ast.Name) - and target.id == "snapshot_root" - for target in candidate.targets - ): + if isinstance(node, ast.Dict): + state = "safe" + explicit_path = False + for key, value in zip(node.keys, node.values): + if key is None: + unpacked_state = python_environment_mapping_state( + value, tree, seen.copy(), parents + ) + if unpacked_state != "safe": + return unpacked_state continue - value = candidate.value if not ( - isinstance(value, ast.Call) - and python_dotted_name(value.func) in {"Path", "pathlib.Path"} - and len(value.args) == 1 - and not value.keywords - and isinstance(value.args[0], ast.Attribute) - and value.args[0].attr == "name" - and isinstance(value.args[0].value, ast.Name) - and value.args[0].value.id == "snapshot_directory" + isinstance(key, ast.Constant) + and isinstance(key.value, str) ): + state = "unknown" continue - for source_assignment in ast.walk(current): - if not isinstance(source_assignment, ast.Assign): - continue - if any( - isinstance(target, ast.Name) - and target.id == "snapshot_directory" - for target in source_assignment.targets - ) and temporary_directory_call(source_assignment.value): - return True - return False + if credential_environment_name(key.value): + return "unsafe" + if key.value != "PATH": + continue + explicit_path = True + if not python_path_value_allowed(value): + return "unsafe" + return state + if isinstance(node, ast.DictComp): + if ( + len(node.generators) == 1 + and isinstance(node.generators[0].iter, ast.Name) + and node.generators[0].iter.id == "reviewed_child_environment_names" + ): + return "safe" + if ( + len(node.generators) == 1 + and isinstance(node.generators[0].iter, ast.Call) + and python_dotted_name(node.generators[0].iter.func) == "os.environ.items" + and any( + isinstance(candidate, ast.Name) + and candidate.id == "reviewed_child_environment_names" + for condition in node.generators[0].ifs + for candidate in ast.walk(condition) + ) + ): + return "safe" + if ( + len(node.generators) == 1 + and isinstance(node.generators[0].iter, ast.Call) + and python_dotted_name(node.generators[0].iter.func) == "os.environ.items" + and any( + isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) + == "credential_environment_name" + for candidate in ast.walk(node) + ) + ): + return "safe" + return "unknown" if isinstance(node, ast.Name): - for candidate in ast.walk(current): - if not isinstance(candidate, ast.Assign): + states = [] + scope = python_enclosing_scope(node, parents) + for candidate in ast.walk(tree): + if not isinstance(candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): continue - if not any( - isinstance(target, ast.Name) and target.id == node.id - for target in candidate.targets - ): + if python_enclosing_scope(candidate, parents) is not scope: continue - if reviewed_source_snapshot_path( - candidate.value, tree, parents, seen.copy() - ): - return True - return False - if isinstance(node, ast.Attribute) and node.attr == "parent": - return reviewed_source_snapshot_path(node.value, tree, parents, seen) - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): - if not reviewed_source_snapshot_path(node.left, tree, parents, seen): - return False - if isinstance(node.right, ast.Constant): - return temporary_path_component_safe(node.right.value) - return isinstance(node.right, ast.Name) and node.right.id in { - "relative_path", - "relative_directory", - "directory", - } - return False - - -python_filesystem_mutating_methods = { - "chmod", - "chown", - "hardlink_to", - "link_to", - "mkdir", - "makedirs", - "move", - "open", - "rename", - "replace", - "rmdir", - "symlink_to", - "touch", - "unlink", - "write_bytes", - "write_text", -} -python_filesystem_mutating_functions = { - "os.chmod", - "os.chflags", - "os.chown", - "os.fchmodat", - "os.fchownat", - "os.lchmod", - "os.lchflags", - "os.lchown", - "os.link", - "os.makedirs", - "os.mkdir", - "os.mkfifo", - "os.mknod", - "os.remove", - "os.rename", - "os.replace", - "os.rmdir", - "os.symlink", - "os.setxattr", - "os.lsetxattr", - "os.removexattr", - "os.lremovexattr", - "os.truncate", - "os.unlink", - "os.utime", - "os.utime_ns", - "shutil.copy", - "shutil.copy2", - "shutil.copyfile", - "shutil.copymode", - "shutil.copystat", - "shutil.copytree", - "shutil.make_archive", - "shutil.move", - "shutil.rmtree", - "shutil.unpack_archive", -} - -python_reviewed_os_calls = { - "os.close", - "os.environ.clear", - "os.environ.get", - "os.environ.items", - "os.environ.pop", - "os.environ.update", - "os.fdopen", - "os.fork", - "os.fsdecode", - "os.getenv", - "os.getpgrp", - "os.getpid", - "os.kill", - "os.killpg", - "os._exit", - "os.path", - "os.pipe", - "os.read", - "os.set_blocking", - "os.waitpid", - "os.write", -} + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + if any(isinstance(target, ast.Name) and target.id == node.id for target in targets): + states.append( + python_environment_mapping_state( + candidate.value, tree, seen.copy(), parents + ) + ) + if "unsafe" in states: + return "unsafe" + if states and all(state == "safe" for state in states): + return "safe" + if not states and node.id in python_reviewed_environment_names: + return "safe" + return "unknown" + return "unknown" -def python_unknown_os_call_violation(tree): - """Fail closed for OS calls whose path/effect surface is not reviewed.""" +def python_child_environment_violation(tree, modules, functions, parents=None): + """Reject unreviewed PATH changes before Python child argv approval.""" + if parents is None: + parents = {} for node in ast.walk(tree): + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if not isinstance(target, ast.Subscript): + continue + if python_dotted_name(target.value) != "os.environ": + continue + key = target.slice.value if isinstance(target.slice, ast.Constant) else None + if key == "PATH" and not python_path_value_allowed(node.value): + return ( + "Python child environment changes PATH outside the reviewed " + f"path set on line {node.lineno}" + ) if not isinstance(node, ast.Call): continue dotted = python_dotted_name(node.func) - if not dotted or not dotted.startswith("os."): + if dotted == "os.putenv" and node.args: + key = node.args[0].value if isinstance(node.args[0], ast.Constant) else None + if key == "PATH" and (len(node.args) < 2 or not python_path_value_allowed(node.args[1])): + return ( + "Python child environment changes PATH through os.putenv " + f"on line {node.lineno}" + ) + if dotted == "os.environ.update" and node.args: + if python_environment_mapping_state( + node.args[0], tree, parents=parents + ) == "unsafe": + return ( + "Python child environment mapping for os.environ.update is not safe " + f"on line {node.lineno}" + ) + resolved = python_resolved_name(node.func, modules, functions) + if resolved not in python_command_functions: continue - if ( - dotted in python_filesystem_mutating_functions - or dotted in python_reviewed_os_calls - or dotted.startswith("os.path.") - or dotted.startswith("os.environ.") - ): + if resolved == "run_go_child": continue - return ( - "Python heredoc contains an unreviewed OS call with possible path/effect " - f"surface {dotted!r} on line {node.lineno}" - ) - return None - - -def python_filesystem_mutator_alias_violation(tree, parents): - """Reject extracted mutator methods unless their receiver is temp-owned.""" - for node in ast.walk(tree): - assignments = [] - if isinstance(node, ast.Assign): - assignments.extend((target, node.value) for target in node.targets) - elif isinstance(node, ast.AnnAssign): - assignments.append((node.target, node.value)) - elif isinstance(node, ast.NamedExpr): - assignments.append((node.target, node.value)) - for target, value in assignments: - if not isinstance(target, ast.Name): + for keyword in node.keywords: + if keyword.arg != "env": continue - receiver = None - method = None + argument = python_command_argument(node) if ( - isinstance(value, ast.Attribute) - and value.attr in python_filesystem_mutating_methods - ): - receiver = value.value - method = value.attr - elif ( - isinstance(value, ast.Call) - and isinstance(value.func, ast.Name) - and value.func.id == "getattr" - and len(value.args) == 2 - and isinstance(value.args[1], ast.Constant) - and value.args[1].value in python_filesystem_mutating_methods + isinstance(argument, (ast.List, ast.Tuple)) + and argument.elts + and isinstance(argument.elts[0], ast.Constant) + and argument.elts[0].value == "git" ): - receiver = value.args[0] - method = value.args[1].value - elif ( - isinstance(value, ast.Attribute) - and python_dotted_name(value) in python_filesystem_mutating_functions + fixture_value = [] + for element in argument.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + fixture_value.append(element.value) + elif ( + isinstance(element, ast.Call) + and python_dotted_name(element.func) == "str" + and len(element.args) == 1 + and not element.keywords + ): + fixture_value.append("") + else: + fixture_value.append(None) + if reviewed_python_synthetic_git_fixture( + node, fixture_value, tree, parents + ): + continue + if ( + isinstance(argument, ast.Name) + and reviewed_python_static_loop_binding(argument, tree) ): - return ( - "Python heredoc extracts an unowned filesystem mutator " - f"{python_dotted_name(value)!r} into alias {target.id!r} " - f"on line {node.lineno}" + continue + if ( + isinstance(argument, (ast.List, ast.Tuple)) + and argument.elts + and isinstance(argument.elts[0], ast.Constant) + and argument.elts[0].value == "/bin/bash" + and reviewed_python_synthetic_shell_input( + node, ["/bin/bash"], tree, parents ) - if receiver is None: + ): continue - if not temporary_path_expression(receiver, tree, parents): + if python_environment_mapping_state( + keyword.value, tree, parents=parents + ) != "safe": return ( - "Python heredoc extracts an unowned filesystem mutator " - f"{method!r} into alias {target.id!r} on line {node.lineno}" + "Python child launcher has an unknown or unreviewed environment " + f"keyword on line {node.lineno}" ) return None -def python_path_receiver_expression(node, tree, parents, seen=None): - """Recognize a Path-like receiver for ambiguous mutating method names.""" - if node is None: +python_reviewed_read_path_prefixes = ( + "docs/", + "experiments/", + ".github/", + "README", + "go.mod", + "go.sum", +) +python_reviewed_read_path_exact_paths = { + "scripts/evidence_packet/issue79_regression_test.py", +} +python_reviewed_read_path_names = { + "path", + "source", + "source_path", + "packet", + "packet_path", + "current_packet", + "parent_packet", + "probe", + "marker", + "name", + "package_dir", + "repo_root", + "module_dir", + "relative_path", + "directory", +} + +# These are the only helper parameters whose path provenance is reviewed by +# this packet. All other function parameters, including path-like names, are +# rejected unless the function body assigns them from a reviewed path value. +python_reviewed_read_path_parameters = { + ("anchors", "path"), + ("assignment", "path"), + ("git_worktree_matches_pinned_blobs", "repo_root"), + ("git_worktree_matches_pinned_blobs", "relative_path"), + ("source_fuzz_declarations", "source_path"), + ("source_test_names", "source_path"), +} + + +def python_unassigned_path_parameter(node, parents): + """Recognize a function parameter that has no reviewed assignment.""" + if not isinstance(node, ast.Name): return False - if seen is None: - seen = set() - if id(node) in seen: + current = node + while current is not None: + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): + arguments = ( + list(current.args.posonlyargs) + + list(current.args.args) + + list(current.args.kwonlyargs) + ) + return any(argument.arg == node.id for argument in arguments) + current = parents.get(current) + return False + + +def python_reviewed_read_path_parameter(node, tree, parents): + """Permit only named helper parameters with explicit packet provenance.""" + if not python_unassigned_path_parameter(node, parents): return False - seen.add(id(node)) - if temporary_path_expression(node, tree, parents): - return True - if isinstance(node, ast.Call): - dotted = python_dotted_name(node.func) - if dotted in {"Path", "pathlib.Path"}: - return True + current = node + while current is not None and not isinstance( + current, (ast.FunctionDef, ast.AsyncFunctionDef) + ): + current = parents.get(current) + if current is None: + return False + return (current.name, node.id) in python_reviewed_read_path_parameters + + +def python_reviewed_markdown_file_value(node, tree): + """Prove the link checker Path(name) value came from Git's Markdown list.""" + if not ( + isinstance(node, ast.Call) + and python_dotted_name(node.func) in {"Path", "pathlib.Path"} + and len(node.args) == 1 + and not node.keywords + and isinstance(node.args[0], ast.Name) + and node.args[0].id == "name" + ): + return False + files_from_git = False + for candidate in ast.walk(tree): + if not isinstance(candidate, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "files" + for target in candidate.targets + ): + continue + value = candidate.value if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "joinpath" + isinstance(value, ast.Call) + and isinstance(value.func, ast.Attribute) + and value.func.attr == "splitlines" + and isinstance(value.func.value, ast.Call) ): - return python_path_receiver_expression(node.func.value, tree, parents, seen) + value = value.func.value + if not ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) == "subprocess.check_output" + and value.args + and isinstance(value.args[0], (ast.List, ast.Tuple)) + and [ + item.value + for item in value.args[0].elts + if isinstance(item, ast.Constant) + ][:3] + == ["git", "ls-files", "*.md"] + ): + continue + files_from_git = True + break + if not files_from_git: return False - if isinstance(node, ast.Name): - for candidate in ast.walk(tree): - if not isinstance(candidate, ast.Assign): - continue - if not any( - isinstance(target, ast.Name) and target.id == node.id - for target in candidate.targets - ): - continue - if python_path_receiver_expression(candidate.value, tree, parents, seen): - return True - return node.id.casefold().endswith(("path", "file", "directory", "dir", "root")) and not python_unassigned_path_parameter(node, parents) - if isinstance(node, ast.Attribute): - return python_path_receiver_expression(node.value, tree, parents, seen) - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): - return python_path_receiver_expression(node.left, tree, parents, seen) - return False + return any( + isinstance(candidate, ast.For) + and isinstance(candidate.target, ast.Name) + and candidate.target.id == "name" + and isinstance(candidate.iter, ast.Name) + and candidate.iter.id == "files" + for candidate in ast.walk(tree) + ) -def temporary_path_expression(node, tree, parents, seen=None): - """Prove a path stays below the active literal TemporaryDirectory.""" - if node is None: - return False +def python_reviewed_read_path(node, tree, parents, seen=None): + """Allow reads only from reviewed repository or owned temporary paths.""" if seen is None: seen = set() - if reviewed_source_snapshot_path(node, tree, parents, seen.copy()): - return True - binding = temporary_directory_binding(node, parents) - if binding is None: + if node is None or id(node) in seen: return False - bindings = temporary_directory_bindings(node, tree, parents) + seen.add(id(node)) + if temporary_path_expression(node, tree, parents): + return True + if isinstance(node, ast.Constant) and isinstance(node.value, str): + value = node.value.replace("\\", "/") + if value in python_reviewed_read_path_exact_paths: + return True + return ( + not value.startswith("/") + and ".." not in value.split("/") + and value.startswith(python_reviewed_read_path_prefixes) + ) if isinstance(node, ast.Name): - if node.id in bindings: - assignments = [] - for candidate in ast.walk(tree): - targets = [] - value = None - if isinstance(candidate, ast.Assign): - targets = candidate.targets - value = candidate.value - elif isinstance(candidate, ast.AnnAssign): - targets = [candidate.target] - value = candidate.value - elif isinstance(candidate, ast.NamedExpr): - targets = [candidate.target] - value = candidate.value - elif isinstance(candidate, ast.AugAssign): - targets = [candidate.target] - if ( - temporary_directory_binding(candidate, parents) == binding - and any( - isinstance(target, ast.Name) and target.id == node.id - for target in targets - ) - ): - assignments.append(value) - if not assignments: - return True - return all( - value is not None - and temporary_path_expression(value, tree, parents, seen) - for value in assignments - ) - if node.id in seen: - return False - seen.add(node.id) - assignment_values = [] + assignments = [] + scope = python_enclosing_scope(node, parents) for candidate in ast.walk(tree): - targets = [] - value = None - if isinstance(candidate, ast.Assign): + if isinstance(candidate, (ast.Assign, ast.NamedExpr)): targets = candidate.targets - value = candidate.value elif isinstance(candidate, ast.AnnAssign): targets = [candidate.target] - value = candidate.value - elif isinstance(candidate, ast.NamedExpr): - targets = [candidate.target] - value = candidate.value - elif isinstance(candidate, ast.AugAssign): - targets = [candidate.target] - if temporary_directory_binding(candidate, parents) != binding: + else: + continue + if python_enclosing_scope(candidate, parents) is not scope: continue if any( isinstance(target, ast.Name) and target.id == node.id for target in targets ): - assignment_values.append(value) - if assignment_values: + assignments.append(candidate.value) + if assignments: return all( - temporary_path_expression(value, tree, parents, seen) - for value in assignment_values - ) - return False - if isinstance(node, ast.Attribute) and node.attr == "parent": - return False - if isinstance(node, ast.Call): - dotted = python_dotted_name(node.func) - if dotted in {"Path", "pathlib.Path"} and len(node.args) == 1 and not node.keywords: - return temporary_path_expression(node.args[0], tree, parents, seen) - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "joinpath" - and temporary_path_expression(node.func.value, tree, parents, seen) - and all( - isinstance(argument, ast.Constant) - and isinstance(argument.value, str) - and temporary_path_component_safe(argument.value) - for argument in node.args + python_reviewed_read_path(value, tree, parents, seen.copy()) + for value in assignments ) - and not node.keywords - ): + if python_reviewed_read_path_parameter(node, tree, parents): return True return False if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): return ( - temporary_path_expression(node.left, tree, parents, seen) + python_reviewed_read_path(node.left, tree, parents, seen) and ( ( isinstance(node.right, ast.Constant) and isinstance(node.right.value, str) - and temporary_path_component_safe(node.right.value) + and not node.right.value.replace("\\", "/").startswith( + ("/", "~", "$HOME", "${HOME}", "$home", "${home}") + ) + and ".." not in node.right.value.replace("\\", "/").split("/") ) or ( isinstance(node.right, ast.Name) - and node.right.id in { - "relative_path", - "relative_directory", - "directory", - } - and any( - isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)) - and parent.name == "create_immutable_source_snapshot" - for parent in _python_parent_chain(node, parents) - ) + and node.right.id in python_reviewed_read_path_names ) ) ) + if isinstance(node, ast.Call): + if python_dotted_name(node.func) in {"Path", "pathlib.Path"} and len(node.args) == 1 and not node.keywords: + if python_reviewed_markdown_file_value(node, tree): + return True + return python_reviewed_read_path(node.args[0], tree, parents, seen) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "resolve" + and not node.args + and not node.keywords + ): + receiver = node.func.value + if ( + isinstance(receiver, ast.BinOp) + and isinstance(receiver.op, ast.Div) + and all( + isinstance(part, ast.Name) + and part.id in python_reviewed_read_path_names + for part in (receiver.left, receiver.right) + ) + ): + return True + return python_reviewed_read_path(receiver, tree, parents, seen) return False -def python_filesystem_mutation_violation(tree, parents): - """Reject filesystem mutations unless their path is temp-owned.""" - alias_violation = python_filesystem_mutator_alias_violation(tree, parents) - if alias_violation: - return alias_violation - open_aliases = python_open_aliases(tree) +python_sensitive_sink_methods = { + "write", + "writelines", + "write_text", + "write_bytes", + "dump", + "dumps", + "error", + "exception", + "critical", + "warning", + "warn", + "info", + "debug", + "log", +} + + +def python_sensitive_sink_storage_violation(tree, parents): + """Reject output methods hidden in containers or other indirect holders.""" for node in ast.walk(tree): - if not isinstance(node, ast.Call): + if not ( + isinstance(node, ast.Attribute) + and node.attr.casefold() in python_sensitive_sink_methods + ): continue - dotted = python_dotted_name(node.func) - mutation = False - path_arguments = [] - if isinstance(node.func, ast.Attribute) and node.func.attr in python_filesystem_mutating_methods: - if ( - node.func.attr == "replace" - and not python_path_receiver_expression(node.func.value, tree, parents) - ): - continue - mutation = True - path_arguments = [node.func.value] - if node.func.attr in { - "hardlink_to", - "link_to", - "symlink_to", - "move", - "rename", - "replace", - }: - path_arguments.extend(node.args[:1]) - elif dotted in python_filesystem_mutating_functions: - mutation = True - path_arguments = list(node.args[:2]) - elif dotted == "open" or ( - isinstance(node.func, ast.Name) and node.func.id in open_aliases + parent = parents.get(node) + if isinstance(parent, ast.Call) and parent.func is node: + continue + if isinstance(parent, ast.ExceptHandler) and parent.type is node: + continue + if isinstance(parent, ast.Assign) and parent.value is node and all( + isinstance(target, ast.Name) for target in parent.targets ): - unpacked_keyword = any(keyword.arg is None for keyword in node.keywords) - if unpacked_keyword: - mutation = True - mode_keyword = next( - (keyword.value for keyword in node.keywords if keyword.arg == "mode"), - None, - ) - has_mode = len(node.args) > 1 or any( - keyword.arg == "mode" for keyword in node.keywords - ) - mode = node.args[1] if len(node.args) > 1 else mode_keyword - if not has_mode and not unpacked_keyword: - continue - if unpacked_keyword: - mode = None - elif not isinstance(mode, ast.Constant) or not isinstance(mode.value, str): - mutation = True - else: - mutation = any(flag in mode.value for flag in ("w", "a", "x", "+")) - path_arguments = list(node.args[:1]) - elif dotted == "tempfile.TemporaryDirectory": continue - elif dotted in { - "tempfile.NamedTemporaryFile", - "tempfile.TemporaryFile", - "tempfile.SpooledTemporaryFile", - "tempfile.mkstemp", - "tempfile.mkdtemp", - }: - mutation = True - directory = next( - (keyword.value for keyword in node.keywords if keyword.arg == "dir"), - None, - ) - path_arguments = [directory] if directory is not None else [] - elif dotted == "io.FileIO": - path = node.args[0] if node.args else next( + if isinstance(parent, (ast.AnnAssign, ast.NamedExpr)) and parent.value is node and isinstance( + parent.target, ast.Name + ): + continue + return f"Python heredoc stores an output sink method in an unreviewed holder on line {node.lineno}" + return None + + +def python_path_method_alias_visible(name, method, node, tree, parents): + """Resolve Path.home/Path.cwd aliases in lexical scope, respecting shadows.""" + bindings = getattr(tree, "_issue79_path_method_bindings", None) + if bindings is None: + bindings = {} + + def bind(scope, alias, value, source): + bindings.setdefault((id(scope), alias), []).append((source, value)) + + def target_names(target): + if isinstance(target, ast.Name): + return [target.id] + if isinstance(target, (ast.Tuple, ast.List)): + return [name for item in target.elts for name in target_names(item)] + return [] + + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets, value = candidate.targets, candidate.value + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets, value = [candidate.target], candidate.value + elif isinstance(candidate, (ast.For, ast.AsyncFor, ast.comprehension)): + targets, value = [candidate.target], candidate.iter + else: + targets, value = [], None + if targets: + scope = python_enclosing_scope(candidate, parents) + for target in targets: + for alias in target_names(target): + bind(scope, alias, value, candidate) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + scope = python_enclosing_scope(parents.get(candidate), parents) + bind(scope, candidate.name, None, candidate) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + positional = list(candidate.args.posonlyargs) + list(candidate.args.args) + default_offset = len(positional) - len(candidate.args.defaults) + for index, argument in enumerate(positional): + default = ( + candidate.args.defaults[index - default_offset] + if index >= default_offset + else None + ) + bind(candidate, argument.arg, default, candidate) + for argument, default in zip( + candidate.args.kwonlyargs, candidate.args.kw_defaults + ): + bind(candidate, argument.arg, default, candidate) + for argument in (candidate.args.vararg, candidate.args.kwarg): + if argument is not None: + bind(candidate, argument.arg, None, candidate) + if isinstance(candidate, ast.Import): + scope = python_enclosing_scope(candidate, parents) + for imported in candidate.names: + bind( + scope, + imported.asname or imported.name.split(".")[0], + None, + candidate, + ) + elif isinstance(candidate, ast.ImportFrom): + scope = python_enclosing_scope(candidate, parents) + for imported in candidate.names: + bind(scope, imported.asname or imported.name, None, candidate) + tree._issue79_path_method_bindings = bindings + + path_aliases = getattr(tree, "_issue79_path_constructor_aliases", None) + if path_aliases is None: + path_aliases = python_path_constructor_aliases(tree) + tree._issue79_path_constructor_aliases = path_aliases + def binding_is_conditional(source, scope): + current = source + while current is not None and current is not scope: + current = parents.get(current) + if isinstance( + current, ( - keyword.value - for keyword in node.keywords - if keyword.arg in {"file", "name"} + ast.If, + ast.For, + ast.AsyncFor, + ast.While, + ast.Try, + ast.ExceptHandler, + ast.IfExp, + ast.BoolOp, + ast.comprehension, ), - None, - ) - mode = node.args[1] if len(node.args) > 1 else next( - (keyword.value for keyword in node.keywords if keyword.arg == "mode"), - None, - ) - if mode is None: - if path is None or not python_reviewed_read_path(path, tree, parents): - mutation = True - elif isinstance(mode, ast.Constant) and isinstance(mode.value, str): - if any(flag in mode.value for flag in ("w", "a", "x", "+")): - mutation = True - elif path is None or not python_reviewed_read_path(path, tree, parents): - mutation = True - else: - mutation = True - path_arguments = [path] if path is not None else [] - if not mutation: - continue - if path_arguments and all( - temporary_path_expression(argument, tree, parents) - for argument in path_arguments + ) or isinstance(current, (ast.Match, ast.match_case)): + return True + return False + + def resolves_expression(value, scope, seen): + if isinstance(value, ast.Name): + for visible_scope in python_lexical_scope_chain(scope, parents): + if (id(visible_scope), value.id) not in bindings: + continue + return resolves_name( + visible_scope, value.id, seen + ) + return python_path_method_reference(value, method, tree) + + def resolves_name(visible_scope, alias, seen): + key = (id(visible_scope), alias) + if key in seen: + return False + entries = bindings.get(key, ()) + if not entries: + return False + ordered = sorted( + entries, + key=lambda entry: ( + getattr(entry[0], "lineno", -1), + getattr(entry[0], "col_offset", -1), + ), + ) + definite = [ + entry + for entry in ordered + if not binding_is_conditional(entry[0], visible_scope) + ] + base = definite[-1] if definite else None + next_seen = seen | {key} + if ( + base is not None + and base[1] is not None + and resolves_expression(base[1], visible_scope, next_seen) ): - continue - return ( - "Python heredoc contains an unreviewed filesystem mutation " - f"{dotted or ''!r} on line {node.lineno}" + return True + base_position = ordered.index(base) if base is not None else -1 + return any( + bound_expression is not None + and resolves_expression(bound_expression, visible_scope, next_seen) + for index, (source, bound_expression) in enumerate(ordered) + if index > base_position + and binding_is_conditional(source, visible_scope) ) - return None + scope = python_enclosing_scope(node, parents) + for visible_scope in python_lexical_scope_chain(scope, parents): + key = (id(visible_scope), name) + if key not in bindings: + continue + return resolves_name(visible_scope, name, set()) + return False -python_reviewed_path_values = { - "/opt/homebrew/bin:/usr/bin:/bin", - "/usr/bin:/bin", - "/usr/bin", -} -python_reviewed_path_names = { - "reviewed_path", - "reviewed_shell_path", - "inherited_path_previous", -} -python_reviewed_environment_names = { - "env", - "go_env", - "base_env", - "git_environment", - "remote_environment", - "child_environment", - "child_env", - "probe_env", - "setup_env", - "safe_env", - "isolated_environment", - "git_env", -} +def python_path_method_reference(value, method, tree): + """Recognize direct or static getattr references to Path.home/Path.cwd.""" + path_aliases = getattr(tree, "_issue79_path_constructor_aliases", None) + if path_aliases is None: + path_aliases = python_path_constructor_aliases(tree) + tree._issue79_path_constructor_aliases = path_aliases + module_aliases, constructor_aliases = path_aliases + + def is_path_constructor(expression): + return ( + isinstance(expression, ast.Name) + and expression.id in constructor_aliases + ) or ( + isinstance(expression, ast.Attribute) + and expression.attr == "Path" + and isinstance(expression.value, ast.Name) + and expression.value.id in module_aliases + ) + + if isinstance(value, ast.Attribute) and value.attr == method: + return is_path_constructor(value.value) + if isinstance(value, ast.NamedExpr): + return python_path_method_reference(value.value, method, tree) + if isinstance(value, ast.Call): + is_getattr = python_dotted_name(value.func) == "getattr" or ( + isinstance(value.func, ast.Name) + and python_assigned_callable_alias(value.func.id, "getattr", tree) + ) + return ( + is_getattr + and len(value.args) in {2, 3} + and is_path_constructor(value.args[0]) + and method in python_static_string_values(value.args[1], tree) + ) + return False -def python_path_value_allowed(node): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - return node.value in python_reviewed_path_values - return isinstance(node, ast.Name) and node.id in python_reviewed_path_names +def python_path_method_expression_visible(value, method, node, tree, parents): + """Resolve direct and aliased Path methods at an output call site.""" + if isinstance(value, ast.Name): + return python_path_method_alias_visible( + value.id, method, node, tree, parents + ) + return python_path_method_reference(value, method, tree) -def python_enclosing_scope(node, parents): - """Resolve assignments against the nearest function/module scope.""" - current = node - while current is not None: - if isinstance( - current, - (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.Module), - ): - return current - current = parents.get(current) - return None +def python_resolved_local_path_expression( + node, + tree, + parents, + assignments_by_name, + seen=None, +): + """Track resolved local paths into output sinks without rejecting checks. -def python_environment_mapping_state(node, tree, seen=None, parents=None): - """Return safe/unsafe/unknown for literal child-environment PATH maps.""" + A node needs expansion only once per sink: sharing the visited set across + branches avoids exponential revisits through local-helper return cycles. + """ + if node is None: + return False if seen is None: seen = set() - if parents is None: - parents = {} - if node is None or id(node) in seen: - return "unknown" + if id(node) in seen: + return False seen.add(id(node)) - if isinstance(node, ast.Dict): - state = "safe" - explicit_path = False - for key, value in zip(node.keys, node.values): - if key is None: - unpacked_state = python_environment_mapping_state( - value, tree, seen.copy(), parents - ) - if unpacked_state != "safe": - return unpacked_state - continue - if not ( - isinstance(key, ast.Constant) - and isinstance(key.value, str) + if isinstance(node, ast.Name): + scope = python_enclosing_scope(node, parents) + lexical_scopes = set(python_lexical_scope_chain(scope, parents)) + for assigned_scope, value in assignments_by_name.get(node.id, ()): + if assigned_scope in lexical_scopes and python_resolved_local_path_expression( + value, + tree, + parents, + assignments_by_name, + seen, ): - state = "unknown" - continue - if credential_environment_name(key.value): - return "unsafe" - if key.value != "PATH": - continue - explicit_path = True - if not python_path_value_allowed(value): - return "unsafe" - return state - if isinstance(node, ast.DictComp): - if ( - len(node.generators) == 1 - and isinstance(node.generators[0].iter, ast.Name) - and node.generators[0].iter.id == "reviewed_child_environment_names" + return True + return False + if isinstance(node, ast.Call): + dotted = python_dotted_name(node.func) + is_path_home = python_path_method_expression_visible( + node.func, "home", node, tree, parents + ) + is_path_cwd = python_path_method_expression_visible( + node.func, "cwd", node, tree, parents + ) + if is_path_home or is_path_cwd: + return True + if dotted == "os.path.expanduser" and node.args: + return any( + value.startswith("~") + for value in python_static_string_values(node.args[0], tree) + ) + if any( + python_resolved_local_path_expression( + value, + tree, + parents, + assignments_by_name, + seen, + ) + for value in python_local_call_return_values(node, tree, parents) ): - return "safe" - if ( - len(node.generators) == 1 - and isinstance(node.generators[0].iter, ast.Call) - and python_dotted_name(node.generators[0].iter.func) == "os.environ.items" - and any( - isinstance(candidate, ast.Name) - and candidate.id == "reviewed_child_environment_names" - for condition in node.generators[0].ifs - for candidate in ast.walk(condition) + return True + if dotted == "dict" and any( + python_resolved_local_path_expression( + value, tree, parents, assignments_by_name, seen ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] ): - return "safe" + return True if ( - len(node.generators) == 1 - and isinstance(node.generators[0].iter, ast.Call) - and python_dotted_name(node.generators[0].iter.func) == "os.environ.items" + isinstance(node.func, ast.Attribute) + and node.func.attr == "format" and any( - isinstance(candidate, ast.Call) - and python_dotted_name(candidate.func) - == "credential_environment_name" - for candidate in ast.walk(node) + python_resolved_local_path_expression( + value, tree, parents, assignments_by_name, seen + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] ) ): - return "safe" - return "unknown" - if isinstance(node, ast.Name): - states = [] - scope = python_enclosing_scope(node, parents) - for candidate in ast.walk(tree): - if not isinstance(candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): - continue - if python_enclosing_scope(candidate, parents) is not scope: - continue - targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] - if any(isinstance(target, ast.Name) and target.id == node.id for target in targets): - states.append( - python_environment_mapping_state( - candidate.value, tree, seen.copy(), parents + return True + if isinstance(node.func, ast.Name) and node.func.id in { + "iter", "list", "next", "reversed", "set", "sorted", "tuple" + }: + return any( + python_resolved_local_path_expression( + argument, + tree, + parents, + assignments_by_name, + seen, + ) + for argument in node.args + ) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "expanduser" + and not node.args + and not node.keywords + ): + receiver = node.func.value + if isinstance(receiver, ast.Call) and receiver.args: + module_aliases, constructor_aliases = python_path_constructor_aliases(tree) + is_path_constructor = ( + isinstance(receiver.func, ast.Name) + and receiver.func.id in constructor_aliases + ) or ( + isinstance(receiver.func, ast.Attribute) + and receiver.func.attr == "Path" + and isinstance(receiver.func.value, ast.Name) + and receiver.func.value.id in module_aliases + ) + if is_path_constructor and any( + value.startswith("~") + for value in python_static_string_values( + receiver.args[0], tree ) + ): + return True + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "resolve" + ): + return True + path_preserving_calls = { + "ascii", + "format", + "Path", + "pathlib.Path", + "str", + "repr", + "os.fspath", + "os.fsdecode", + "os.path.abspath", + "os.path.realpath", + } + aliased_ascii = ( + isinstance(node.func, ast.Name) + and python_assigned_callable_alias(node.func.id, "ascii", tree) + ) + if dotted not in path_preserving_calls and not aliased_ascii and not ( + isinstance(node.func, ast.Attribute) + and node.func.attr in { + "__fspath__", "__str__", "as_posix", "as_uri", "decode", "encode" + } + ): + return False + return any( + python_resolved_local_path_expression( + child, + tree, + parents, + assignments_by_name, + seen, + ) + for child in ast.iter_child_nodes(node) + if not isinstance( + child, + (ast.expr_context, ast.operator, ast.unaryop, ast.boolop, ast.cmpop), + ) + ) + + +def python_assigned_sink_method_alias(name, tree): + """Track method output sinks after assignment to callable names.""" + python_assigned_callable_alias("", "print", tree) + assignments = getattr(tree, "_issue79_callable_alias_index", {}) + + def sink_method_matches(value, seen): + if isinstance(value, ast.Attribute): + return value.attr.casefold() in python_sensitive_sink_methods + if ( + isinstance(value, ast.Call) + and ( + python_dotted_name(value.func) == "getattr" + or ( + isinstance(value.func, ast.Name) + and python_assigned_callable_alias(value.func.id, "getattr", tree) ) - if "unsafe" in states: - return "unsafe" - if states and all(state == "safe" for state in states): - return "safe" - if not states and node.id in python_reviewed_environment_names: - return "safe" - return "unknown" - return "unknown" + ) + and len(value.args) >= 2 + and any( + method.casefold() in python_sensitive_sink_methods + for method in python_static_string_values(value.args[1], tree) + ) + ): + return True + if isinstance(value, ast.Name) and value.id not in seen: + return any( + sink_method_matches(candidate, seen | {value.id}) + for candidate in assignments.get(value.id, ()) + if candidate is not None + ) + return False + + return any( + sink_method_matches(value, {name}) + for value in assignments.get(name, ()) + if value is not None + ) -def python_child_environment_violation(tree, modules, functions, parents=None): - """Reject unreviewed PATH changes before Python child argv approval.""" - if parents is None: - parents = {} - for node in ast.walk(tree): - if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): - targets = node.targets if isinstance(node, ast.Assign) else [node.target] - for target in targets: - if not isinstance(target, ast.Subscript): - continue - if python_dotted_name(target.value) != "os.environ": - continue - key = target.slice.value if isinstance(target.slice, ast.Constant) else None - if key == "PATH" and not python_path_value_allowed(node.value): - return ( - "Python child environment changes PATH outside the reviewed " - f"path set on line {node.lineno}" - ) - if not isinstance(node, ast.Call): - continue - dotted = python_dotted_name(node.func) - if dotted == "os.putenv" and node.args: - key = node.args[0].value if isinstance(node.args[0], ast.Constant) else None - if key == "PATH" and (len(node.args) < 2 or not python_path_value_allowed(node.args[1])): - return ( - "Python child environment changes PATH through os.putenv " - f"on line {node.lineno}" +def python_sensitive_output_sink(node, tree=None): + """Recognize output/error sinks without tainting ordinary containers/helpers.""" + if not isinstance(node, ast.Call): + return False + dotted = python_dotted_name(node.func) + if dotted in { + "print", + "sys.exit", + "warnings.warn", + "warnings.warn_explicit", + "traceback.print_exc", + "traceback.print_exception", + }: + return True + if isinstance(node.func, ast.NamedExpr): + named_value = node.func.value + named_targets = ( + "print", + "builtins.print", + "sys.exit", + "warnings.warn", + "warnings.warn_explicit", + "traceback.print_exc", + "traceback.print_exception", + ) + if python_dotted_name(named_value) in named_targets: + return True + if ( + isinstance(named_value, ast.Name) + and tree is not None + and ( + any( + python_assigned_callable_alias(named_value.id, target, tree) + for target in named_targets ) - if dotted == "os.environ.update" and node.args: - if python_environment_mapping_state( - node.args[0], tree, parents=parents - ) == "unsafe": - return ( - "Python child environment mapping for os.environ.update is not safe " - f"on line {node.lineno}" + or python_assigned_sink_method_alias(named_value.id, tree) + ) + ): + return True + if isinstance(node.func, ast.Name) and tree is not None: + sink_aliases = getattr(tree, "_issue79_sensitive_output_sink_aliases", None) + if sink_aliases is None: + targets = ( + "print", + "builtins.print", + "sys.exit", + "warnings.warn", + "warnings.warn_explicit", + "traceback.print_exc", + "traceback.print_exception", + ) + python_assigned_callable_alias("", targets[0], tree) + assignment_index = getattr(tree, "_issue79_callable_alias_index", {}) + sink_aliases = { + name + for name in assignment_index + if any( + python_assigned_callable_alias(name, target, tree) + for target in targets ) - resolved = python_resolved_name(node.func, modules, functions) - if resolved not in python_command_functions: - continue - if resolved == "run_go_child": - continue - for keyword in node.keywords: - if keyword.arg != "env": - continue - argument = python_command_argument(node) - if ( - isinstance(argument, (ast.List, ast.Tuple)) - and argument.elts - and isinstance(argument.elts[0], ast.Constant) - and argument.elts[0].value == "git" - ): - fixture_value = [] - for element in argument.elts: - if isinstance(element, ast.Constant) and isinstance(element.value, str): - fixture_value.append(element.value) - elif ( - isinstance(element, ast.Call) - and python_dotted_name(element.func) == "str" - and len(element.args) == 1 - and not element.keywords - ): - fixture_value.append("") - else: - fixture_value.append(None) - if reviewed_python_synthetic_git_fixture( - node, fixture_value, tree, parents - ): + or python_assigned_sink_method_alias(name, tree) + } + imported_functions = { + "builtins": {"print"}, + "sys": {"exit"}, + "warnings": {"warn", "warn_explicit"}, + "traceback": {"print_exc", "print_exception"}, + } + for candidate in ast.walk(tree): + if not isinstance(candidate, ast.ImportFrom): continue - if ( - isinstance(argument, ast.Name) - and reviewed_python_static_loop_binding(argument, tree) + for imported in candidate.names: + if imported.name not in imported_functions.get( + candidate.module or "", set() + ): + continue + local_name = imported.asname or imported.name + if python_imported_function_alias_is_stable( + local_name, candidate.module, imported.name, tree + ): + sink_aliases.add(local_name) + tree._issue79_sensitive_output_sink_aliases = sink_aliases + if node.func.id in sink_aliases: + return True + return ( + isinstance(node.func, ast.Attribute) + and node.func.attr.casefold() in python_sensitive_sink_methods + ) + + +def python_path_division_names(node): + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): + return python_path_division_names(node.left) + python_path_division_names( + node.right + ) + return [node.id] if isinstance(node, ast.Name) else [] + + +def python_try_in_unreachable_if_body(node, parents): + def condition_value(test): + if isinstance(test, ast.Constant): + return bool(test.value) + if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not): + value = condition_value(test.operand) + return None if value is None else not value + if ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "module_dir" + and len(test.ops) == 1 + and len(test.comparators) == 1 + and isinstance(test.comparators[0], ast.Constant) + and test.comparators[0].value == "experiments/g01-scaleset" + ): + if isinstance(test.ops[0], ast.Eq): + return True + if isinstance(test.ops[0], ast.NotEq): + return False + return None + + current = node + while current in parents: + parent = parents[current] + if isinstance(parent, ast.If): + condition = condition_value(parent.test) + if (condition is False and current in parent.body) or ( + condition is True and current in parent.orelse ): + return True + current = parent + return False + + +def python_reviewed_go_package_directory(node, tree, parents): + """Accept only the canonical zero-argument source-fuzz package guard.""" + scope = python_enclosing_scope(node, parents) + source_guards = [ + candidate for candidate in tree.body + if isinstance(candidate, ast.FunctionDef) + and candidate.name == "source_fuzz_guard" + ] + if not ( + isinstance(node, ast.Name) + and node.id == "package_dir" + and len(source_guards) == 1 + and source_guards[0] is scope + and isinstance(scope, ast.FunctionDef) + and scope.name == "source_fuzz_guard" + and not scope.decorator_list + and not scope.args.posonlyargs + and not scope.args.args + and not scope.args.kwonlyargs + and scope.args.vararg is None + and scope.args.kwarg is None + ): + return False + + def target_has_name(target, name): + if isinstance(target, ast.Name): + return target.id == name + if isinstance(target, (ast.Tuple, ast.List)): + return any(target_has_name(element, name) for element in target.elts) + return False + + def top_level_assignments(name): + found = [] + for statement in tree.body: + if isinstance(statement, ast.Assign): + if any(target_has_name(target, name) for target in statement.targets): + found.append((statement, statement.value)) + elif isinstance(statement, ast.AnnAssign) and target_has_name(statement.target, name): + found.append((statement, statement.value)) + return found + + def has_raising_guard(name, expected, expected_name=False): + for statement in tree.body: + if not isinstance(statement, ast.If) or not isinstance(statement.test, ast.Compare): continue - if ( - isinstance(argument, (ast.List, ast.Tuple)) - and argument.elts - and isinstance(argument.elts[0], ast.Constant) - and argument.elts[0].value == "/bin/bash" - and reviewed_python_synthetic_shell_input( - node, ["/bin/bash"], tree, parents + test = statement.test + if not ( + isinstance(test.left, ast.Name) + and test.left.id == name + and len(test.ops) == 1 + and isinstance(test.ops[0], ast.NotEq) + and len(test.comparators) == 1 + and ( + ( + expected_name + and isinstance(test.comparators[0], ast.Name) + and test.comparators[0].id == expected + ) + or ( + not expected_name + and isinstance(test.comparators[0], ast.Constant) + and test.comparators[0].value == expected + ) ) + and len(statement.body) == 1 + and isinstance(statement.body[0], ast.Raise) + and not statement.orelse ): continue - if python_environment_mapping_state( - keyword.value, tree, parents=parents - ) != "safe": - return ( - "Python child launcher has an unknown or unreviewed environment " - f"keyword on line {node.lineno}" - ) - return None + return True + return False + module_values = top_level_assignments("module_values") + module_directory = top_level_assignments("module_dir") + if not ( + len(module_values) == 1 + and isinstance(module_values[0][1], ast.Call) + and python_dotted_name(module_values[0][1].func) == "flag_values" + and len(module_values[0][1].args) == 2 + and isinstance(module_values[0][1].args[0], ast.Name) + and module_values[0][1].args[0].id == "test_args" + and isinstance(module_values[0][1].args[1], ast.Constant) + and module_values[0][1].args[1].value == "-C" + and len(module_directory) == 1 + and isinstance(module_directory[0][1], ast.Subscript) + and isinstance(module_directory[0][1].value, ast.Name) + and module_directory[0][1].value.id == "module_values" + and isinstance(module_directory[0][1].slice, ast.Constant) + and module_directory[0][1].slice.value == 0 + and has_raising_guard("module_dir", "experiments/g01-scaleset") + ): + return False -python_reviewed_read_path_prefixes = ( - "docs/", - "experiments/", - ".github/", - "README", - "go.mod", - "go.sum", -) -python_reviewed_read_path_names = { - "path", - "source", - "source_path", - "packet", - "packet_path", - "current_packet", - "parent_packet", - "probe", - "marker", - "name", - "package_dir", - "repo_root", - "module_dir", - "relative_path", - "directory", -} + package_values = top_level_assignments("package_value") + actual_packages = top_level_assignments("actual_package") + if not ( + len(package_values) == 1 + and isinstance(package_values[0][1], ast.Subscript) + and isinstance(package_values[0][1].value, ast.Name) + and package_values[0][1].value.id == "list_base_args" + and isinstance(package_values[0][1].slice, ast.Subscript) + and isinstance(package_values[0][1].slice.value, ast.Name) + and package_values[0][1].slice.value.id == "package_indices" + and len(actual_packages) == 1 + and isinstance(actual_packages[0][1], ast.JoinedStr) + and {field.value.id for field in ast.walk(actual_packages[0][1]) + if isinstance(field, ast.FormattedValue) and isinstance(field.value, ast.Name)} + >= {"module_dir", "package_value"} + ): + return False + if not has_raising_guard("actual_package", "expected_package", expected_name=True): + return False -# These are the only helper parameters whose path provenance is reviewed by -# this packet. All other function parameters, including path-like names, are -# rejected unless the function body assigns them from a reviewed path value. -python_reviewed_read_path_parameters = { - ("anchors", "path"), - ("assignment", "path"), - ("git_worktree_matches_pinned_blobs", "repo_root"), - ("git_worktree_matches_pinned_blobs", "relative_path"), - ("source_fuzz_declarations", "source_path"), - ("source_test_names", "source_path"), -} + invocation_roots = top_level_assignments("invocation_root") + if len(invocation_roots) != 1: + return False + invocation_root_value = invocation_roots[0][1] + if not ( + isinstance(invocation_root_value, ast.Call) + and isinstance(invocation_root_value.func, ast.Attribute) + and invocation_root_value.func.attr == "resolve" + and not invocation_root_value.args + and not invocation_root_value.keywords + and isinstance(invocation_root_value.func.value, ast.Call) + and python_dotted_name(invocation_root_value.func.value.func) == "Path.cwd" + and not invocation_root_value.func.value.args + and not invocation_root_value.func.value.keywords + ): + return False + repo_roots = top_level_assignments("repo_root") + if len(repo_roots) != 1: + return False + repo_root_value = repo_roots[0][1] + if not ( + isinstance(repo_root_value, ast.Call) + and isinstance(repo_root_value.func, ast.Attribute) + and repo_root_value.func.attr == "resolve" + and not repo_root_value.args + and not repo_root_value.keywords + and isinstance(repo_root_value.func.value, ast.Call) + and python_dotted_name(repo_root_value.func.value.func) == "Path" + and len(repo_root_value.func.value.args) == 1 + and not repo_root_value.func.value.keywords + ): + return False + root_path_argument = repo_root_value.func.value.args[0] + if not ( + isinstance(root_path_argument, ast.Call) + and isinstance(root_path_argument.func, ast.Attribute) + and root_path_argument.func.attr == "strip" + and not root_path_argument.args + and isinstance(root_path_argument.func.value, ast.Call) + and python_dotted_name(root_path_argument.func.value.func) == "subprocess.check_output" + and root_path_argument.func.value.args + ): + return False + check_output_call = root_path_argument.func.value + check_output_keywords = { + keyword.arg: keyword.value for keyword in check_output_call.keywords + } + if not ( + len(check_output_call.args) == 1 + and set(check_output_keywords) == {"cwd", "env", "text"} + and isinstance(check_output_keywords["cwd"], ast.Name) + and check_output_keywords["cwd"].id == "invocation_root" + and isinstance(check_output_keywords["env"], ast.Name) + and check_output_keywords["env"].id == "env" + and isinstance(check_output_keywords["text"], ast.Constant) + and check_output_keywords["text"].value is True + ): + return False + git_root_call = check_output_call.args[0] + if not ( + isinstance(git_root_call, ast.Call) + and python_dotted_name(git_root_call.func) == "git_command" + and len(git_root_call.args) == 1 + and not git_root_call.keywords + and isinstance(git_root_call.args[0], (ast.List, ast.Tuple)) + and [ + item.value for item in git_root_call.args[0].elts + if isinstance(item, ast.Constant) + ] == ["rev-parse", "--show-toplevel"] + and has_raising_guard("invocation_root", "repo_root", expected_name=True) + ): + return False -def python_unassigned_path_parameter(node, parents): - """Recognize a function parameter that has no reviewed assignment.""" - if not isinstance(node, ast.Name): + initializers = [ + candidate for candidate in tree.body + if isinstance(candidate, ast.FunctionDef) + and candidate.name == "package_initialization_guard" + ] + if not ( + len(initializers) == 1 + and not initializers[0].decorator_list + and not initializers[0].args.posonlyargs + and not initializers[0].args.args + and not initializers[0].args.kwonlyargs + and initializers[0].args.vararg is None + and initializers[0].args.kwarg is None + ): + return False + initializer = initializers[0] + snapshot_binding = None + root_binding = None + snapshot_bindings = [] + root_bindings = [] + for candidate in ast.walk(initializer): + if python_enclosing_scope(candidate, parents) is not initializer: + continue + if isinstance(candidate, ast.Assign): + if any(target_has_name(target, "source_snapshot_root") for target in candidate.targets): + snapshot_binding = candidate + snapshot_bindings.append(candidate) + if any(target_has_name(target, "go_repo_root") for target in candidate.targets): + root_binding = candidate + root_bindings.append(candidate) + if not ( + len(snapshot_bindings) == 1 + and len(root_bindings) == 1 + and snapshot_binding is not None + and isinstance(snapshot_binding.value, ast.Call) + and python_dotted_name(snapshot_binding.value.func) == "create_immutable_source_snapshot" + and len(snapshot_binding.value.args) == 3 + and [ + argument.id for argument in snapshot_binding.value.args + if isinstance(argument, ast.Name) + ] == ["repo_root", "module_dir", "env"] + and root_binding is not None + and isinstance(root_binding.value, ast.Name) + and root_binding.value.id == "source_snapshot_root" + and snapshot_binding.end_lineno < root_binding.lineno + ): + return False + source_snapshot_roots = top_level_assignments("source_snapshot_root") + if not ( + len(source_snapshot_roots) == 1 + and isinstance(source_snapshot_roots[0][1], ast.Constant) + and source_snapshot_roots[0][1].value is None + ): return False + + root_names = { + "invocation_root", "repo_root", "module_dir", "package_value", + "actual_package", "source_snapshot_root", "go_repo_root", + } + approved_store_nodes = set() + for name in root_names: + assignments = top_level_assignments(name) + if name == "go_repo_root" and not ( + len(assignments) == 1 + and isinstance(assignments[0][1], ast.Name) + and assignments[0][1].id == "repo_root" + ): + return False + for statement, _value in assignments: + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + approved_store_nodes.update( + target_node + for target in targets + for target_node in ast.walk(target) + if isinstance(target_node, ast.Name) and target_node.id == name + ) + approved_store_nodes.update( + target_node + for target in root_binding.targets + for target_node in ast.walk(target) + if isinstance(target_node, ast.Name) and target_node.id == "go_repo_root" + ) + approved_store_nodes.update( + target_node + for target in snapshot_binding.targets + for target_node in ast.walk(target) + if isinstance(target_node, ast.Name) and target_node.id == "source_snapshot_root" + ) + if any( + isinstance(candidate, ast.Name) + and candidate.id in root_names + and isinstance(candidate.ctx, ast.Store) + and candidate not in approved_store_nodes + for candidate in ast.walk(tree) + ): + return False + + initialization_calls = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == "package_initialization_guard" + ] + fuzz_guard_calls = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == "source_fuzz_guard" + ] + fuzz_guard_call = next( + ( + candidate for candidate in fuzz_guard_calls + if not candidate.args + and not candidate.keywords + and isinstance(parents.get(candidate), ast.Expr) + and parents.get(parents.get(candidate)) is tree + ), + None, + ) + initialization_call = next( + ( + candidate for candidate in initialization_calls + if not candidate.args + and not candidate.keywords + and isinstance(parents.get(candidate), ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "test_source_paths" + for target in parents[candidate].targets + ) + and parents.get(parents.get(candidate)) is tree + ), + None, + ) + if not ( + len(fuzz_guard_calls) == 1 + and fuzz_guard_call is not None + and len(initialization_calls) == 1 + and initialization_call is not None + and initialization_call.lineno < fuzz_guard_call.lineno + ): + return False + + expected_package = ["go_repo_root", "module_dir", "package_value"] + package_assignment = False + for candidate in ast.walk(scope): + if python_enclosing_scope(candidate, parents) is not scope: + continue + if not isinstance(candidate, ast.Assign) or not any( + isinstance(target, ast.Name) and target.id == "package_dir" + for target in candidate.targets + ): + continue + value = candidate.value + if not ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Attribute) + and value.func.attr == "resolve" + and not value.args + and not value.keywords + and python_path_division_names(value.func.value) == expected_package + ): + return False + package_assignment = True + if not package_assignment: + return False + expected_root = ["go_repo_root", "module_dir"] + for candidate in ast.walk(scope): + if ( + not isinstance(candidate, ast.Try) + or candidate.end_lineno >= node.lineno + or parents.get(candidate) is not scope + or python_try_in_unreachable_if_body(candidate, parents) + ): + continue + if python_enclosing_scope(candidate, parents) is not scope: + continue + guarded = ( + len(candidate.body) == 1 + and isinstance(candidate.body[0], ast.Expr) + and isinstance(candidate.body[0].value, ast.Call) + and python_dotted_name(candidate.body[0].value.func) + == "package_dir.relative_to" + and len(candidate.body[0].value.args) == 1 + and python_path_division_names(candidate.body[0].value.args[0]) + == expected_root + ) + fail_closed = any( + isinstance(handler.type, ast.Name) + and handler.type.id == "ValueError" + and len(handler.body) == 1 + and isinstance(handler.body[0], ast.Raise) + for handler in candidate.handlers[:1] + ) + if guarded and fail_closed: + return True + return False + + +def python_reviewed_go_module_metadata_path(node, tree, parents): + """Prove a module metadata path came from the reviewed Go module query.""" + if not ( + isinstance(node, ast.Call) + and python_dotted_name(node.func) in {"Path", "pathlib.Path"} + and len(node.args) == 1 + and not node.keywords + and isinstance(node.args[0], ast.Subscript) + and isinstance(node.args[0].value, ast.Name) + and node.args[0].value.id == "module" + and isinstance(node.args[0].slice, ast.Constant) + and node.args[0].slice.value in {"GoMod", "Dir"} + ): + return False + scope = python_enclosing_scope(node, parents) + if not isinstance(scope, ast.FunctionDef) or scope.name != "verify_downloaded_module_sources": + return False + module_loop = any( + isinstance(candidate, ast.For) + and isinstance(candidate.target, ast.Name) + and candidate.target.id == "module" + and isinstance(candidate.iter, ast.Name) + and candidate.iter.id == "modules" + and python_enclosing_scope(candidate, parents) is scope + for candidate in ast.walk(scope) + ) + modules_from_json = False + download_from_go_list = False + for candidate in ast.walk(scope): + if not isinstance(candidate, ast.Assign): + continue + targets = { + target.id for target in candidate.targets if isinstance(target, ast.Name) + } + value = candidate.value + if ( + "modules" in targets + and isinstance(value, ast.Call) + and python_dotted_name(value.func) == "json_objects" + and value.args + and isinstance(value.args[0], ast.Attribute) + and isinstance(value.args[0].value, ast.Name) + and value.args[0].value.id == "download" + and value.args[0].attr == "stdout" + ): + modules_from_json = True + if ( + "download" in targets + and isinstance(value, ast.Call) + and python_dotted_name(value.func) == "run_go_child" + and value.args + and isinstance(value.args[0], (ast.List, ast.Tuple)) + ): + argv = [ + item.value for item in value.args[0].elts if isinstance(item, ast.Constant) + ] + download_from_go_list = argv == ["go", "mod", "download", "-json", "all"] + return module_loop and modules_from_json and download_from_go_list + + +def python_reviewed_markdown_link_target_path(node, tree, parents): + """Allow link-target existence checks only after repository-root containment.""" + if not isinstance(node, ast.Name) or node.id != "path": + return False + link_loop = None current = node while current is not None: - if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): - arguments = ( - list(current.args.posonlyargs) - + list(current.args.args) - + list(current.args.kwonlyargs) + if ( + isinstance(current, ast.For) + and isinstance(current.target, ast.Name) + and current.target.id == "match" + and isinstance(current.iter, ast.Call) + and python_dotted_name(current.iter.func) == "link.finditer" + and current.iter.args + and isinstance(current.iter.args[0], ast.Name) + and current.iter.args[0].id == "markdown" + ): + link_loop = current + break + current = parents.get(current) + if link_loop is None: + return False + source_loop = next( + ( + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.For) + and isinstance(candidate.target, ast.Name) + and candidate.target.id == "name" + and isinstance(candidate.iter, ast.Name) + and candidate.iter.id == "files" + and python_enclosing_scope(candidate, parents) + is python_enclosing_scope(link_loop, parents) + ), + None, + ) + source_from_git_markdown = source_loop is not None and any( + isinstance(candidate, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "source" + for target in candidate.targets + ) + and isinstance(candidate.value, ast.Call) + and python_reviewed_markdown_file_value(candidate.value, tree) + for candidate in ast.walk(source_loop) + ) + target_from_link_text = any( + isinstance(candidate, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "target" + for target in candidate.targets + ) + and any( + isinstance(call, ast.Call) + and python_dotted_name(call.func) == "match.group" + and call.args + and isinstance(call.args[0], ast.Constant) + and call.args[0].value == 1 + for call in ast.walk(candidate.value) + ) + for candidate in ast.walk(link_loop) + ) + target_path_assignments = [ + candidate + for candidate in ast.walk(link_loop) + if isinstance(candidate, ast.Assign) + and candidate.end_lineno < node.lineno + and any( + isinstance(target, ast.Name) and target.id == "path" + for target in candidate.targets + ) + and isinstance(candidate.value, ast.Call) + and isinstance(candidate.value.func, ast.Attribute) + and candidate.value.func.attr == "resolve" + and not candidate.value.args + and not candidate.value.keywords + and isinstance(candidate.value.func.value, ast.BinOp) + and isinstance(candidate.value.func.value.op, ast.Div) + and isinstance(candidate.value.func.value.left, ast.Attribute) + and candidate.value.func.value.left.attr == "parent" + and isinstance(candidate.value.func.value.left.value, ast.Name) + and candidate.value.func.value.left.value.id == "source" + and isinstance(candidate.value.func.value.right, ast.Name) + and candidate.value.func.value.right.id == "target" + ] + target_path_assignment = bool(target_path_assignments) + reviewed_path_bindings = { + target: assignment + for assignment in target_path_assignments + for target in assignment.targets + if isinstance(target, ast.Name) and target.id == "path" + } + path_bindings = sorted( + ( + candidate + for candidate in ast.walk(link_loop) + if isinstance(candidate, ast.Name) + and candidate.id == "path" + and isinstance(candidate.ctx, (ast.Store, ast.Del)) + and python_enclosing_scope(candidate, parents) + is python_enclosing_scope(node, parents) + ), + key=lambda candidate: (candidate.lineno, candidate.col_offset), + ) + + def source_position(candidate): + return candidate.lineno, candidate.col_offset + + def containing_suite(statement): + parent = parents.get(statement) + if parent is None: + return None + for field in ("body", "orelse", "finalbody"): + suite = getattr(parent, field, None) + if isinstance(suite, list) and any(item is statement for item in suite): + return parent, field + return None + + guarded = False + for candidate in ast.walk(link_loop): + if not isinstance(candidate, ast.Try) or candidate.end_lineno >= node.lineno: + continue + containment_checks = [ + statement.value + for statement in candidate.body + if isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Call) + and isinstance(statement.value.func, ast.Attribute) + and statement.value.func.attr == "relative_to" + and isinstance(statement.value.func.value, ast.Name) + and statement.value.func.value.id == "path" + and len(statement.value.args) == 1 + and isinstance(statement.value.args[0], ast.Name) + and statement.value.args[0].id == "repository_root" + ] + checks_repository_root = bool(containment_checks) + skips_outside_paths = any( + isinstance(handler.type, ast.Name) + and handler.type.id == "ValueError" + and any( + isinstance(statement, ast.Continue) for statement in handler.body + ) + for handler in candidate.handlers + ) + if containment_checks: + containment_position = source_position(containment_checks[0]) + latest_bindings = [ + binding + for binding in path_bindings + if source_position(binding) < containment_position + ] + latest_binding = latest_bindings[-1] if latest_bindings else None + reviewed_assignment = reviewed_path_bindings.get(latest_binding) + same_suite_as_target = ( + reviewed_assignment is not None + and containing_suite(candidate) + == containing_suite(reviewed_assignment) + ) + no_later_path_rebind = not any( + containment_position < source_position(binding) < source_position(node) + for binding in path_bindings + ) + else: + same_suite_as_target = False + no_later_path_rebind = False + if ( + checks_repository_root + and skips_outside_paths + and same_suite_as_target + and no_later_path_rebind + and not python_try_in_unreachable_if_body(candidate, parents) + ): + guarded = True + break + return ( + source_loop is not None + and source_from_git_markdown + and target_from_link_text + and target_path_assignment + and guarded + ) + + +def python_reviewed_path_reader(node, method, tree, parents): + if python_reviewed_read_path(node, tree, parents): + return True + if method in {"glob", "rglob", "iterdir", "walk", "is_dir"} and ( + python_reviewed_go_package_directory(node, tree, parents) + ): + return True + if method in {"is_file", "is_dir"} and python_reviewed_go_module_metadata_path( + node, tree, parents + ): + return True + return method == "is_file" and python_reviewed_markdown_link_target_path( + node, tree, parents + ) + + +def python_path_constructor_aliases(tree): + """Resolve only imports and assignments that alias pathlib.Path itself.""" + module_aliases = set() + constructor_aliases = set() + assignments = [] + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Import): + for alias in candidate.names: + if alias.name == "pathlib": + module_aliases.add(alias.asname or "pathlib") + elif isinstance(candidate, ast.ImportFrom) and candidate.module == "pathlib": + for alias in candidate.names: + if alias.name == "Path": + constructor_aliases.add(alias.asname or alias.name) + elif isinstance(candidate, ast.Assign): + assignments.extend((target, candidate.value) for target in candidate.targets) + elif isinstance(candidate, ast.AnnAssign) and candidate.value is not None: + assignments.append((candidate.target, candidate.value)) + elif isinstance(candidate, ast.NamedExpr): + assignments.append((candidate.target, candidate.value)) + + def is_constructor(value): + if isinstance(value, ast.Name): + return value.id in constructor_aliases + if isinstance(value, ast.Attribute): + return ( + isinstance(value.value, ast.Name) + and value.value.id in module_aliases + and value.attr == "Path" ) - return any(argument.arg == node.id for argument in arguments) - current = parents.get(current) - return False + return False + + for _ in range(len(assignments) + 1): + changed = False + for target, value in assignments: + if not isinstance(target, ast.Name) or not is_constructor(value): + continue + if target.id not in constructor_aliases: + constructor_aliases.add(target.id) + changed = True + if not changed: + break + return module_aliases, constructor_aliases -def python_reviewed_read_path_parameter(node, tree, parents): - """Permit only named helper parameters with explicit packet provenance.""" - if not python_unassigned_path_parameter(node, parents): +def python_path_typed_parameter(node, tree, parents): + """Recognize parameters annotated with an imported pathlib.Path.""" + if not isinstance(node, ast.Name): return False + module_aliases, constructor_aliases = python_path_constructor_aliases(tree) current = node while current is not None and not isinstance( current, (ast.FunctionDef, ast.AsyncFunctionDef) @@ -11805,191 +15554,446 @@ def python_reviewed_read_path_parameter(node, tree, parents): current = parents.get(current) if current is None: return False - return (current.name, node.id) in python_reviewed_read_path_parameters + arguments = ( + list(current.args.posonlyargs) + + list(current.args.args) + + list(current.args.kwonlyargs) + ) + for argument in arguments: + if argument.arg != node.id or argument.annotation is None: + continue + for annotation in ast.walk(argument.annotation): + if isinstance(annotation, ast.Name) and annotation.id in constructor_aliases: + return True + if ( + isinstance(annotation, ast.Attribute) + and annotation.attr == "Path" + and isinstance(annotation.value, ast.Name) + and annotation.value.id in module_aliases + ): + return True + return False -def python_reviewed_markdown_file_value(node, tree): - """Prove the link checker Path(name) value came from Git's Markdown list.""" - if not ( - isinstance(node, ast.Call) - and python_dotted_name(node.func) in {"Path", "pathlib.Path"} - and len(node.args) == 1 - and not node.keywords - and isinstance(node.args[0], ast.Name) - and node.args[0].id == "name" - ): - return False - files_from_git = False +def python_imported_module_alias_is_stable(name, module, tree): + """Require the imported module name to have no competing binding.""" + imported = False for candidate in ast.walk(tree): - if not isinstance(candidate, ast.Assign): - continue - if not any( - isinstance(target, ast.Name) and target.id == "files" - for target in candidate.targets - ): - continue - value = candidate.value - if ( - isinstance(value, ast.Call) - and isinstance(value.func, ast.Attribute) - and value.func.attr == "splitlines" - and isinstance(value.func.value, ast.Call) - ): - value = value.func.value - if not ( - isinstance(value, ast.Call) - and python_dotted_name(value.func) == "subprocess.check_output" - and value.args - and isinstance(value.args[0], (ast.List, ast.Tuple)) - and [ - item.value - for item in value.args[0].elts - if isinstance(item, ast.Constant) - ][:3] - == ["git", "ls-files", "*.md"] - ): - continue - files_from_git = True - break - if not files_from_git: - return False - return any( - isinstance(candidate, ast.For) - and isinstance(candidate.target, ast.Name) - and candidate.target.id == "name" - and isinstance(candidate.iter, ast.Name) - and candidate.iter.id == "files" - for candidate in ast.walk(tree) - ) + if isinstance(candidate, ast.Name) and candidate.id == name and isinstance(candidate.ctx, ast.Store): + return False + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and candidate.name == name: + return False + if isinstance(candidate, ast.arg) and candidate.arg == name: + return False + if isinstance(candidate, ast.Import): + for alias in candidate.names: + local = alias.asname or alias.name.split(".", 1)[0] + if local == name: + if alias.name != module: + return False + imported = True + elif isinstance(candidate, ast.ImportFrom): + for alias in candidate.names: + if (alias.asname or alias.name) == name: + return False + return imported -def python_reviewed_read_path(node, tree, parents, seen=None): - """Allow reads only from reviewed repository or owned temporary paths.""" - if seen is None: - seen = set() - if node is None or id(node) in seen: +def python_imported_function_alias_is_stable(name, module, function, tree): + """Require an imported pure helper alias to have no competing binding.""" + imported = False + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Name) and candidate.id == name and isinstance(candidate.ctx, ast.Store): + return False + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and candidate.name == name: + return False + if isinstance(candidate, ast.arg) and candidate.arg == name: + return False + if isinstance(candidate, ast.Import): + for alias in candidate.names: + if (alias.asname or alias.name.split(".", 1)[0]) == name: + return False + elif isinstance(candidate, ast.ImportFrom): + for alias in candidate.names: + if (alias.asname or alias.name) != name: + continue + if candidate.level or candidate.module != module or alias.name != function: + return False + imported = True + return imported + + +def python_known_non_path_reader_call(node, tree): + """Exempt proven AST/regex APIs that overlap Path filesystem method names.""" + attribute = node.func if isinstance(node, ast.Call) else node + if not isinstance(attribute, ast.Attribute): return False - seen.add(id(node)) - if temporary_path_expression(node, tree, parents): + if attribute.attr == "group" and python_regex_match_receiver( + attribute.value, tree + ): return True - if isinstance(node, ast.Constant) and isinstance(node.value, str): - value = node.value.replace("\\", "/") - return ( - not value.startswith("/") - and ".." not in value.split("/") - and value.startswith(python_reviewed_read_path_prefixes) - ) - if isinstance(node, ast.Name): - assignments = [] - scope = python_enclosing_scope(node, parents) - for candidate in ast.walk(tree): - if isinstance(candidate, (ast.Assign, ast.NamedExpr)): - targets = candidate.targets - elif isinstance(candidate, ast.AnnAssign): - targets = [candidate.target] - else: - continue - if python_enclosing_scope(candidate, parents) is not scope: - continue - if any( - isinstance(target, ast.Name) and target.id == node.id - for target in targets - ): - assignments.append(candidate.value) - if assignments: - return all( - python_reviewed_read_path(value, tree, parents, seen.copy()) - for value in assignments - ) - if python_reviewed_read_path_parameter(node, tree, parents): - return True + if attribute.attr != "walk" or not isinstance(attribute.value, ast.Name): return False - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): - return ( - python_reviewed_read_path(node.left, tree, parents, seen) - and ( - ( - isinstance(node.right, ast.Constant) - and isinstance(node.right.value, str) - and not node.right.value.replace("\\", "/").startswith( - ("/", "~", "$HOME", "${HOME}", "$home", "${home}") - ) - and ".." not in node.right.value.replace("\\", "/").split("/") + module = attribute.value.id + return python_imported_module_alias_is_stable(module, "ast", tree) + + +def python_regex_match_receiver(node, tree, seen=None): + """Prove a .group receiver came from an imported regular-expression API.""" + regex_modules = set() + regex_match_functions = {} + regex_compile_functions = {} + assignments = {} + iterable_bindings = {} + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + + def shadowed_by_parameter(value): + if not isinstance(value, ast.Name): + return False + current = value + while current in parents: + current = parents[current] + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): + parameters = ( + list(current.args.posonlyargs) + + list(current.args.args) + + list(current.args.kwonlyargs) ) - or ( - isinstance(node.right, ast.Name) - and node.right.id in python_reviewed_read_path_names + if current.args.vararg is not None: + parameters.append(current.args.vararg) + if current.args.kwarg is not None: + parameters.append(current.args.kwarg) + return any(parameter.arg == value.id for parameter in parameters) + if isinstance(current, ast.Lambda): + parameters = ( + list(current.args.posonlyargs) + + list(current.args.args) + + list(current.args.kwonlyargs) ) - ) - ) - if isinstance(node, ast.Call): - if python_dotted_name(node.func) in {"Path", "pathlib.Path"} and len(node.args) == 1 and not node.keywords: - if python_reviewed_markdown_file_value(node, tree): - return True - return python_reviewed_read_path(node.args[0], tree, parents, seen) - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "resolve" - and not node.args - and not node.keywords - ): - receiver = node.func.value + if current.args.vararg is not None: + parameters.append(current.args.vararg) + if current.args.kwarg is not None: + parameters.append(current.args.kwarg) + return any(parameter.arg == value.id for parameter in parameters) + return False + + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Import): + for alias in candidate.names: + local = alias.asname or alias.name.split(".", 1)[0] + if alias.name == "re" and python_imported_module_alias_is_stable( + local, "re", tree + ): + regex_modules.add(alias.asname or "re") + elif isinstance(candidate, ast.ImportFrom) and candidate.module == "re": + for alias in candidate.names: + if alias.name in {"match", "fullmatch", "search"}: + regex_match_functions[alias.asname or alias.name] = alias.name + elif alias.name == "compile": + regex_compile_functions[alias.asname or alias.name] = alias.name + elif isinstance(candidate, ast.Assign): + for target in candidate.targets: + if isinstance(target, ast.Name): + assignments.setdefault(target.id, []).append(candidate.value) + elif isinstance(candidate, ast.AnnAssign) and isinstance(candidate.target, ast.Name): + if candidate.value is not None: + assignments.setdefault(candidate.target.id, []).append(candidate.value) + elif isinstance(candidate, ast.NamedExpr) and isinstance(candidate.target, ast.Name): + assignments.setdefault(candidate.target.id, []).append(candidate.value) + elif isinstance(candidate, (ast.For, ast.AsyncFor, ast.comprehension)): + target_names = [] + + def collect_target(target): + if isinstance(target, ast.Name): + target_names.append(target.id) + elif isinstance(target, ast.Starred): + collect_target(target.value) + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + collect_target(element) + + collect_target(candidate.target) + for name in target_names: + iterable_bindings.setdefault(name, []).append(candidate.iter) + + def compiled_pattern(value, visited=None): + if visited is None: + visited = set() + if value is None or id(value) in visited: + return False + visited.add(id(value)) + if isinstance(value, ast.Call): + if isinstance(value.func, ast.Attribute) and value.func.attr == "compile": + module = value.func.value + if isinstance(module, ast.Name) and module.id in regex_modules: + return True if ( - isinstance(receiver, ast.BinOp) - and isinstance(receiver.op, ast.Div) - and all( - isinstance(part, ast.Name) - and part.id in python_reviewed_read_path_names - for part in (receiver.left, receiver.right) + isinstance(value.func, ast.Name) + and value.func.id in regex_compile_functions + and python_imported_function_alias_is_stable( + value.func.id, "re", regex_compile_functions[value.func.id], tree ) ): return True - return python_reviewed_read_path(receiver, tree, parents, seen) - return False + if isinstance(value, ast.Name): + candidates = assignments.get(value.id, ()) + return bool(candidates) and all( + compiled_pattern(candidate, visited.copy()) + for candidate in candidates + ) + return False + + def regex_match_callable(value, visited=None): + if visited is None: + visited = set() + if value is None or id(value) in visited: + return False + visited.add(id(value)) + if isinstance(value, ast.Name): + if value.id in regex_match_functions and python_imported_function_alias_is_stable( + value.id, "re", regex_match_functions[value.id], tree + ): + return True + candidates = assignments.get(value.id, ()) + return bool(candidates) and all( + regex_match_callable(candidate, visited.copy()) + for candidate in candidates + ) + if isinstance(value, ast.Attribute) and value.attr in { + "finditer", "fullmatch", "match", "search", + }: + if isinstance(value.value, ast.Name) and value.value.id in regex_modules: + return True + return compiled_pattern(value.value) + return False + def yields_match(value, visited=None): + if visited is None: + visited = set() + if value is None or id(value) in visited: + return False + visited.add(id(value)) + if isinstance(value, ast.Name): + if shadowed_by_parameter(value): + return False + candidates = tuple(assignments.get(value.id, ())) + tuple( + iterable_bindings.get(value.id, ()) + ) + return bool(candidates) and all( + yields_match(candidate, visited.copy()) + for candidate in candidates + ) + if isinstance(value, ast.Call): + if regex_match_callable(value.func): + return True + if isinstance(value.func, ast.Name) and value.func.id in { + "filter", "iter", "list", "next", "reversed", "set", + "sorted", "tuple", + }: + return bool(value.args) and all( + yields_match(argument, visited.copy()) + for argument in value.args + ) + if isinstance(value.func, ast.Name) and value.func.id == "map": + if value.args and regex_match_callable(value.args[0]): + return True + if isinstance(value.func, ast.Name) and value.func.id == "zip": + return bool(value.args) and all( + yields_match(argument, visited.copy()) for argument in value.args + ) + if isinstance(value, (ast.ListComp, ast.SetComp, ast.GeneratorExp)): + return yields_match(value.elt, visited.copy()) and all( + yields_match(generator.iter, visited.copy()) + for generator in value.generators + ) + if isinstance(value, (ast.List, ast.Tuple, ast.Set)): + return bool(value.elts) and all( + yields_match(element, visited.copy()) for element in value.elts + ) + return False -python_sensitive_sink_methods = { - "write", - "writelines", - "write_text", - "write_bytes", - "dump", - "dumps", - "error", - "exception", - "critical", - "warning", - "warn", - "info", - "debug", - "log", -} + if seen is not None: + return yields_match(node, set(seen)) + return yields_match(node) -def python_sensitive_output_sink(node): - """Recognize output/error sinks without tainting ordinary containers/helpers.""" - if not isinstance(node, ast.Call): - return False - dotted = python_dotted_name(node.func) - if dotted in { - "print", - "warnings.warn", - "warnings.warn_explicit", - "traceback.print_exc", - "traceback.print_exception", - }: - return True - return ( - isinstance(node.func, ast.Attribute) - and node.func.attr.casefold() in python_sensitive_sink_methods +def python_sensitive_read_violation(tree, parents): + """Reject environment/credential reads and unreviewed file read sinks.""" + unresolved_path_getattr = python_unresolved_path_getattr_violation( + tree, parents ) + if unresolved_path_getattr: + return unresolved_path_getattr + sensitive_names = python_sensitive_value_names(tree, parents) + credential_reader_aliases = python_credential_reader_aliases(tree) + path_reader_aliases = python_path_reader_aliases(tree, parents) + assignments_by_name = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets = candidate.targets + value = candidate.value + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets = [candidate.target] + value = candidate.value + elif isinstance(candidate, (ast.For, ast.AsyncFor, ast.comprehension)): + targets = [candidate.target] + value = candidate.iter + else: + continue + for target in targets: + for name in ast.walk(target): + if isinstance(name, ast.Name) and isinstance(name.ctx, ast.Store): + assignments_by_name.setdefault(name.id, []).append( + (python_enclosing_scope(candidate, parents), value) + ) + + def helper_parameters(function): + return list(function.args.posonlyargs) + list(function.args.args) + + def expanded_keyword_values(value): + if isinstance(value, ast.Dict) and all( + isinstance(key, ast.Constant) and isinstance(key.value, str) + for key in value.keys + ): + return dict(zip((key.value for key in value.keys), value.values)) + if ( + isinstance(value, ast.Call) + and python_dotted_name(value.func) == "dict" + and not value.args + and all(keyword.arg is not None for keyword in value.keywords) + ): + return {keyword.arg: keyword.value for keyword in value.keywords} + return None + for function in ast.walk(tree): + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + positional = helper_parameters(function) + default_offset = len(positional) - len(function.args.defaults) + for parameter, default in zip( + positional[default_offset:], function.args.defaults + ): + assignments_by_name.setdefault(parameter.arg, []).append( + (function, default) + ) + for parameter, default in zip( + function.args.kwonlyargs, function.args.kw_defaults + ): + if default is not None: + assignments_by_name.setdefault(parameter.arg, []).append( + (function, default) + ) + + for call in ast.walk(tree): + if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name): + continue + for function in python_local_function_candidates( + call.func.id, call, tree, parents + ): + positional = helper_parameters(function) + for index, argument in enumerate(call.args): + if isinstance(argument, ast.Starred): + for parameter in positional[index:]: + assignments_by_name.setdefault(parameter.arg, []).append( + (function, argument.value) + ) + elif index < len(positional): + assignments_by_name.setdefault(positional[index].arg, []).append( + (function, argument) + ) + if function.args.vararg is not None: + assignments_by_name.setdefault(function.args.vararg.arg, []).append( + (function, argument) + ) + named_parameters = { + parameter.arg + for parameter in positional + function.args.kwonlyargs + } + for keyword in call.keywords: + if keyword.arg in named_parameters: + assignments_by_name.setdefault(keyword.arg, []).append( + (function, keyword.value) + ) + if keyword.arg is None: + expanded_values = expanded_keyword_values(keyword.value) + if expanded_values is not None: + for key, value in expanded_values.items(): + if key in named_parameters: + assignments_by_name.setdefault(key, []).append( + (function, value) + ) + else: + for name in named_parameters: + assignments_by_name.setdefault(name, []).append( + (function, keyword.value) + ) + if function.args.kwarg is not None and ( + keyword.arg is None or keyword.arg not in named_parameters + ): + assignments_by_name.setdefault(function.args.kwarg.arg, []).append( + (function, keyword.value) + ) + local_exception_names = { + candidate.name + for candidate in ast.walk(tree) + if isinstance(candidate, ast.ClassDef) + and any( + isinstance(base, ast.Name) and base.id in {"Exception", "ValueError"} + for base in candidate.bases + ) + } + + def exception_arguments(value, seen_names=None, follow_names=True): + if value is None: + return [] + if seen_names is None: + seen_names = set() + if isinstance(value, ast.Name): + if not follow_names: + return [value] + if value.id in seen_names: + return [value] + scope = python_enclosing_scope(value, parents) + sources = ( + (source_scope, source) + for source_scope, source in assignments_by_name.get(value.id, ()) + if source_scope is scope + and ( + isinstance(source, ast.Name) + or ( + isinstance(source, ast.Call) + and ( + (python_dotted_name(source.func) or "").rsplit(".", 1)[-1].endswith( + ("Error", "Exception", "Exit", "Warning") + ) + or (python_dotted_name(source.func) or "").rsplit(".", 1)[-1] in { + "StopIteration", "StopAsyncIteration", "KeyboardInterrupt", + "ExceptionGroup", "BaseExceptionGroup", "TimeoutExpired", + } + or (python_dotted_name(source.func) or "").rsplit(".", 1)[-1] + in local_exception_names + ) + ) + ) + ) + return [value] + [ + argument + for _scope, source in sources + for argument in exception_arguments(source, seen_names | {value.id}) + ] + if isinstance(value, ast.Call): + arguments = list(value.args) + [keyword.value for keyword in value.keywords] + return [value] + [ + nested + for argument in arguments + for nested in exception_arguments(argument, seen_names) + ] + return [value] -def python_sensitive_read_violation(tree, parents): - """Reject environment/credential reads and unreviewed file read sinks.""" - sensitive_names = python_sensitive_value_names(tree, parents) - credential_reader_aliases = python_credential_reader_aliases(tree) - path_reader_aliases = python_path_reader_aliases(tree) for alias, receiver in path_reader_aliases.items(): if not python_reviewed_read_path(receiver, tree, parents): return f"Python unreviewed Path reader alias {alias!r} is not allowed" @@ -12001,6 +16005,9 @@ def python_sensitive_read_violation(tree, parents): "Python credential/environment subscript is not allowed " f"on line {node.lineno}" ) + if isinstance(node, ast.Assert) and node.msg is not None: + if python_sensitive_value_expression(node.msg, sensitive_names, tree, parents): + return f"Python assertion message contains an environment value on line {node.lineno}" if isinstance(node, ast.Raise): if node.exc is not None and python_sensitive_value_expression( node.exc, sensitive_names, tree, parents @@ -12009,6 +16016,27 @@ def python_sensitive_read_violation(tree, parents): "Python credential/environment value is sent to an exception " f"on line {node.lineno}" ) + exception_values = exception_arguments(node.exc) + exception_arguments(node.cause) + if any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents + ) + for value in exception_values if value is not None + ): + return ( + "Python credential/environment value is sent through an exception argument " + f"on line {node.lineno}" + ) + if any( + python_resolved_local_path_expression( + value, tree, parents, assignments_by_name + ) + for value in exception_values if value is not None + ): + return ( + "Python resolved local path is sent to an exception " + f"on line {node.lineno}" + ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) if dotted == "os.getenv": @@ -12048,17 +16076,36 @@ def python_sensitive_read_violation(tree, parents): "Python unreviewed Path reader alias call " f"{node.func.id!r} is not allowed on line {node.lineno}" ) - if python_sensitive_output_sink(node) and any( + output_arguments = list(node.args) + [ + keyword.value for keyword in node.keywords + ] + if python_sensitive_output_sink(node, tree) and any( + python_resolved_local_path_expression( + argument, + tree, + parents, + assignments_by_name, + ) + for argument in output_arguments + ): + return ( + "Python resolved local path is sent to an output/error sink " + f"{dotted or ''!r} on line {node.lineno}" + ) + if python_sensitive_output_sink(node, tree) and any( python_sensitive_value_expression( argument, sensitive_names, tree, parents ) - or any( - isinstance(candidate, ast.Attribute) - and python_dotted_name(candidate) == "os.environ" - for candidate in ast.walk(argument) + or ( + any( + isinstance(candidate, ast.Attribute) + and python_dotted_name(candidate) == "os.environ" + for candidate in ast.walk(argument) + ) + and id(argument) + not in getattr(tree, "_issue79_safe_local_format_calls", set()) ) - for argument in list(node.args) - + [keyword.value for keyword in node.keywords] + for argument in output_arguments ): return ( "Python credential/environment value is sent to an output/error " @@ -12070,12 +16117,38 @@ def python_sensitive_read_violation(tree, parents): path = receiver.args[0] if receiver.args else None if not python_reviewed_read_path(path, tree, parents): return f"Python unreviewed file read through open on line {node.lineno}" - if isinstance(node.func, ast.Attribute) and node.func.attr in {"read_text", "read_bytes"}: - if not python_reviewed_read_path(node.func.value, tree, parents): + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr in python_path_filesystem_read_methods + ): + if python_known_non_path_reader_call(node, tree): + continue + if not python_path_receiver_expression( + node.func.value, tree, parents + ): + if python_reviewed_read_path( + node.func.value, tree, parents + ): + continue + return ( + "Python filesystem reader has unresolved Path receiver " + f"for .{node.func.attr} on line {node.lineno}" + ) + if not python_reviewed_path_reader( + node.func.value, node.func.attr, tree, parents + ): return ( - f"Python unreviewed Path.{node.func.attr} file read " + f"Python unreviewed Path.{node.func.attr} filesystem read " f"on line {node.lineno}" ) + if node.func.attr == "samefile" and any( + not python_reviewed_read_path(argument, tree, parents) + for argument in node.args + ): + return ( + "Python Path.samefile filesystem read has an unreviewed " + f"peer path on line {node.lineno}" + ) return None @@ -12318,8 +16391,35 @@ def python_open_aliases(tree): return aliases -def python_path_reader_aliases(tree): - """Resolve Path.read_text/read_bytes callable aliases to their receivers.""" +python_path_filesystem_read_methods = { + "open", + "read_text", + "read_bytes", + "readlink", + "iterdir", + "glob", + "rglob", + "walk", + "stat", + "lstat", + "exists", + "is_file", + "is_dir", + "is_symlink", + "is_mount", + "is_socket", + "is_fifo", + "is_block_device", + "is_char_device", + "is_junction", + "samefile", + "owner", + "group", +} + + +def python_path_reader_aliases(tree, parents): + """Resolve aliases for Path methods that inspect filesystem state.""" aliases = {} assignments = [] for node in ast.walk(tree): @@ -12329,16 +16429,66 @@ def python_path_reader_aliases(tree): assignments.append((node.target, node.value)) elif isinstance(node, ast.NamedExpr): assignments.append((node.target, node.value)) + + def reader_receivers(value): + if ( + isinstance(value, ast.Attribute) + and value.attr in python_path_filesystem_read_methods + and not python_known_non_path_reader_call(value, tree) + ): + return [value.value] + if ( + isinstance(value, ast.Call) + and ( + python_dotted_name(value.func) == "getattr" + or ( + isinstance(value.func, ast.Name) + and python_assigned_callable_alias( + value.func.id, "getattr", tree + ) + ) + ) + and len(value.args) in {2, 3} + ): + receivers = [] + for method in python_static_string_values_from_local_calls( + value.args[1], tree, parents + ): + attribute = ast.Attribute( + value=value.args[0], attr=method, ctx=ast.Load() + ) + if ( + method in python_path_filesystem_read_methods + and not python_known_non_path_reader_call(attribute, tree) + ): + receivers.append(value.args[0]) + if len(value.args) == 3: + receivers.extend(reader_receivers(value.args[2])) + return receivers + return [] + for _ in range(len(assignments) + 1): changed = False for target, value in assignments: if not isinstance(target, ast.Name): continue receiver = None - if isinstance(value, ast.Attribute) and value.attr in {"read_text", "read_bytes"}: - receiver = value.value - elif isinstance(value, ast.Name) and value.id in aliases: + if isinstance(value, ast.Name) and value.id in aliases: receiver = aliases[value.id] + candidates = reader_receivers(value) + if isinstance(value, ast.Call): + for returned in python_local_call_return_values(value, tree, parents): + candidates.extend(reader_receivers(returned)) + for candidate in candidates: + if receiver is None or not python_reviewed_read_path( + candidate, tree, parents + ): + receiver = candidate + previous = aliases.get(target.id) + if previous is not None and not python_reviewed_read_path( + previous, tree, parents + ): + continue if receiver is not None and aliases.get(target.id) is not receiver: aliases[target.id] = receiver changed = True @@ -12347,6 +16497,35 @@ def python_path_reader_aliases(tree): return aliases +def python_unresolved_path_getattr_violation(tree, parents): + """Fail closed when getattr selects an unknown member from a Path receiver.""" + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Call) + and ( + python_dotted_name(node.func) == "getattr" + or ( + isinstance(node.func, ast.Name) + and python_assigned_callable_alias( + node.func.id, "getattr", tree + ) + ) + ) + and len(node.args) in {2, 3} + ): + continue + if python_static_string_values_from_local_calls( + node.args[1], tree, parents + ): + continue + if python_path_receiver_expression(node.args[0], tree, parents): + return ( + "Python getattr has an unresolved member on a Path receiver " + f"on line {node.lineno}" + ) + return None + + def python_open_read_violation(tree, parents): """Validate readable open paths before iteration/list/constructor consumers.""" open_aliases = python_open_aliases(tree) @@ -12386,6 +16565,144 @@ def python_open_read_violation(tree, parents): return None +def python_unreviewed_decorator_violation(tree, parents): + """Keep decorators limited to inert reviewed builtin forms.""" + sys_names = python_assigned_module_names(tree, "sys") + for candidate in ast.walk(tree): + if isinstance(candidate, ast.ImportFrom) and candidate.module == "sys" and any( + alias.name in {"modules", "_getframe", "_current_frames", "*"} for alias in candidate.names + ): + return "Python heredoc imports a mutable process namespace handle" + if ( + isinstance(candidate, ast.Call) + and isinstance(candidate.func, ast.Name) + and ( + candidate.func.id in {"globals", "locals"} + or (candidate.func.id == "vars" and not candidate.args) + or python_assigned_callable_alias(candidate.func.id, "globals", tree) + or python_assigned_callable_alias(candidate.func.id, "locals", tree) + or ( + not candidate.args + and python_assigned_callable_alias(candidate.func.id, "vars", tree) + ) + ) + ): + return "Python heredoc accesses the mutable global namespace" + if ( + isinstance(candidate, ast.Attribute) + and candidate.attr in {"modules", "_getframe", "_current_frames"} + and isinstance(candidate.value, ast.Name) + and candidate.value.id in sys_names + ): + return "Python heredoc accesses a mutable process namespace handle" + if isinstance(candidate, ast.Name) and candidate.id == "__builtins__": + return "Python heredoc accesses the mutable builtins namespace" + if isinstance(candidate, ast.Import) and any( + alias.name == "builtins" for alias in candidate.names + ): + return "Python heredoc imports the mutable builtins namespace" + if isinstance(candidate, ast.ImportFrom) and candidate.module == "builtins": + return "Python heredoc imports from the mutable builtins namespace" + reviewed_value = ast.parse("self._process.returncode", mode="eval").body + def shadows_builtin(name): + return any( + ( + isinstance(candidate, ast.Name) + and candidate.id == name + and isinstance(candidate.ctx, ast.Store) + ) + or ( + isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and candidate.name == name + ) + or (isinstance(candidate, ast.arg) and candidate.arg == name) + or ( + isinstance(candidate, (ast.Import, ast.ImportFrom)) + and any((alias.asname or alias.name) == name for alias in candidate.names) + ) + for candidate in ast.walk(tree) + ) + property_is_shadowed = shadows_builtin("property") + staticmethod_is_shadowed = shadows_builtin("staticmethod") + staticmethod_aliases = {} + if not staticmethod_is_shadowed: + for candidate in ast.walk(tree): + if ( + isinstance(candidate, ast.Assign) + and len(candidate.targets) == 1 + and isinstance(candidate.targets[0], ast.Name) + and isinstance(candidate.value, ast.Name) + and candidate.value.id == "staticmethod" + ): + staticmethod_aliases[candidate.targets[0].id] = candidate.lineno + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if not node.decorator_list: + continue + parent = parents.get(node) + decorator = node.decorator_list[0] + reviewed_staticmethod = ( + not staticmethod_is_shadowed + and isinstance(node, ast.FunctionDef) + and isinstance(parent, ast.ClassDef) + and len(node.decorator_list) == 1 + and isinstance(decorator, ast.Name) + and ( + decorator.id == "staticmethod" + or ( + decorator.id in staticmethod_aliases + and staticmethod_aliases[decorator.id] < node.lineno + and sum( + isinstance(candidate, ast.Name) + and candidate.id == decorator.id + and isinstance(candidate.ctx, ast.Store) + for candidate in ast.walk(tree) + ) == 1 + and not any( + ( + isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and candidate.name == decorator.id + ) + or (isinstance(candidate, ast.arg) and candidate.arg == decorator.id) + or ( + isinstance(candidate, (ast.Import, ast.ImportFrom)) + and any( + (alias.asname or alias.name) == decorator.id + for alias in candidate.names + ) + ) + for candidate in ast.walk(tree) + ) + ) + ) + ) + reviewed_property = ( + not property_is_shadowed + and isinstance(node, ast.FunctionDef) + and isinstance(parent, ast.ClassDef) + and parent.name == "GoAliasPopen" + and node.name == "returncode" + and len(node.decorator_list) == 1 + and isinstance(node.decorator_list[0], ast.Name) + and node.decorator_list[0].id == "property" + and len(node.args.args) == 1 + and node.args.args[0].arg == "self" + and not node.args.posonlyargs + and not node.args.kwonlyargs + and node.args.vararg is None + and node.args.kwarg is None + and not node.args.defaults + and len(node.body) == 1 + and isinstance(node.body[0], ast.Return) + and ast.dump(node.body[0].value, include_attributes=False) + == ast.dump(reviewed_value, include_attributes=False) + ) + if not reviewed_property and not reviewed_staticmethod: + return f"Python heredoc has an unreviewed decorator on line {node.lineno}" + return None + + def inspect_python_heredoc(body, safe_marker): try: tree = ast.parse(body, filename="") @@ -12405,6 +16722,9 @@ def inspect_python_heredoc(body, safe_marker): if import_path_mutation_violation: return import_path_mutation_violation mappings = python_mapping_bindings(tree, modules, functions) + mapping_lookup_violation = python_mapping_lookup_alias_violation(tree, mappings) + if mapping_lookup_violation: + return mapping_lookup_violation literal_bindings = python_literal_bindings(tree) dynamic_bindings, unresolved_dynamic_bindings = python_dynamic_execution_bindings(tree) parents = { @@ -12412,6 +16732,15 @@ def inspect_python_heredoc(body, safe_marker): for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } + sink_storage_violation = python_sensitive_sink_storage_violation(tree, parents) + if sink_storage_violation: + return sink_storage_violation + decorator_violation = python_unreviewed_decorator_violation(tree, parents) + if decorator_violation: + return decorator_violation + subprocess_os_violation = python_subprocess_os_reexport_violation(tree) + if subprocess_os_violation: + return subprocess_os_violation class_command_violation = python_class_command_attribute_violation( tree, modules, functions ) @@ -12562,7 +16891,7 @@ def inspect_python_heredoc(body, safe_marker): continue segments = shell_token_segments(value) if kind == "shell" else [value] for segment in segments: - violation = forbidden_command(segment) + violation = forbidden_shell_command(segment) if violation: return f"Python heredoc command: {violation}" if dynamic_calls: @@ -12587,7 +16916,7 @@ for command, number in shell_commands(source): "an isolated AST-inspected heredoc" ) continue - violation = forbidden_command(segment) + violation = forbidden_shell_command(segment) if violation: matches.append(f"line {number}: {violation}") for number, body, safe_marker, invocation in python_heredoc_bodies(source): @@ -24095,10 +28424,12 @@ boundary remain preserved. The RED command reads the immutable parent with `git show`, extracts only its offline scanner and evaluates synthetic AST/string witnesses. A RED line means the exact parent returned no violation for the unsafe witness. The Markdown -link RED case evaluates the parent's old `is_file()`-only local-target rule -against an existing absolute file; no fixture file, payload, credential, -compiler, Go child, workflow, runner, Docker, Lima, Keychain, launchd or live -remote operation is started. +link RED case now exercises the parent's old `is_file()`-only local-target +rule with a synthetic absolute path and a lexical `is_absolute()` check. The +captured output below preserves the earlier run's host-path presence result as +historical evidence; the current source does not repeat that check. No fixture +file, payload, credential, compiler, Go child, workflow, runner, Docker, Lima, +Keychain, launchd or live remote operation is started. ~~~sh # g01-safe-python-heredoc: reviewed exact-parent 518f23c seven-finding RED probe @@ -24190,10 +28521,10 @@ link_end = parent_packet.index("jq empty docs/backlog.json", link_start) old_link_rule = parent_packet[link_start:link_end] if "repository_root" in old_link_rule or "relative_to(" in old_link_rule: raise SystemExit("RED setup changed: exact parent already had repository-root link containment") -absolute_target = (Path("docs/evidence/g01-recovery-packet.md").parent / "/etc/passwd").resolve() -if not absolute_target.is_file(): - raise SystemExit("RED setup changed: absolute boundary file unavailable") -print("RED 5675188494 absolute Markdown local target: immutable parent accepted existing /etc/passwd") +absolute_target = Path("/") / "synthetic-private" / "file" +if not absolute_target.is_absolute(): + raise SystemExit("RED setup changed: synthetic absolute target formation changed") +print("RED 5675188494 absolute Markdown local target: immutable parent accepted synthetic absolute target") print("exact-parent 518f23c seven-finding RED probes: all seven findings / 12 unsafe witnesses accepted") PY ~~~ @@ -25183,19 +29514,1835 @@ test was claimed. Rollback is packet-only to immutable parent `f44a4871f87c1a8165593549d58ece6ffee61bf2`; preserve historical evidence, independent corrections and manually installed runners. -### Final packet certification after exact-head review comment `5676911476` +### Pre-issue-79 packet certification after exact-head review comment `5676911476` + +That pre-issue-79 certification reran the immutable-parent RED and current +GREEN/CURRENT boundary commands above, then checked Markdown fence/style +parity, packet-local links and fragments, backlog JSON, its then-current +six-row ledger and preserved historical ledgers, embedded wrapper/scanner/ +parity AST and compile, full static scanner zero violations, one-file scope, +added-line secret/private-path hygiene and pager-safe Git diff checks. Its +link, scanner and ledger totals below describe the pre-issue-79 packet only. +Local/tracking/remote/PR SHA parity was to be recorded in the worker handoff +after the stable candidate push so the packet remained non-self-referential. +No live verification, workflow replay, credential use, source-code test, merge +or Codex review was claimed; rollback was packet-only to +`f44a4871f87c1a8165593549d58ece6ffee61bf2`. -Final certification reruns the immutable-parent RED and current GREEN/CURRENT -boundary commands above, then checks Markdown fence/style parity, packet-local -links and fragments, backlog JSON, the current six-row ledger and preserved -historical ledgers, embedded wrapper/scanner/parity AST and compile, full -static scanner zero violations, one-file scope, added-line secret/private-path -hygiene and pager-safe Git diff checks. Local/tracking/remote/PR SHA parity is -recorded in the worker handoff after the one stable candidate push so the -packet remains non-self-referential. No live verification, workflow replay, -credential use, source-code test, merge or Codex review is claimed; rollback is -packet-only to `f44a4871f87c1a8165593549d58ece6ffee61bf2`. +~~~text +Historical pre-issue-79 counts: 474 raw Markdown fence-like marker lines, of which 472 are semantic markers in 236 matching pairs; 1,040 Markdown links across 56 tracked Markdown files (157 local targets, 49 fragments, 883 external syntax URLs); prior six-row ledger valid with 6 rows x 4 columns and historical URL/source/disposition ledger valid with 31 rows; exact-parent RED plus current GREEN/CURRENT and failure-boundary probes passed with 2 scoped fresh shell probes and 0 script errors; wrapper/scanner AST and compile valid, 95 Python heredoc bodies AST/compile valid, full static scanner passed with 331 shell commands and zero violations; one-file scope, added-line secret/private-path hygiene, and git -P diff --check passed; no live verification, workflow replay, credential use, source-code test, merge or Codex review claimed; rollback parent f44a4871f87c1a8165593549d58ece6ffee61bf2 +~~~ + +### Issue #79 evidence hardening from exact-head review comment `5677854749` + +The [seven-finding review comment](https://github.com/1XP-AI/gh-runnerd/pull/78#issuecomment-5677854749) +was reported against immutable packet source +`b6dbf021801ef5d920d1a9e7659f8bd97be11695`. The focused offline regression +harness is [issue79_regression_test.py](../../scripts/evidence_packet/issue79_regression_test.py). +The test inputs are synthetic strings and mappings. The harness statically +loads only scanner definitions and safe literal configuration from this +packet, parses shell/Python examples as data, and runs only literal Git setup +commands against temporary local repositories created by the tests. + +The red run was recorded before the scanner corrections on branch baseline +`3d108256883458d25446a6311c8f50176a8ee7cd`: + +~~~text +RED: python3 scripts/evidence_packet/issue79_regression_test.py +7 tests run; 18 assertion failures across all seven findings. +GREEN: python3 scripts/evidence_packet/issue79_regression_test.py +7 tests passed after the scoped fail-closed corrections. +RED packet-blob boundary: python3 -B scripts/evidence_packet/issue79_regression_test.py +8 tests ran; 7 passed and the packet-sized synthetic blob errored with "Git query output exceeded the reviewed budget" at the 64 KiB metadata cap. +GREEN current: python3 -B scripts/evidence_packet/issue79_regression_test.py +8 tests passed; packet blob capture uses its separate 8 MiB cap, while metadata queries remain capped at 64 KiB. +~~~ + +#### Seven-finding URL/source/disposition ledger + +| # | Finding in review `5677854749` | Red proof | Correction and retained safe case | +|---|---|---|---| +| 1 | Path filesystem readers beyond `read_text`/`read_bytes` bypass path review | Synthetic `Path.glob`, `iterdir`, and `stat` calls on unreviewed paths were accepted | Path receivers and aliases require reviewed provenance; unreviewed readers are rejected, annotated `Path` readers stay fail-closed, reviewed packet `Path.read_text`/`stat` remain accepted, and the existing package `glob` is accepted only after its `relative_to` root guard. | +| 2 | Environment values read through loops/comprehensions are not tainted | Synthetic loop and list-comprehension targets could receive `os.environ` values without reaching the sink check | Loop and comprehension targets inherit iterable taint; literal loop data remains accepted. | +| 3 | Process launchers hidden in iterable targets bypass command checks | Synthetic list/tuple launcher elements and loop aliases passed unresolved | Launcher aliases in iterable/loop targets fail closed; ordinary `str.upper` iterator callbacks remain accepted. | +| 4 | Shell `export`/`set` forms dump inherited environment values | Bare `export`, `export -p`, and non-reviewed `set` forms passed | Bare/dump builtins and non-reviewed `set` forms are rejected; exact `set -euo pipefail` and enumerated packet export values remain accepted. | +| 5 | Negative-filter Git child environment forwards credentials | A synthetic child environment containing `GH_TOKEN`, `GITHUB_TOKEN`, app-key, and generic-secret names retained them | Git children receive the positive `PATH`/locale allowlist plus reviewed Git config overrides; synthetic credentials are dropped. | +| 6 | Exact-head parity misses Git intent bits and raw-byte divergence | A temporary local repository showed skip-worktree/assume-unchanged bits and worktree byte divergence that porcelain status alone missed | The verifier checks `git ls-files -v -z`, the pinned `HEAD` blob, and worktree bytes before status; a packet-sized synthetic `git show` also reproduced the old 64 KiB capture failure. The reviewed packet blob now has a separate 8 MiB maximum through the same bounded deadline/process-group cleanup path; all metadata queries retain 64 KiB. | +| 7 | Git config include directives are not rejected | Synthetic `-c`, `--config-env`, and `git config` include/includeIf forms passed read-only classification | Include-path config is rejected before read-only classification; reviewed `git -c core.fsmonitor=false` remains accepted. | + +The historical URL bookkeeping is also restored: the prior ledger omitted the +[packet-completion summary comment](https://github.com/1XP-AI/gh-runnerd/pull/78#issuecomment-5651578138) +and the [initial Codex findings comment](https://github.com/1XP-AI/gh-runnerd/pull/78#issuecomment-5652329850). +Both are provenance links to earlier evidence; their dispositions remain in +the preserved historical ledger above. The source pin for this seven-row +ledger is `b6dbf021801ef5d920d1a9e7659f8bd97be11695`; the local rollback parent +for this candidate is `3d108256883458d25446a6311c8f50176a8ee7cd`. + +The current harness also locks down two issue-candidate regressions found +during review: an annotated `p: Path = Path("synthetic/unreviewed")` reader +must remain rejected, while `ast.walk` and regex `match.group` are not Path +readers; the exact-head blob fixture is sized to at least the packet's UTF-8 +byte length. Its corrected positive path accepts only the packet's fixed +repository path and a literal `HEAD` or full hexadecimal revision. + +This is packet/scanner and focused synthetic-harness evidence only. It does +not claim live GitHub/App/runner/Scale Set verification, workflow dispatch or +replay, credential use, source-code tests, merge, or Codex review. Malicious +review examples were inspected only as data. + +#### Prior issue #79 candidate certification (superseded by PR #103 review) + +Current Markdown fence count: 476 raw marker-like lines, 474 semantic fence +markers in 237 matching pairs. The seven-row finding ledger has 4 columns; +the two previously missing historical comment URLs and local harness link are +present. `python3 -B scripts/evidence_packet/issue79_regression_test.py` passed +8 synthetic tests; the packet-wide static scanner passed 331 shell commands +and 95 Python heredoc bodies with zero violations; `git -P diff --check` +passed. No final-verification template, live GitHub/App/runner/Scale Set, +workflow replay, credential use, Go test, Docker, Keychain or launchd action +was run; rollback is packet/harness-only to +`3d108256883458d25446a6311c8f50176a8ee7cd`. + +#### PR #103 blocking review correction ledger + +This ledger records the nine P1 findings on immutable candidate source +`387a647355e48d44333954fadf48d5b02335290c`. The specimens are synthetic +source strings, shell tokens, mappings, and temporary local Git repositories. +The harness parses scanner inputs as data; it never evaluates a malicious +specimen or invokes its command. Trusted scanner function definitions are +validated before compilation and are exercised by the harness. This is a +trusted-code review boundary, not a Python sandbox. + +| # | Immutable review finding | Disposition and retained positive case | +|---|---|---| +| 1 | [4111249272](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111249272) — unresolved/aliased Path receivers | RED reproduced imported/assigned Path aliases, `resolve().read_text()`, a factory result, a typed `Path` parameter, and an unresolved helper receiver. GREEN resolves reviewed aliases and fails closed on unknown filesystem readers; a fixed reviewed packet path, stable `ast.walk`, and stable regex `match.group` remain accepted. | +| 2 | [4111249273](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111249273) — iterator-wrapped environment taint | RED reproduced `enumerate`, `zip`, and starred loop-target leaks from `os.environ`; GREEN propagates taint through iterator wrappers and starred targets; literal iteration remains accepted. | +| 3 | [4111249274](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111249274) — mapping views and container-call launchers | RED reproduced `.values()`/`.items()` plus list/tuple/set/iterator wrappers and starred target/subscript launchers; GREEN tracks those aliases and rejects unresolved launcher subscripts; ordinary `str.upper` callbacks remain accepted. | +| 4 | [4111249275](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111249275) — scanner loader definition-time execution | RED supplied import, decorator, default, annotation, shadowing, and attribute-assignment specimens as AST data. GREEN validates an explicit import allowlist and definition-time AST before compiling trusted scanner functions, and rejects protected-name rebinding; no specimen code ran. | +| 5 | [4111249279](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111249279) — Git include prefixes and `--includes` | RED reproduced `git config --includes` and `GIT_CONFIG_KEY_n` include/includeIf injection through environment assignment prefixes; GREEN rejects before read-only classification; `git -P status` and reviewed non-include core configuration remain accepted. | +| 6 | [4111249281](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111249281) — Python harness language decision | This documentation finding has no command specimen. The narrow language rationale is recorded in ADR 0004, which is already present in this candidate; its accepted status remains subject to the stated PR merge and review gate. | +| 7 | [4111249283](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111249283) — `env` without a child exposes inherited variables | RED reproduced bare `env`, `env -0`, and no-child assignment forms; GREEN rejects environment-dump forms while preserving reviewed `env -i printf reviewed`. | +| 8 | [4111273707](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111273707) — Git replacement refs alter reviewed `HEAD:path` bytes | RED used a temporary Git repo where ordinary `git show` returned synthetic replacement bytes for the reviewed commit; GREEN adds `--no-replace-objects` to every bounded Git query and confirms the packet blob query returns the reviewed bytes. | +| 9 | [4111273712](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4111273712) — package-root helper accepts caller-controlled roots | RED showed acceptance of a parameterized helper, a changed synthetic invocation root, and a local synthetic root shadow; GREEN requires the canonical zero-argument helper, `Path.cwd()` matched against Git's repository root, fixed reviewed `-C` module, package derived from parsed command arguments, and immutable source-snapshot root. The canonical packet helper remains accepted by a positive-control test. | + +Red/green commands and results for this batch: the first seven-case synthetic +red command was `python3 -B scripts/evidence_packet/issue79_regression_test.py` +with 18 assertion failures and 3 missing-validator errors. The loader/rebinding +red command targeted `test_path_filesystem_readers_require_reviewed_paths` +and `test_packet_loader_rejects_packet_controlled_definition_time_code` and +reported 8 failures; the replacement-ref/root-helper red command targeted +`test_packet_blob_query_ignores_replace_refs` and +`test_path_filesystem_readers_require_reviewed_paths` and reported 2 failures. +The subsequent `test_canonical_package_guard_remains_reviewed` red caught +accepted synthetic invocation-root and local-root-shadow mutations; it passed +after both root anchors were tied to the canonical packet flow. +After their scoped corrections, the same targeted tests passed, and the +canonical package-root and reviewed-path positive controls passed together. +The final complete suite also passed; its exact command and current +packet-wide static counts are recorded below. + +The prior certification's fence and ledger counts describe the candidate +before these PR #103 corrections. This section adds one nine-row, three-column +finding table and no Markdown code fences. No live GitHub/App, runner/Scale +Set, workflow dispatch/replay, credential, Docker, Keychain, launchd, Go test, +or host cleanup operation was performed or inferred. + +Post-correction bookkeeping: 476 raw fence-like marker lines, of which 474 +are semantic Markdown fence markers in 237 matching pairs. This ledger has 9 +rows and 3 columns; the historical seven-row/four-column ledger and earlier +ledgers remain preserved. `python3 -B scripts/evidence_packet/issue79_regression_test.py` +passed all 12 focused synthetic tests; the packet-wide scan covered 331 shell +commands and 95 Python heredoc bodies with zero violations; `git diff --check` +passed. These are local focused/static results only. + +### Follow-up independent scanner findings on candidate `00fc5c4` + +The new regression specimens are synthetic Python source, shell tokens, +environment mappings, or temporary local Git fixtures. Python specimens are +parsed and inspected as data; none is compiled or executed. On the candidate +scanner, the new red probes reproduced environment-taint loss through +generator yields, `itertools.chain`, and starred operands; launcher aliases +lost through `reversed`, dictionary conversion, `sorted`/`filter`/`map` over +mapping values, local generator yields, and local helper returns; +packet-derived function definitions reaching `compile`/`exec` without a +definition-time review; include-option abbreviations and +`GIT_CONFIG_PARAMETERS` escaping Git config checks; local `Path.resolve()` +values reaching output sinks; and nested or unreachable raises being accepted +as containment proof. + +The scanner now propagates taint through generator yields, chain operands, and +starred values; tracks launcher aliases through the reviewed iterator and +mapping conversions, `sorted`/`filter`/`map` wrappers, generator yields, and +helper-returned callables; validates packet-derived +`FunctionDef` definitions before both bounded Git-query and parity-helper +compile/exec sites; rejects include-option prefixes and +`GIT_CONFIG_PARAMETERS`; blocks resolved local paths at output/error sinks; +and requires a direct top-level guard raise plus a direct `ValueError` raise +for package containment. The bounded Git-query loader permits only the +previously validated output-limit constant as a nonliteral default. The path +disclosure probe also checks a direct alias while the canonical package guard +remains accepted. + +The original 22-test red run had 12 failures and 1 error. Follow-up pre-fix +probes confirmed the corrected chain and reversed/dictionary specimens fail +independently; after correction, both nested-root and unreachable-containment +probes were replayed against the stated candidate scanner and failed because +it accepted each unsafe proof. All focused corrections passed afterward. +The preceding 22-test harness revision completed this full local verification: +`python3 -B scripts/evidence_packet/issue79_regression_test.py` ran 22 tests +in 53.538s and passed; the embedded static scan covered 331 shell commands +and 95 Python heredoc bodies with zero violations. `git diff --check` exited +0 with no output. No live runner/workflow operations, network calls, or GitHub +writes were performed; no live tests were run. + +#### Follow-up launcher-iteration regression ledger + +| Regression path | RED against candidate `9548096` | Correction and focused GREEN | +|---|---|---| +| `sorted(launchers.values())` | The scanner accepted a loop that invoked the `subprocess.run` value. | Treat `sorted` as an iterable-preserving wrapper and propagate launcher provenance from its arguments. | +| `filter(None, launchers.values())` | The scanner accepted the filtered launcher loop. | Inspect `filter` arguments for contained command launchers. | +| `map(lambda value: value, launchers.values())` | The scanner accepted the mapped launcher loop. | Inspect `map` arguments for contained command launchers. | +| Local generator yielding `subprocess.run` | The scanner accepted a callable yielded and invoked by the caller. | Include direct `yield` and `yield from` expressions when tracing local helper-produced iterables. | + +Each row has a focused source-data fixture; the four fixtures failed against +the candidate and passed after the scanner change. The existing reversed and +dictionary-conversion positive findings and ordinary `str.upper` callback +control remain covered. + +The `GIT_CONFIG_PARAMETERS` rejection fixtures now pass a Git-formatted, +single-quoted `key=value` token through the shell, for example +`GIT_CONFIG_PARAMETERS="'include.path=synthetic/included.cfg'"`. An isolated +local Git config query accepted the embedded single-quoted token; the +unquoted token and value-only quoting variants were rejected by Git. The +scanner still rejects the valid environment assignment before read-only +classification. + +Updated full local verification: `python3 -B +scripts/evidence_packet/issue79_regression_test.py` ran 26 tests in 53.313s +and passed. The embedded static scan covered 331 shell commands and 95 Python +heredoc bodies with zero violations; `git diff --check` exited 0 with no +output. No live tests, runner/workflow operations, network calls, or GitHub +writes were performed. + +### Issue #79 four-gap scanner correction at baseline `8b5f35b` + +This batch addresses four independent evidence-packet scanner gaps reported +for [issue #79](https://github.com/1XP-AI/gh-runnerd/issues/79). The reviewer +provenance is the supplied independent finding set against immutable starting +packet source `8b5f35b3d6bfea965aad3d515a35b1a3485dae6a`; the prior reviewer did +not rerun this regression suite. The red reproduction below is this worker's +run against that unchanged scanner. Four AST-only tests were added first; their +Python specimens are inert source strings passed to the scanner and were not +evaluated, compiled, or launched. + +| # | Finding at immutable source `8b5f35b3d6bfea965aad3d515a35b1a3485dae6a` | RED against unchanged scanner | Scoped correction and GREEN | +|---|---|---|---| +| 1 | Nested lexical helper definitions sharing a function name could cause a safe shadow helper to hide a `subprocess.run` launcher returned by the outer helper. | `test_nested_function_name_collision_does_not_hide_launcher_alias` failed because the scanner returned no violation for the inert launcher witness. | Launcher-return analysis now examines every same-name local helper definition and fails closed if any returned value is command-capable. The non-launcher helper control remains accepted. | +| 2 | A package-containment `try` nested under `if False` could be accepted as a reachable `package_dir.relative_to(...)` check. | `test_unreachable_package_containment_try_is_not_reviewed` failed because the modified source-fuzz guard was accepted. | Package-root evidence ignores containment `try` nodes under a literal-false branch. The canonical reachable guard remains accepted. | +| 3 | Sensitive values returned by local helpers, including `dict(os.environ)`, were not propagated to output sinks. | Three subcases in `test_sensitive_local_helper_returns_are_tainted_at_output_sinks` failed: direct environment mapping return, `dict(os.environ)`, and a forwarding helper. | Sensitive-value analysis now follows local `Return` values through helper calls. A helper returning a reviewed status mapping remains accepted. | +| 4 | Resolved local paths returned by helpers or stored in module globals could reach output sinks without path disclosure rejection. | Two subcases in `test_resolved_local_paths_from_helpers_and_globals_reach_output_sinks` failed: helper-returned `Path.cwd().resolve()` and a function printing a module-global resolved path. | Path-output analysis follows local helper returns and module-level path bindings referenced by functions. Internal path validation remains accepted. | + +Exact focused red command: `python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_nested_function_name_collision_does_not_hide_launcher_alias Issue79RegressionTests.test_unreachable_package_containment_try_is_not_reviewed Issue79RegressionTests.test_sensitive_local_helper_returns_are_tainted_at_output_sinks Issue79RegressionTests.test_resolved_local_paths_from_helpers_and_globals_reach_output_sinks` ran 4 tests and failed with 7 assertion failures. The scanner in the working tree was still the baseline scanner; only the four test methods had been added. + +Exact focused green command: the same command above ran 4 tests in 15.099s and passed. Its safe controls covered ordinary helper outputs, the canonical reachable package guard, a status mapping, and local path validation without disclosure. An intermediate candidate attempt surfaced 9 implementation errors from changing a helper map's shape at the wrong call site; that mapping was corrected before the green result, and the complete suite below records the final candidate. + +The complete command and result, `git diff --check`, and final two-file scope are recorded in the candidate certification below. Rollback point is immutable starting SHA `8b5f35b3d6bfea965aad3d515a35b1a3485dae6a`; only this packet and its offline regression harness are in scope. The local commit can be reverted, or these two paths restored from that SHA. No GitHub writes, push, review request, project change, credential/host operation, synthetic source execution, live workflow, or runner action was performed. The checks establish static-scanner behavior for these specimens only; they do not prove runtime behavior or close G01's live/product evidence gaps. + +#### Candidate verification + +The final `python3 -B scripts/evidence_packet/issue79_regression_test.py` rerun ran 30 tests in 72.350s and passed; the current packet static scan covered 331 shell commands and 95 Python heredoc bodies with zero violations. `git diff --check` exited 0 with no output. `git diff --name-only` listed only `docs/evidence/g01-recovery-packet.md` and `scripts/evidence_packet/issue79_regression_test.py`; the added-line credential/private-path scan found no matches. Rollback remains the two-file diff from immutable parent `8b5f35b3d6bfea965aad3d515a35b1a3485dae6a`. + +### Issue #79 exact-candidate evidence hardening from `184701d0ff26b4e8d15ce02adbaadfd6913bdb9e` + +The supplied independent review findings apply to immutable input candidate +`184701d0ff26b4e8d15ce02adbaadfd6913bdb9e`. This batch closes two P1 findings +and the resolved-path disclosure cases in scope for issue #79. The review +dispatch did not include a review URL, so this record does not invent one. +The Python specimens below are inert source strings parsed by the offline +harness; none was compiled or executed. + +| Finding | RED against exact input candidate | Correction and retained safe case | +|---|---|---| +| P1: package containment could be counted when its `try` existed only in the unreachable `else` of `if True`, or in the unreachable body of `if 0`. | `test_unreachable_package_containment_try_is_not_reviewed` accepted both modified guards. | Reachability checks account for literal truth values in both `if` arms; the canonical reachable package guard remains accepted. | +| P1: an unrelated nested helper with the same name could shadow the top-level helper binding used for environment-map taint propagation. | `test_sensitive_mapping_return_survives_unrelated_nested_name_collision` accepted a mapping from `dict(os.environ)` returned through a top-level relay. | Helper candidates are constrained to lexical scopes visible at the call, preserving the top-level relay parameter taint. A top-level reviewed mapping remains accepted when an unrelated nested same-name helper returns `os.environ`. | +| P2: resolved local paths could reach output sinks through local helper aliases, arguments bound to helper parameters, generator yields, nested closure captures, or default arguments. | `test_resolved_local_paths_cross_helper_boundaries_to_output_sinks` accepted all five inert disclosure specimens. | Path provenance follows visible helper aliases and return/yield values, call arguments and defaults bound to helper parameters, and assignments captured from enclosing scopes. Internal root validation with no output sink remains accepted. | +| P3: an unrelated nested same-name helper returning `os.environ` caused a safe top-level status mapping to be classified as sensitive. | `test_safe_top_level_helper_ignores_unrelated_nested_name_collision` falsely rejected the reviewed status mapping. | The same lexical helper resolution removes this false positive while the P1 environment-map relay remains rejected. | + +Test-first RED commands and recorded results: + +```text +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_sensitive_mapping_return_survives_unrelated_nested_name_collision Issue79RegressionTests.test_resolved_local_paths_cross_helper_boundaries_to_output_sinks Issue79RegressionTests.test_unreachable_package_containment_try_is_not_reviewed +Ran 3 tests in 31.961s; failed with 8 assertion failures (1 environment-taint case, 5 path-disclosure cases, and 2 unreachable package-guard cases). +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_safe_top_level_helper_ignores_unrelated_nested_name_collision +Ran 1 test in 0.074s; failed because the scanner reported a credential/environment output-sink violation for the safe top-level status mapping. +``` + +Focused GREEN commands and recorded results: + +```text +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_sensitive_mapping_return_survives_unrelated_nested_name_collision Issue79RegressionTests.test_safe_top_level_helper_ignores_unrelated_nested_name_collision Issue79RegressionTests.test_resolved_local_paths_cross_helper_boundaries_to_output_sinks Issue79RegressionTests.test_resolved_local_paths_from_helpers_and_globals_reach_output_sinks Issue79RegressionTests.test_resolved_local_paths_are_not_disclosed_to_output_sinks +Ran 5 tests in 0.084s; passed. +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_unreachable_package_containment_try_is_not_reviewed Issue79RegressionTests.test_canonical_package_guard_remains_reviewed +Ran 2 tests in 28.124s; passed. +``` + +GitHub exact-head Codex review and the hosted PR quick check remain pending; +neither is claimed complete by this local evidence. The full offline regression +harness, final packet scan, diff check, and added-line credential/private-path +scan are recorded in the candidate certification below. The local commit SHA is +reported in the worker completion record. + +#### Current-candidate offline certification + +After the scanner corrections and this review disposition were added, +the final focused command ran 8 tests in 27.672s and passed. A subprocess wrapper +captured stdout and stderr separately for `python3 -B +scripts/evidence_packet/issue79_regression_test.py`; the command ran 33 tests +in 53.046s and passed with exit code 0. Its current-packet scan covered 331 +shell commands and 95 Python heredoc bodies with zero violations. These checks +are offline static-scanner evidence; they do not close G01's remaining evidence +gates or replace the pending exact-head Codex review and hosted PR quick check. +Rollback point is the immutable starting SHA +`184701d0ff26b4e8d15ce02adbaadfd6913bdb9e`. +`git -P diff --check` exited 0; `git diff --name-only` listed only +`docs/evidence/g01-recovery-packet.md` and +`scripts/evidence_packet/issue79_regression_test.py`; the added-line +credential/private-path scan found zero matches. + +### Issue #79 independent-review correction from `9e921283cf338c5b6d1b1c358d3735ae42f26cdd` + +The independent offline review of that candidate reported three P1 and two P2 +scanner gaps. Its report was delivered through the local review task, without a +GitHub review URL. These five findings were reproduced with inert Python source +strings; the source strings were parsed and inspected, never executed. The +reviewer's nine focused tests passed for the earlier corrections, and its five +new probes demonstrated distinct uncovered cases. + +| Finding | RED against `9e921283` | Correction and safe control | +|---|---|---| +| P1 containment proof was unreachable or swallowed | An `else` after the required module-directory guard, `if not False`, and a broad first exception handler each left an ineffective `relative_to` check accepted. | Recognize the proved module-directory branch and constant negation; require the first exception handler to fail closed for `ValueError`. The canonical reachable guard remains accepted. | +| P1 environment map through variadic/default parameters | `*args`, `**kwargs`, and a default parameter each passed `dict(os.environ)` to `print`. | Propagate credential taint through expanded arguments and defaults. A helper printing ordinary values remains accepted. | +| P1 environment map through `sys.exit` | Direct and helper-returned environment maps reached the exit message. | Classify `sys.exit` as an output/error sink; an ordinary status message remains accepted. | +| P2 path through expanded helper arguments | Positional variadic, keyword variadic, and literal `**mapping` arguments sent a resolved path to `print`. | Bind expanded arguments to helper parameters for path provenance. Internal path validation remains accepted. | +| P2 path in raised exception | `RuntimeError` and `SystemExit` carried a resolved path in their message. | Inspect raised exception arguments for resolved paths; ordinary status errors remain accepted. | + +Test-first RED command: + +```text +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_resolved_local_paths_cross_expanded_helper_arguments Issue79RegressionTests.test_resolved_local_paths_in_raised_errors_are_rejected Issue79RegressionTests.test_sensitive_variadic_and_default_helper_parameters_are_tainted Issue79RegressionTests.test_sys_exit_is_an_output_sink_for_sensitive_values Issue79RegressionTests.test_unreachable_package_containment_try_is_not_reviewed +Ran 5 tests in 60.417s; failed with 14 assertion failures across the five findings. +``` + +Focused GREEN command: + +```text +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_resolved_local_paths_cross_expanded_helper_arguments Issue79RegressionTests.test_resolved_local_paths_in_raised_errors_are_rejected Issue79RegressionTests.test_sensitive_variadic_and_default_helper_parameters_are_tainted Issue79RegressionTests.test_sys_exit_is_an_output_sink_for_sensitive_values Issue79RegressionTests.test_unreachable_package_containment_try_is_not_reviewed Issue79RegressionTests.test_canonical_package_guard_remains_reviewed Issue79RegressionTests.test_safe_top_level_helper_ignores_unrelated_nested_name_collision +Ran 7 tests in 78.528s; passed. +``` + +Rollback point is immutable `9e921283cf338c5b6d1b1c358d3735ae42f26cdd`; +the correction touches only this packet and its offline issue #79 test harness. +The final `python3 -B scripts/evidence_packet/issue79_regression_test.py` +run passed all 37 tests in 129.787s, including a static scan of 331 shell +commands and 95 Python heredoc bodies with zero violations. `git diff --check` +exited 0 with no output. An added-line scan for private home paths and common +credential/key prefixes returned zero matches. The independent delta review, +hosted PR quick check, and GitHub Codex review remain pending for the final +pushed SHA; this local verification is not their substitute. + +### Issue #79 PR #103 exact-head correction from `dac58b4adde5b3f552254700e85a229cd6d0ad1b` + +The correction reproduces the supplied exact-head Codex findings and the +independent offline review cases against immutable starting source +`dac58b4adde5b3f552254700e85a229cd6d0ad1b`. Every Python specimen is an inert +source string parsed by the scanner; no specimen was compiled, evaluated, or +launched. The offline harness inspects only its reviewed scanner functions and +temporary local Git fixtures. + +| # | Finding | RED against `dac58b4` | Minimal correction and retained safe case | +|---|---|---|---| +| 1 | P1 [Codex comment 4118425759](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118425759): sensitive environment values returned from local methods or lambdas could reach output sinks. | `test_sensitive_method_and_lambda_returns_are_tainted` accepted direct lambda, direct method, and assigned-instance method returns containing `dict(os.environ)`. | Resolve visible local function, method, and lambda return expressions for sensitive-value checks. Ordinary status mappings returned from methods remain accepted. | +| 2 | P1 [Codex comment 4118425766](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118425766): sensitive values could be laundered through wrapped comprehension iterators. | `test_environment_taint_reaches_comprehension_iterator_outputs` accepted `iter(dict(os.environ).items())` and `enumerate(dict(os.environ).items())` comprehension sources. | Recursively inspect each comprehension iterator for environment taint; literal comprehension sources remain accepted. | +| 3 | P1 [Codex comment 4118425771](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118425771): command launchers returned through methods or lambdas could be assigned and called as aliases. | `test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected` accepted factory lambdas, immediately called lambdas, and a local method returning `subprocess.run`. | Reuse scoped local-return analysis in launcher alias tracking. A local method returning `str.upper` remains accepted. | +| 4 | P1 [Codex comment 4118425775](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118425775): package containment could be credited to a `try` nested below an unknown condition. | `test_package_containment_try_requires_direct_reachable_body` credited both the conditional `try` body and conditional `else` as the guard. | Count containment only when the fail-closed `try` is a direct statement in the canonical guard body. The unchanged direct guard remains accepted. | +| 5 | P1 [Codex comment 4118425779](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118425779): duplicate parity-helper definitions could cause the test loader to silently select one definition. | `test_verification_parity_helper_definition_must_be_unique` showed the loader accepted duplicate `require_packet_head_parity` definitions. | Require a single top-level parity helper and reject nested or duplicate definitions before the parity checks run. The packet's single reviewed helper remains accepted by the parity fixture. | +| 6 | P1: independent exact-head review (finding summary supplied with this correction; no public URL was supplied): `dict(snapshot=os.environ)` passed through an expanded `**kwargs` helper could reach a sink without taint. | `test_sensitive_mapping_expanded_into_kwargs_is_tainted` accepted both direct and nested environment mappings through `**dict(...)`. | Inspect `dict` keyword values and bind literal `dict(...)` expansions to helper parameters, including `**kwargs`. An ordinary `status="reviewed"` mapping remains accepted. | +| 7 | P1: independent exact-head review (finding summary supplied with this correction; no public URL was supplied): `from sys import exit as leave` and `raise SystemExit(...)` bypassed sensitive output/error checks. | `test_imported_exit_alias_and_system_exit_preserve_sensitive_taint` accepted the imported exit alias with `str(dict(os.environ))` and the same value in a `SystemExit` message. | Recognize stable imported `sys.exit` aliases as output sinks and propagate sensitive taint through `SystemExit`; ordinary status messages remain accepted. | +| 8 | P2: independent exact-head review (finding summary supplied with this correction; no public URL was supplied): `**dict(root=Path.cwd().resolve())` could pass a resolved path to a helper sink. | `test_resolved_paths_cross_expanded_keyword_helpers_and_formatting` accepted the expanded path argument. | Bind literal keyword-map entries to local helper parameters for path provenance. A helper that only validates the root remains accepted. | +| 9 | P2: independent exact-head review (finding summary supplied with this correction; no public URL was supplied): a resolved path in `RuntimeError("root={}".format(...))` was missed. | The same `test_resolved_paths_cross_expanded_keyword_helpers_and_formatting` accepted a formatted path in a raised exception. | Follow path provenance through `str.format` arguments. Ordinary status exceptions remain accepted. | + +Test-first RED command: `python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_sensitive_method_and_lambda_returns_are_tainted Issue79RegressionTests.test_environment_taint_reaches_comprehension_iterator_outputs Issue79RegressionTests.test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected Issue79RegressionTests.test_package_containment_try_requires_direct_reachable_body Issue79RegressionTests.test_verification_parity_helper_definition_must_be_unique Issue79RegressionTests.test_sensitive_mapping_expanded_into_kwargs_is_tainted Issue79RegressionTests.test_imported_exit_alias_and_system_exit_preserve_sensitive_taint Issue79RegressionTests.test_resolved_paths_cross_expanded_keyword_helpers_and_formatting` ran 8 tests and failed with 16 assertion failures. The negative specimens failed closed only after the corrections; the safe controls were included in each test method. + +Final focused GREEN command: the same selected command ran 8 tests in 10.734s and passed after the refactor. A second focused pass combined the new probes with existing helper-return, comprehension, launcher, path, package-containment, and parity-fixture regressions; it ran 24 tests in 93.566s and passed. The corrections share one cached scoped return-expression index for local functions, methods, and lambdas; environment taint remains limited to known sensitive values and iterator provenance. + +Final offline harness, packet scan, `git diff --check`, added-line credential/private-path scan, and two-file scope are recorded below after the stable candidate run. No GitHub writes or browser access, push, workflow dispatch, runner operation, credential use, or specimen execution was performed. These offline scanner checks do not close G01's live/product evidence gaps, replace exact-head GitHub Codex review, or replace the hosted PR quick check. Rollback point is the immutable starting SHA `dac58b4adde5b3f552254700e85a229cd6d0ad1b`. + +#### Intermediate offline certification before independent-review corrections + +After tightening the conditional-containment specimens to literal false/true +branches, `python3 -B scripts/evidence_packet/issue79_regression_test.py` ran +45 tests in 198.583s and passed. The packet-wide static scan covered 331 shell commands +and 95 Python heredoc bodies with zero violations. `git diff --check` exited 0 +with no output. `git diff --name-only` listed only +`docs/evidence/g01-recovery-packet.md` and +`scripts/evidence_packet/issue79_regression_test.py`. The added-line scan +covered 465 lines and found zero credential-pattern matches and zero +personal/private-path matches. HEAD remains +`dac58b4adde5b3f552254700e85a229cd6d0ad1b`; both files remain uncommitted. +The local `gh` PR read could not connect to GitHub, so no review state or hosted +quick-check status is inferred from this offline certification. + +#### Independent read-only delta review corrections + +An independent `gpt-6-luna`/`max` read-only review of the local candidate +identified additional executable bypasses in the new method/alias tracing. +Against the then-current source, focused inert AST specimens reproduced missed +environment-return methods behind instance aliases or constructors with arguments, +bound-method aliases, assigned/list-indexed/`getattr`-derived `sys.exit` or +`SystemExit` aliases, and launcher factories behind bound-method or `getattr` +aliases (including the optional third `getattr` argument). Focused RED runs +used the three corresponding `Issue79RegressionTests` methods and failed with +3, 2, 4, 2, and 2 assertion failures respectively as specimens were added. +Each correction was followed by GREEN of its focused methods; the latest +two-method focused run passed. The scanner now follows scoped receiver aliases, +argument-bearing constructors, bound-method returns, literal container +selection, and static `getattr` attributes, retaining the safe status controls. +No specimen was executed. This is independent local review evidence, not the +GitHub exact-head Codex review or hosted PR quick check. + +### Four P1 findings from PR #103 Codex review `5334233252` + +The supplied review is [Codex review 5334233252](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5334233252) +against exact base `b79709bf4c8d9d2762c9fbaa8128ba6e6a06f969`. The four Python +specimens are inert AST input strings. The index-bit and byte-parity checks use +only temporary local Git repositories. This record does not claim a review of +the final worktree head. + +| # | Finding | RED against exact base | Correction and safe control | +|---|---|---|---| +| 1 | A local factory could return `Path("synthetic-private/file").read_text`, then its result could be called as an unchecked reader. | `test_path_filesystem_readers_require_reviewed_paths` accepted the factory-returned reader. | Path-reader alias analysis now inspects local helper return expressions. A factory returning the reviewed packet reader remains accepted. | +| 2 | `launchers = {"x": subprocess.run}; launch = launchers.pop("x")` left a callable launcher alias unresolved. | `test_launcher_alias_returned_by_mapping_pop_is_rejected` accepted the launcher invocation. | Launcher provenance now follows mapping `pop` values. A `str.upper` callback popped from a local map remains accepted. | +| 3 | Credential taint did not bind arguments to local method or assigned-lambda parameters, so `C().emit(os.environ)` and its lambda equivalent reached `print(payload)`. | `test_sensitive_values_are_tainted_into_method_and_lambda_parameters` accepted both inert specimens. | Taint binding now covers known local method receivers and assigned local lambdas. Safe status values passed through each callable remain accepted. | +| 4 | Final parity checked intent bits and raw HEAD bytes only for the packet, leaving `scripts/evidence_packet/issue79_regression_test.py` maskable by Git intent bits. | The parity regression failed because the template had no reviewed-source path list or parity check for the harness. A temporary repository reproduced clean porcelain status while either protected path had `skip-worktree` or `assume-unchanged` set and modified bytes. | The template checks both #79 evidence paths before status, and the bounded blob reader permits exactly those paths under the existing packet-blob output cap. The temporary Git fixture verifies clean parity, both hidden intent bits on each path, and unmasked byte divergence. | + +Exact pre-fix RED command: + +```text +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_path_filesystem_readers_require_reviewed_paths Issue79RegressionTests.test_launcher_alias_returned_by_mapping_pop_is_rejected Issue79RegressionTests.test_sensitive_values_are_tainted_into_method_and_lambda_parameters Issue79RegressionTests.test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence +Ran 4 tests in 0.096s; failed with 5 assertion failures (the helper-returned reader, mapping-pop launcher, method taint, lambda taint, and missing final-template evidence-path coverage). +``` + +After the initial correction, the first full run executed 47 tests in +365.465s and found one static-scan failure: `Path(reviewed_path).read_bytes()` +did not prove a reviewed literal path. The template now reads each allowlisted +path through its own literal `Path(...)` expression. The focused GREEN command +at that stage was: + +```text +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_path_filesystem_readers_require_reviewed_paths Issue79RegressionTests.test_launcher_alias_returned_by_mapping_pop_is_rejected Issue79RegressionTests.test_sensitive_values_are_tainted_into_method_and_lambda_parameters Issue79RegressionTests.test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence Issue79RegressionTests.test_sensitive_method_and_lambda_returns_are_tainted Issue79RegressionTests.test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected Issue79RegressionTests.test_large_packet_blob_uses_a_separate_bounded_capture Issue79RegressionTests.test_packet_blob_query_ignores_replace_refs +Ran 8 tests in 1.080s; passed. +``` + +The next complete run (47 tests in 221.119s) found one remaining static-scan +failure because the scanner's reviewed-read allowlist did not include the +exact harness path. The scanner now accepts that one exact path; it does not +broaden the `scripts/` prefix. Final focused GREEN command: + +```text +python3 -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_path_filesystem_readers_require_reviewed_paths Issue79RegressionTests.test_launcher_alias_returned_by_mapping_pop_is_rejected Issue79RegressionTests.test_sensitive_values_are_tainted_into_method_and_lambda_parameters Issue79RegressionTests.test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence Issue79RegressionTests.test_sensitive_method_and_lambda_returns_are_tainted Issue79RegressionTests.test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected Issue79RegressionTests.test_large_packet_blob_uses_a_separate_bounded_capture Issue79RegressionTests.test_packet_blob_query_ignores_replace_refs +Ran 8 tests in 1.177s; passed. +``` + +#### Final verification and scope + +The final full offline harness, `python3 -B +scripts/evidence_packet/issue79_regression_test.py`, ran 47 tests in 235.064s +and passed. Its packet static scan covered 331 shell commands and 95 Python +heredoc bodies with zero violations. `git diff --check` exited 0. The final +added-line scan covered 507 lines with zero credential-pattern matches and +zero personal-path matches; the only changed paths are this packet and +`scripts/evidence_packet/issue79_regression_test.py`. HEAD remains exactly +`b79709bf4c8d9d2762c9fbaa8128ba6e6a06f969`, with both files uncommitted. No +GitHub access or writes, browser use, commit, push, merge, workflow dispatch, +credential operation, or live runner access was performed. This local evidence +does not claim final-head Codex review or hosted PR quick-check completion. + +Independent integration check before the correction push found one further +fail-open path-reader case: a local factory returned a reviewed reader at one +return site and an unreviewed reader at another. When AST traversal encountered +the reviewed return first, the previous alias pass selected it and accepted a +call that could read `synthetic-private/file`. The added inert specimen in +`test_path_filesystem_readers_require_reviewed_paths` failed RED with one +assertion failure, then passed GREEN after alias selection conservatively +retained any unreviewed return or assignment. The same test retains the +reviewed-reader positive control. No specimen was executed; this result still +requires final-head independent and GitHub Codex review. + +### Issue #79 PR #103 P1 corrections from Codex review `5334590641` + +The supplied exact-head review was reported against source +`9233241cbd55f35746dffac8f98ff06509cd3c38`. The four public P1 findings below +were triaged as blocking and reproduced with inert Python AST specimens. None +was evaluated, compiled, or launched. Safe controls use literal status values, +non-launcher callbacks, and the isolated interpreter probe described below. + +| # | Finding and review URL | RED against the starting worktree | Correction and retained safe case | +|---|---|---|---| +| 1 | [Codex P1 4119067262](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119067262): a constructor alias (`Alias = Snapshot`) hid a method returning `dict(os.environ)` from output taint. | `test_sensitive_method_and_lambda_returns_are_tainted` accepted `print(Alias().read())`. | Local method-return analysis now follows scoped class-name aliases. An aliased status class returning `{"status": "reviewed"}` remains accepted. | +| 2 | [Codex P1 4119067272](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119067272): `launchers.get("x")` lost the `subprocess.run` mapping provenance when assigned to `launch`. | `test_launcher_alias_returned_by_mapping_get_is_rejected` accepted the indirect workflow launcher call. | Mapping `.get` results now inherit launcher presence; a candidate alias is rejected unresolved. A `str.upper` callback selected with `.get` remains accepted. | +| 3 | [Codex P1 4119067280](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119067280): resolved local paths escaped output checks through `format`, `ascii`, and explicit `__str__`. | `test_resolved_local_paths_are_not_disclosed_to_output_sinks` accepted all three converted path values. | Path provenance now follows those string conversions to output sinks. The same converters over a reviewed literal remain accepted. | +| 4 | [Codex P1 4119067288](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119067288): the prescribed `python3 -B` command remained importable through `PYTHONPATH` or a local shadow module. | `test_isolated_invocation_ignores_synthetic_local_module` found the ADR still prescribed `python3 -B`; its synthetic local `json.py` isolation control passed with `-I`. | The ADR now prescribes `python3 -I -B`. The harness verifies an inert local module cannot shadow the standard library with `PYTHONPATH` and a temporary cwd set. Earlier `python3 -B` results in this packet remain historical records. | + +Three independent local P1 corrections already present in the pre-writer +worktree were preserved. No public review URLs were supplied for these findings. +The focused GREEN rerun below covers each corrected path and its safe control. + +| Local finding (no public URL supplied) | Preserved correction and positive control | +|---|---| +| A helper returning `getattr(Path("synthetic-private/file"), member)` could hand an unchecked filesystem reader to its caller. | Reader aliases now follow static `getattr` and helper returns; the reviewed packet reader remains accepted. | +| `callback = getattr(sink, member); callback(os.environ)` could pass an environment mapping into a local output method. | Method aliases from static `getattr` are resolved for taint binding; a literal status mapping remains accepted. | +| `sink = build(); sink.emit(os.environ)` could hide the output receiver behind a helper-created instance. | Scoped receiver aliases now follow helper returns; a helper-created sink receiving a literal status mapping remains accepted. | + +The new test-first RED command was: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_sensitive_method_and_lambda_returns_are_tainted Issue79RegressionTests.test_launcher_alias_returned_by_mapping_get_is_rejected Issue79RegressionTests.test_resolved_local_paths_are_not_disclosed_to_output_sinks Issue79RegressionTests.test_isolated_invocation_ignores_synthetic_local_module +Ran 4 tests in 0.130s; failed with 6 assertion failures: constructor alias taint, mapping-get launcher provenance, three path conversions, and the obsolete ADR invocation. The synthetic import-isolation control and safe controls passed. +``` + +After the scoped corrections and ADR update, focused GREEN was: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_path_filesystem_readers_require_reviewed_paths Issue79RegressionTests.test_sensitive_values_are_tainted_into_method_and_lambda_parameters Issue79RegressionTests.test_sensitive_method_and_lambda_returns_are_tainted Issue79RegressionTests.test_launcher_alias_returned_by_mapping_pop_is_rejected Issue79RegressionTests.test_launcher_alias_returned_by_mapping_get_is_rejected Issue79RegressionTests.test_resolved_local_paths_are_not_disclosed_to_output_sinks Issue79RegressionTests.test_isolated_invocation_ignores_synthetic_local_module +Ran 7 tests in 0.152s; passed. +``` + +The three pre-existing local corrections were also run before this batch: the +path-reader, method/lambda taint-binding, and method/lambda-return tests passed +3 tests in 0.109s. All specimens remained inert scanner input. The local module +was a temporary harmless `json.py`; no repository module, credential, network, +runner, or workflow was accessed. + +Rollback is a reviewed reversal of this batch's packet, harness, and ADR delta +to the pre-writer worktree snapshot at `9233241cbd55f35746dffac8f98ff06509cd3c38`, +retaining the three pre-existing local corrections. No commit or push was made. +Final local verification used `python3 -I -B +scripts/evidence_packet/issue79_regression_test.py`; it ran 49 tests and passed. +The packet-wide static scan covered 331 shell commands and 95 Python heredoc +bodies with zero violations. `git -P diff --check` exited 0, and the added-line +credential/private-path scan returned zero matches. The only changed paths are +this packet, the offline issue #79 harness, and ADR 0004. No commit, push, +merge, workflow dispatch, runner access, credential operation, live test, +browser use, or GitHub write was performed. This local evidence does not claim +final-head GitHub Codex review or hosted PR quick-check completion. + +### Issue #79 PR #103 follow-up on starting head a61c35fb8ad7a1425e363a8fcfc6f5748eaa5029 + +This bounded offline correction preserves the packet's AST scanner contract. +It does not emulate Python execution: specimens are parsed inert strings, and +the fixes follow only literal mapping aliases, local helper-returned bound +methods, bounded literal class aliases, resolved-path conversions, and +statically resolvable getattr names. The independent local review source was +gpt-6-luna / max, read-only inspection of the workspace diff, with no public +review URL supplied. + +| # | Severity and scanner contract | RED witness against the starting worktree | Fail-closed correction and safe control | +|---|---|---|---| +| 1 | P1: command-launcher provenance must survive a mapping lookup alias chain so a workflow launcher cannot be hidden from command policy. | launchers.get assigned to lookup, copied to lookup2, then called to produce subprocess.run; the final gh workflow run was accepted. | Calls through aliases of lookup methods on launcher-bearing maps are rejected as unresolved. A similarly aliased str.upper callback remains accepted. | +| 2 | P1: environment and credential values must not reach output sinks through a local callback. | make_callback returned C.emit; calling the resulting alias with os.environ reached print(value) without taint. | Local helper-returned bound methods are resolved for parameter taint. Passing a literal status mapping through the same callback remains accepted. | +| 3 | P1: resolved local paths must not be disclosed to output sinks after path/string conversion. | convert = ascii, Path.cwd().resolve().__fspath__(), and Path.cwd().resolve().as_posix().encode().decode() each returned no violation. | Path provenance now follows the tested ascii alias, __fspath__, as_posix, encode, and decode calls. The same conversions on a reviewed repository path remain accepted. | +| 4 | P1: a method returning environment data must remain tainted through a class alias selected by literal container indexing or a conditional. | Alias = (Snapshot,)[0] and Alias = Snapshot if flag else Snapshot both hid dict(os.environ) from the output check. | Class alias tracing follows bounded tuple/list indexing and both IfExp branches. The regression also selects between sensitive Snapshot and safe Status classes; safe status aliases remain accepted. | +| 5 | P1: filesystem reads from paths outside reviewed roots must remain rejected through getattr aliases. | read_text assembled from literal strings hid Path("synthetic-private/file").read_text; an unresolved member and an alias of getattr also returned no violation. | Literal concatenation resolves to the reviewed reader policy. getattr on a Path-like receiver with no bounded static member name fails closed, including a statically assigned getattr alias. The reviewed packet reader selected with literal concatenation remains accepted. | + +The first focused RED command added the five regression methods before scanner +edits: ~~~text -GREEN final packet certification: 474 Markdown fence markers in 237 matching pairs; 1,040 total Markdown links across 56 tracked Markdown files (157 local targets, 49 fragments, 883 external syntax URLs); backlog JSON valid; current six-row ledger valid with 6 rows x 4 columns and historical URL/source/disposition ledger valid with 31 rows; exact-parent RED plus current GREEN/CURRENT and failure-boundary probes passed with 2 scoped fresh shell probes and 0 script errors; wrapper/scanner AST and compile valid, 95 Python heredoc bodies AST/compile valid, full static scanner passed with 331 shell commands and zero violations; one-file scope, added-line secret/private-path hygiene, and git -P diff --check passed; no live verification, workflow replay, credential use, source-code test, merge or Codex review claimed; rollback parent f44a4871f87c1a8165593549d58ece6ffee61bf2 +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_mapping_lookup_method_alias_chain_preserves_launcher_provenance Issue79RegressionTests.test_factory_returned_bound_method_receives_sensitive_argument Issue79RegressionTests.test_resolved_paths_keep_taint_through_protocol_and_byte_conversions Issue79RegressionTests.test_container_and_conditional_class_aliases_preserve_return_taint Issue79RegressionTests.test_concatenated_getattr_path_reader_is_rejected +Ran 5 tests in 0.097s; failed with 8 assertion failures across the unsafe specimens. The safe controls passed. ~~~ + +After the independent reviewer flagged unresolved and aliased getattr member +forms, each added inert specimen produced a separate RED with one assertion +failure because the scanner returned None. The conditional-class control was +strengthened to select between the sensitive Snapshot and safe Status classes. +The final focused GREEN command was: + +~~~text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_mapping_lookup_method_alias_chain_preserves_launcher_provenance Issue79RegressionTests.test_factory_returned_bound_method_receives_sensitive_argument Issue79RegressionTests.test_resolved_paths_keep_taint_through_protocol_and_byte_conversions Issue79RegressionTests.test_container_and_conditional_class_aliases_preserve_return_taint Issue79RegressionTests.test_concatenated_getattr_path_reader_is_rejected +Ran 5 tests in 0.098s; passed, including safe callback, status, reviewed-path, and internal-validation controls. +~~~ + +An intermediate complete run before the unresolved-member and getattr-alias +follow-ups ran 54 tests and passed. Its packet scan covered 331 shell commands +and 95 Python heredoc bodies with zero violations. Final verification of the +amended ledger and scanner is recorded below. + +The read-only independent review classified the five supplied shapes as P1 +under existing command, sensitive-output, and unreviewed-file rules. It +identified the unresolved and aliased getattr forms; both were added with +bounded fail-closed checks and inert regression coverage. This is independent +local review evidence, not GitHub Codex review of a final pushed head. + +Rollback target is the exact starting worktree snapshot at +a61c35fb8ad7a1425e363a8fcfc6f5748eaa5029. Reversing this uncommitted batch +restores only the two owned paths. No ADR change was needed. + +#### Final local verification + +On this amended worktree, python3 -I -B +scripts/evidence_packet/issue79_regression_test.py ran 54 tests in 233.883 +seconds and passed. Its packet-wide static scan covered 331 shell commands +and 95 Python heredoc bodies with zero violations. The focused five-method +rerun also passed in 0.098 seconds after the final getattr-alias correction. +git -P diff --check exited 0. The added-line pattern scan found no +credential-shaped values or personal home paths, and only the two owned paths +are modified. + +The independent gpt-6-luna / max read-only local review confirmed the final +getattr-alias correction is bounded, checked the final evidence entry, and +found the supplied scanner bypasses closed by static inspection. It ran no +tests or specimens and supplied no public review URL. No final-head GitHub +Codex review or hosted PR quick check is claimed. No commit, push, merge, +browser, workflow, live specimen, runner, or credential operation occurred. +A separate static-only rerun after appending the follow-up evidence entry ran +1 test in 121.094 seconds and passed with 331 shell commands and 95 Python +heredoc bodies scanned, zero violations. + +### Issue #79 PR #103 correction from exact review `5334901335` + +The correction starts from HEAD `a61c35fb8ad7a1425e363a8fcfc6f5748eaa5029` +with the pre-existing uncommitted five-P1 batch preserved in these same two +owned files. The supplied exact-head Codex review reported two P1 inline +findings and no new issue-comment findings. Both P1s were triaged as blocking; +their public inline threads remain open pending review of a future pushed +candidate. No GitHub read or write was performed during this local correction. +Every Python witness below is an inert string parsed and inspected by the AST +scanner, never compiled, evaluated, or launched. + +| # | Finding and immutable review URL | RED against the starting worktree | Correction and retained safe control | +|---|---|---|---| +| 1 | P1 [Codex comment 4119319614](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119319614): the Markdown local-link exemption accepted `path.relative_to(repository_root)` hidden under `if False` inside `try`, allowing later file and anchor checks to inspect an out-of-root target. | `test_markdown_link_containment_guard_must_be_direct_and_reachable` failed because the scanner approved a `relative_to` call nested under `if False`; the same test also checks a try hidden beneath an unreachable branch. | Count only a direct `path.relative_to(repository_root)` expression in a try that shares the path-assignment suite, skips `ValueError` directly, and is not under a literal-unreachable branch. The canonical Markdown checker guard remains reviewed. | +| 2 | P1 [Codex comment 4119319626](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119319626): the regex `.group()` exemption resolved receiver names globally and treated a shadowing function parameter as a regex match. | `test_regex_group_exemption_respects_shadowing_parameters` failed because a function parameter named `match` inherited a module-level `re.match` exemption. | Regex receiver tracing refuses the exemption when the receiver name is a function or lambda parameter. A canonical imported `re.match(...).group(0)` receiver remains accepted. | +| 3 | Local P1 (no public URL supplied): `print(build().read())` lost sensitive-value taint when `build` returned a `Snapshot` instance from a local assignment and `Snapshot.read` returned `dict(os.environ)`. | `test_sensitive_return_through_factory_created_instance_is_tainted` failed because the scanner returned no violation. | Method-return tracing resolves assignments in the lexical scope that produced the returned receiver, then follows its class method return. A factory-created status object remains accepted. | +| 4 | Local P1 (no public URL supplied): `callback = getattr(sink, "missing", sink.emit); callback(os.environ)` lost method parameter taint through the `getattr` default bound method. | `test_getattr_default_bound_method_taints_sensitive_arguments` failed because the scanner returned no violation. | Bound-method tracing includes an attribute supplied as the `getattr` default. The same callback invoked with a literal status mapping remains accepted. | +| 5 | P2 harness documentation: its module docstring said Git was the only child-process kind, while the existing import-isolation test also launches an isolated Python probe. | Direct source inspection of the docstring and `test_isolated_invocation_ignores_synthetic_local_module` confirmed the stale statement; no behavior test was needed for this documentation-only correction. | The docstring now records both temporary local Git commands and the isolated `sys.executable -I -B -c` standard-library shadowing probe. | + +The focused RED command added the four AST tests before scanner edits: +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_markdown_link_containment_guard_must_be_direct_and_reachable Issue79RegressionTests.test_regex_group_exemption_respects_shadowing_parameters Issue79RegressionTests.test_sensitive_return_through_factory_created_instance_is_tainted Issue79RegressionTests.test_getattr_default_bound_method_taints_sensitive_arguments` ran 4 tests in 0.092s and failed with 4 assertions. The four unsafe witnesses were accepted; safe controls are in the same methods. The Markdown method stops at its first failing unsafe assertion in RED; after correction its rerun exercised both unreachable forms and the canonical positive. + +The same focused command after the minimal scanner changes ran 4 tests in +0.092s and passed. A broader focused rerun including the existing filesystem +reader, sensitive method/lambda, tainted parameter, and package-guard cases +ran 8 tests in 12.623s and passed. No unsafe source snippet was executed. + +The rollback target is the pre-correction worktree snapshot: HEAD +`a61c35fb8ad7a1425e363a8fcfc6f5748eaa5029` plus the preserved uncommitted +five-P1 batch. Reversal removes only this follow-up's four tests, scanner +changes, and docstring edit while retaining that batch; restoring the clean +HEAD files would incorrectly discard the pre-existing work. No ADR change was +needed. Final full-suite and hygiene results follow. + +#### Final local verification + +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` ran 58 +tests in 236.487s and passed. Its packet-wide static scan covered 331 shell +commands and 95 Python heredoc bodies with zero violations. `git diff --check` +exited 0. The added-line credential and personal-path scan found no matches, +and `git status --short` lists only the packet and offline regression harness. + +No unsafe Python or shell specimen was evaluated, compiled, or launched. The +pre-existing isolated `-I -B -c` standard-library probe and temporary local +Git fixtures ran as part of the harness. No credential, GitHub API/write, +browser, commit, push, merge, workflow, live specimen, or runner operation was +performed. Codex review `5334901335` applies to input HEAD `a61c35f`; this +uncommitted correction has no final-head Codex review or hosted PR quick check. +The two P1 threads therefore still require a future exact-head review before +merge. These results establish only the tested offline static-AST behavior; +they do not establish runtime behavior or complete the remaining G01 evidence +gates. + +### Issue #79 PR #103 local self-review follow-up + +This follow-up preserves the prior nine P1 corrections, the harness docstring +correction, and their packet ledger. The additional local self-review finding +has no public review URL. Every witness is an inert AST specimen; no unsafe +source is evaluated, compiled, or launched. + +| # | Finding and URL | RED against the pre-follow-up worktree | Correction and safe control | +|---|---|---|---| +| 1 | P1 (self-review; no public URL supplied): the Markdown-link exemption certified `path.is_file()` after `path` was rebound to `Path("synthetic-private/file")`. A containment `try` placed before the approved resolved-path assignment also certified the later read. | `test_markdown_link_containment_guard_must_be_direct_and_reachable` retained the canonical safe control and added both mutations. `python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_markdown_link_containment_guard_must_be_direct_and_reachable` ran 1 test and failed with 2 subtest assertion failures: both unsafe variants were accepted. | Track `path` bindings in the read's lexical scope. The `relative_to(repository_root)` guard must follow the reviewed resolved-path binding, and no later `path` binding may occur before the file check. The canonical guard and existing unreachable-guard negatives remain covered. | + +The same focused command after the fix ran 1 test in 0.091s and passed. This +exercised the canonical positive, both existing unreachable-guard negatives, +the later-rebinding negative, and the guard-before-assignment negative. + +Rollback is to the exact pre-follow-up worktree snapshot: HEAD +`a61c35fb8ad7a1425e363a8fcfc6f5748eaa5029` plus the preserved uncommitted +nine-P1 batch, harness docstring correction, and packet ledger. Reversal must +remove only this follow-up's scanner change, regression additions, and ledger +entry; restoring either owned file from clean HEAD would discard prior work. +No ADR change was needed. Final full-suite and hygiene results follow. + +#### Final local verification + +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` +ran 58 tests in 236.197s and passed. Its packet-wide static scan covered 331 +shell commands and 95 Python heredoc bodies with zero violations. The focused +Markdown guard test passed in 0.091s after the fix. `git diff --check` exited +0. Added-line credential-token and personal-machine-path scans found no +matches. `git status --short` lists only the packet and offline regression +harness. + +No unsafe Python or shell specimen was evaluated, compiled, or launched. The +existing isolated `-I -B -c` standard-library probe and temporary local Git +fixtures ran as part of the harness. No credentials, GitHub API or writes, +browser, commit, push, merge, workflow, live specimen, or runner operation +were used. This self-review finding has no public URL; the local correction +has no final-head GitHub Codex review or hosted PR quick check. These results +cover the requested offline AST and packet-static behavior only; they do not +establish runtime behavior or complete the remaining G01 evidence gates. + +### Issue #79 PR #103 correction from exact review `5335553432` + +This correction starts from clean HEAD +`2c755af9dca8c44f902ad82879010743da2cc62c`. The supplied [Codex review +5335553432](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5335553432) +reported five inline P1 findings and no new issue-comment findings. All five +were triaged as blocking. The three additional local P1 findings below had no +public URLs supplied; the local P2 scope false positive was fixed because the +correction stayed bounded to lexical alias resolution. Every Python and shell +specimen below was inert scanner input; none was evaluated, compiled, or run +as an unsafe witness. + +| # | Finding and immutable URL | RED evidence against input HEAD | Correction and safe control | +|---|---|---|---| +| 1 | P1 [Codex comment 4119824482](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119824482): `maker = dict; value = maker(os.environ); print(value)` lost environment taint through a constructor alias. | `test_constructor_and_output_sink_aliases_preserve_sensitive_taint` accepted the aliased `dict` result at `print`. | Taint follows assigned and imported aliases of `dict`; an aliased copy of a literal status mapping remains accepted. | +| 2 | P1 [Codex comment 4119824487](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119824487): aliases of output sinks such as `emit = print; emit(os.environ)` and `warnings.warn` were not classified as sinks. | The same focused test accepted both `print` and `warnings.warn` aliases carrying `os.environ`. | Known output-sink assignment/import aliases are resolved once per AST; literal status messages through the same aliases remain accepted. | +| 3 | P1 [Codex comment 4119824492](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119824492): output of `Path.home()`, `Path("~").expanduser()`, or `os.path.expanduser("~")` could disclose the home path. | `test_home_and_decoded_local_paths_are_not_disclosed` accepted all three home-path forms. | Home-path sources and literal tilde expansion are tainted for output checks. Internal validation of `Path.home()` and output of a relative repository path after `expanduser()` remain accepted. | +| 4 | P1 [Codex comment 4119824497](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119824497): `awk` could print `ENVIRON["GH_TOKEN"]` and `jq -n env` could dump the inherited environment. | `test_shell_environment_dump_readers_are_rejected` returned no violation for either supplied command; a second AWK environment iteration and `jq env.GH_TOKEN` were also RED. | The scanner rejects AWK `ENVIRON` access and jq filter tokens containing the `env` filter. Literal-only AWK and jq filters remain accepted. These commands were passed only to the tokenizer/scanner and never executed. | +| 5 | P1 [Codex comment 4119824501](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119824501): the harness's first-match assignment helper could verify a canonical evidence path list while Python runtime honored a later duplicate assignment. | `test_reviewed_evidence_path_assignment_must_be_unique` failed because `_top_level_assignment` returned the first of two inert top-level assignments. Direct inspection of the input packet found one actual `issue79_reviewed_evidence_paths` declaration; the harness's duplicate-source guard was still missing. | `_top_level_assignment` now requires exactly one top-level declaration. The existing parity test also checks the canonical two-path value and use order, so a future duplicate fails the harness before it can bless the first value. | +| 6 | P1 (independent local review; no public URL supplied): a local helper returning a private `Path` plus a helper-returned `getattr` member name (`read_text`) bypassed filesystem-reader checks. | `test_path_getattr_readers_follow_local_path_and_member_returns` accepted the two inert helper returns followed by a `getattr` reader. | Static literal-string analysis follows visible local return expressions and Path receiver analysis follows local helper returns; the same computed member on the reviewed packet path remains accepted. | +| 7 | P1 (independent local review; no public URL supplied): `lookup = getattr(launchers, "get")` hid a launcher-bearing mapping lookup. | `test_getattr_mapping_lookup_alias_preserves_launcher_provenance` accepted the subsequent `lookup("x")` and workflow-launcher alias. | Mapping lookup analysis recognizes bounded `getattr` selections and keeps launcher provenance; a map containing `str.upper` remains accepted. | +| 8 | P1 (independent local review; no public URL supplied): `print(os.fsdecode(Path.cwd().resolve()))` disclosed a resolved local path after filesystem decoding. | `test_home_and_decoded_local_paths_are_not_disclosed` accepted the converted current-directory path. | `os.fsdecode` now preserves resolved-path taint into output sinks; decoding a reviewed repository-relative path remains accepted. | +| 9 | P2 (independent local review; no public URL supplied): a launcher lookup alias named `lookup` in one function caused a same-named local `str.upper` in another function to be rejected. | `test_mapping_lookup_alias_tracking_respects_function_scopes` returned a violation for the safe sibling function while the launcher alias was unused. | Lookup aliases are keyed by lexical binding and nearest shadowing binding. The sibling `str.upper` case is accepted while an invoked launcher lookup alias remains rejected. | +| 10 | P1 (coordinator inert AST probe; no public URL supplied): `Path.cwd()` output bypassed resolved-local-path disclosure checks through `Path as P`, `pathlib as pl`, and `cwd = Path.cwd; cwd()`. | `test_current_directory_path_aliases_are_not_disclosed` accepted all three output forms before the correction. | The resolver recognizes imported constructor/module aliases and assigned `cwd` method aliases; internal absolute-path validation and output of a reviewed repository-relative path remain accepted. | + +The focused RED command added the seven regression methods before scanner +changes: `python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_constructor_and_output_sink_aliases_preserve_sensitive_taint +Issue79RegressionTests.test_home_and_decoded_local_paths_are_not_disclosed +Issue79RegressionTests.test_shell_environment_dump_readers_are_rejected +Issue79RegressionTests.test_path_getattr_readers_follow_local_path_and_member_returns +Issue79RegressionTests.test_getattr_mapping_lookup_alias_preserves_launcher_provenance +Issue79RegressionTests.test_mapping_lookup_alias_tracking_respects_function_scopes +Issue79RegressionTests.test_reviewed_evidence_path_assignment_must_be_unique` +ran 7 tests and failed with 16 assertion failures. The unsafe cases were +accepted; the P2 safe-scope control was rejected. After the minimal corrections, +the same seven-test command passed. An additional home-path safe-control +refinement was checked by rerunning `test_home_and_decoded_local_paths_are_not_disclosed`; +it passed with ordinary relative `expanduser()` output still allowed. + +The final `python3 -I -B scripts/evidence_packet/issue79_regression_test.py` +run passed all 65 tests in 158.666 seconds. Its packet-wide static scan covered +331 shell commands and 95 Python heredoc bodies with zero violations. The first +whole-suite attempt was interrupted after the static test remained active for +over six minutes; its traceback showed imported-sink alias checks repeatedly +walking the same AST. The resolver now caches the alias set per AST, and the +completed full rerun above passed. + +Rollback target is the clean input HEAD +`2c755af9dca8c44f902ad82879010743da2cc62c`; restoring only these two owned +paths to that commit reverses this correction and leaves all committed work +intact. No other path was edited. Final hygiene results are recorded below. +No unsafe Python or shell witness was executed. The authorized harness did run +its existing isolated standard-library shadowing check and synthetic local Git +fixtures. No credentials, GitHub API or writes, browser, commit, push, merge, +workflow, live test, or runner operation was used. The supplied review applies +to the input SHA; this local correction has no final-head Codex review or +hosted PR quick check. These offline AST and packet-static results do not prove +runtime behavior or complete the remaining G01 evidence gates. + +#### Final correction hygiene + +After the final packet-wide static scan, `git diff --check` exited 0 and the +added-line credential/private-machine-path scan returned zero matches. The +only modified paths are this packet and +`scripts/evidence_packet/issue79_regression_test.py`. No commit or push was +made. + +### Issue #79 PR #103 local self-review: jq `$ENV` + +Independent local self-review reproduced a P1 scanner bypass: the new +`jq_command_violation` rejected `jq -n env` but accepted `jq -n '$ENV'` and +`jq -n '$ENV.GH_TOKEN'`. jq's `$ENV` is an environment object. No public +finding URL was supplied. The coordinator triaged this as blocking and +reproduced it by passing each command string only through `shlex.split` and +`forbidden_command`; neither command was executed. + +The inert regression `test_jq_environment_object_references_are_rejected` +was added first. Before the scanner change, +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_jq_environment_object_references_are_rejected` +ran 1 test and failed with 2 assertion failures: both `$ENV` forms returned +no violation. The safe literal filter in the same test remained accepted. + +The bounded correction now recognizes a `$ENV` token with identifier +boundaries alongside the existing `env` filter check. The focused GREEN +command, +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_jq_environment_object_references_are_rejected +Issue79RegressionTests.test_shell_environment_dump_readers_are_rejected`, +ran 2 tests in 0.093s and passed. This covered both `$ENV` forms, the safe +literal jq filter, the existing jq `env` cases, AWK `ENVIRON` readers, and +their safe literal controls. These results concern scanner input only. + +Rollback for this follow-up removes only the new jq regression method, restores +the jq matcher to its pre-follow-up `env`-only expression, and removes this +self-review entry. The rollback target is the input HEAD +`2c755af9dca8c44f902ad82879010743da2cc62c` plus the full pre-existing +uncommitted two-file batch. Restoring either file from HEAD would discard that +batch and is not the rollback for this follow-up. + +The prior interrupted suite attempt recorded in the preserved batch ledger is +inconclusive and is not counted as passing evidence. The full-suite result for +this follow-up is recorded after its completed run below. + +#### Follow-up verification + +After recording this self-review, `python3 -I -B +scripts/evidence_packet/issue79_regression_test.py` completed with 66 tests +passing in 159.614s. The final packet static scan covered 331 shell commands +and 95 Python heredoc bodies with zero violations. `git diff --check` exited 0. +The added-line scan found zero credential-pattern and personal/private-path +matches. `git status --short` lists only this packet and +`scripts/evidence_packet/issue79_regression_test.py`; HEAD remains +`2c755af9dca8c44f902ad82879010743da2cc62c`. The earlier interrupted run +remains inconclusive; this completed run is separate evidence. No credentials, +GitHub API access or writes, browser, commit, push, merge, workflow, live test, +or runner operation was used. This local correction has no final-head Codex +review or hosted PR quick check and does not establish runtime behavior or +complete the remaining G01 evidence gates. + +### Issue #79 PR #103 local self-review: assigned constructor aliases + +This P1 self-review follows up on the immutable [Codex finding +4119824482](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4119824482), +which identified aliases of the environment-preserving constructors. The +preceding correction recognized an assigned `dict` alias, while assigned +`list`, `tuple`, `set`, `str`, and `repr` aliases still lost taint when their +results were stored before reaching an output sink. No separate public URL was +supplied for this local reproduction. The direct nested `print(maker(...))` +shape was already rejected; these specimens used an intermediate `value`. + +The inert regression `test_assigned_sensitive_constructor_aliases_preserve_taint` +was added before the scanner correction. Its RED command, +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_assigned_sensitive_constructor_aliases_preserve_taint`, +ran 1 test and failed with 6 assertion failures: all five reported aliases and +an aliased `bytes` conversion were accepted at `print(value)`. The bytes case +uses `maker(next(iter(os.environ.values()), "").encode())`, which is a valid +conversion to bytes; all specimens were scanner input only. The six literal +status controls in the same test passed. + +The bounded fix now propagates sensitive arguments through the existing +constructor set (`dict`, `list`, `tuple`, `set`, `str`, `bytes`, and `repr`) +when called directly or through an assigned/stable `builtins` alias. Alias +resolution is cached on the AST. The focused GREEN command, +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_assigned_sensitive_constructor_aliases_preserve_taint +Issue79RegressionTests.test_constructor_and_output_sink_aliases_preserve_sensitive_taint +Issue79RegressionTests.test_sensitive_local_helper_returns_are_tainted_at_output_sinks +Issue79RegressionTests.test_sensitive_return_through_factory_created_instance_is_tainted +Issue79RegressionTests.test_sensitive_method_and_lambda_returns_are_tainted`, +ran 5 test methods in 0.110s and passed, including the safe controls and +adjacent constructor, helper-return, and sink cases. + +The full `python3 -I -B scripts/evidence_packet/issue79_regression_test.py` +offline suite then passed all 67 tests in 122.046s. Its packet-wide static scan +covered 331 shell commands and 95 Python heredoc bodies with zero violations. +No specimen was executed as code. + +Rollback for this self-review removes only the new regression method, restores +the constructor check in `python_sensitive_value_expression` to the exact +pre-follow-up behavior, and removes this ledger section. The rollback target is +the state immediately before this follow-up: input HEAD +`2c755af9dca8c44f902ad82879010743da2cc62c` plus the full existing uncommitted +two-file batch, including its jq `$ENV` correction and earlier ledger entries. +Do not restore either owned file from HEAD; that would discard the full batch. + +#### Final local verification and limits + +The final `git diff --check` exited 0, and the added-line credential/private- +path scan found zero matches. The complete offline suite and packet-wide scan +results above are the completed runs; the earlier interrupted attempt remains +inconclusive and is not counted. Only this packet and +`scripts/evidence_packet/issue79_regression_test.py` are modified, and HEAD +remains `2c755af9dca8c44f902ad82879010743da2cc62c`. No unsafe specimen was +executed. No credentials, GitHub API or writes, browser, commit, push, merge, +workflow, live test, or runner operation was used. This uncommitted correction +has no final-head Codex review or hosted PR quick check. The offline results do +not establish runtime behavior or complete the remaining G01 evidence gates. + +### Issue #79 PR #103 follow-up: home-path aliases and fresh P1 scanner findings + +The path-disclosure extension to `test_home_and_decoded_local_paths_are_not_disclosed` +was run before the policy change. Its inert RED witnesses showed that `Path as P`, +`pathlib as pl`, and an assigned `Path.home` callable were accepted; initial +positive controls that attempted reviewed-file reads were rejected by the separate +filesystem-read policy, so those controls were narrowed to reviewed relative path +objects and internal validation before GREEN. The scanner now resolves imported +Path constructor aliases and assigned `home` method aliases when classifying +resolved local paths. No specimen was executed. + +The coordinator also supplied two exact-head P1 findings from Codex review +[5336505067](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5336505067): +[inline Python NamedExpr sink alias](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4120522190) +and [Bash indirect expansion](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4120522218). +The new inert regressions failed before correction: both `(emit := print)(os.environ)` +and `name=GH_TOKEN; printf "%s\\n" "${!name}"` were accepted. The scanner now +recognizes a direct NamedExpr callable output sink and rejects indirect shell +parameter expansion fail-closed; literal reviewed output remains accepted. + +Focused GREEN command: `python3 -I -B +scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_home_and_decoded_local_paths_are_not_disclosed +Issue79RegressionTests.test_named_expression_callable_sink_preserves_environment_taint +Issue79RegressionTests.test_bash_indirect_environment_expansion_rejects_credential_names` +ran 3 tests and passed. The first owned full-suite process (PID 95107) ran for +5m53s at approximately 96–100% CPU before the coordinator sent SIGINT while +recursively evaluating existing path-alias analysis. It raised +KeyboardInterrupt and is inconclusive, not passing evidence. Repeated +computation of Path constructor aliases during path analysis was then cached +per AST. The completed rerun after that cache fix passed all 69 tests in +121.498s, with the same 331 shell commands, 95 Python heredoc bodies, and zero +packet-scan violations. + +Rollback removes the three added alias witnesses and safe controls, restores the +pre-follow-up resolved-path check for `Path.home`, removes the NamedExpr sink and +indirect-expansion checks, removes the two new P1 regression methods, and removes +this ledger section. The rollback target is input HEAD +`1f5f89bc80f09393dbc44f45d20f0141e747005a` plus its existing two-file worktree +state; do not restore either file wholesale from HEAD because that discards prior +uncommitted evidence hardening. + +#### Completed local verification + +The completed `python3 -I -B scripts/evidence_packet/issue79_regression_test.py` +run passed all 69 tests in 121.498s. Its packet-wide static scan covered 331 shell +commands and 95 Python heredoc bodies with zero violations. `git diff --check` +passed, the added-line sensitive-pattern scan found zero credential or personal +path matches, and `git status --short` listed only the two assigned files. HEAD +remains `1f5f89bc80f09393dbc44f45d20f0141e747005a`. These are offline scanner +results only; they do not qualify runtime behavior, complete G01, or substitute +for exact-final-head Codex review and hosted PR quick checks. No credentials, +GitHub API or writes, browser, commit, push, merge, workflow, live test, or runner +operation was used. + +The final packet text was then rescanned with +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations`; +that test passed and again reported 331 shell commands, 95 Python heredoc +bodies, and zero violations. + +### Current-directory path alias finding + +An adjacent P1 scanner bypass was reproduced from the coordinator's inert AST +probes: output of `Path.cwd()` was accepted when the imported constructor was +aliased (`Path as P`), the module was aliased (`pathlib as pl`), or the bound +method was assigned (`cwd = Path.cwd; print(cwd())`). The RED command +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_current_directory_path_aliases_are_not_disclosed` +ran 1 test and failed with 3 subtest assertion failures in 0.096s. All three +unsafe forms were accepted before the fix, while the safe absolute-path +validation and reviewed-relative-path output controls in the same test passed. +These were scanner inputs only; no specimen was executed and no real current +directory was read. + +The resolver now recognizes `cwd` calls through imported `Path` constructors, +aliased `pathlib` modules, and assigned method names. `home` and `cwd` method +aliases share one cached per-AST pass. Focused GREEN command: +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py +Issue79RegressionTests.test_current_directory_path_aliases_are_not_disclosed +Issue79RegressionTests.test_home_and_decoded_local_paths_are_not_disclosed +Issue79RegressionTests.test_named_expression_callable_sink_preserves_environment_taint +Issue79RegressionTests.test_bash_indirect_environment_expansion_rejects_credential_names +Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations` +ran 5 tests in 46.559s and passed. The packet scan found 331 shell commands and +95 Python heredoc bodies with zero violations. + +The post-cwd complete offline run +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` passed all 70 +tests in 123.489s. Its packet-wide static scan found 331 shell commands, 95 +Python heredoc bodies, and zero violations. This is the current completed run; +the prior 5m53s interrupted attempt remains inconclusive. + +To roll back only this cwd-alias correction while preserving the incoming +two-file worktree changes, remove +`test_current_directory_path_aliases_are_not_disclosed`, remove cwd recognition +and the shared `home`/`cwd` method-alias cache from +`python_resolved_local_path_expression` (restoring the incoming home-alias +handling), and remove finding 10 and this section. Do not restore either owned +file wholesale from HEAD; the input worktree already contained unrelated +uncommitted evidence hardening. The full task input HEAD remains +`1f5f89bc80f09393dbc44f45d20f0141e747005a`. + +### Issue #79 PR #103 exact-head callable and path-alias findings + +This correction records two P1 findings and one P2 finding from an independent, +read-only gpt-6-luna/max review of immutable input HEAD +1784c1530e64bb6c45b512f293a8caeaaa0ff44a; no public URL was supplied for +that review. It also records three fresh exact-head GitHub Codex P1 findings +from review 5337088216 at the same input SHA. The Python specimens below are +inert strings passed to the AST scanner; none was executed, and no real +environment mapping, home path, or current directory was read. + +| # | Severity and immutable finding | RED reproduction at input HEAD | GREEN resolution and positive control | +|---|---|---|---| +| 1 | P1, independent read-only review (no public URL): an assigned output-sink alias in (alias := emit)(os.environ) lost environment taint although direct (alias := print)(os.environ) was rejected. | test_named_expression_sink_alias_chain_preserves_environment_taint accepted the assigned-alias witness; the direct sink control was rejected. | Named-expression callable values now resolve assigned aliases of reviewed output sinks. A literal reviewed status mapping through the same named-expression form remains accepted. | +| 2 | P1, independent read-only review (no public URL): home = Path.home; other = home; print(other()) bypassed path-disclosure detection; the same alias chain through Path.cwd was unchecked. | test_path_method_alias_chains_respect_lexical_shadowing accepted both the home and current-directory alias chains. | The path checker follows scoped Path.home and Path.cwd method aliases across assignments; existing direct, imported-alias, and safe relative-path controls remain covered. | +| 3 | P2, independent read-only review (no public URL): a module-level method alias named home or cwd wrongly tainted a shadowing function parameter in def report(home): print(home()). | The same test rejected both the home and cwd parameter-shadow controls. | Method aliases now resolve against lexical bindings and stop at a nearer parameter or other binding. Both shadowing controls and a reviewed relative Path output pass. | +| 4 | P1, [GitHub Codex finding 4120959212](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4120959212), source 1784c1530e64bb6c45b512f293a8caeaaa0ff44a: immediate lambda invocation did not bind os.environ to the lambda's payload parameter before checking print(payload). | test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters accepted (lambda payload: print(payload))(os.environ). | Immediate lambda call arguments now bind to lambda parameters during taint analysis. The assigned-lambda status control remains accepted. | +| 5 | P1, [GitHub Codex finding 4120959225](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4120959225), source 1784c1530e64bb6c45b512f293a8caeaaa0ff44a: taint was not propagated through "{}".format(secret) after secret = os.environ. | test_sensitive_taint_reaches_string_format_arguments accepted the assigned environment alias passed to str.format; the direct os.environ argument control was already rejected. | Sensitive-value analysis now follows str.format arguments as well as the format receiver. Formatting a literal reviewed status mapping remains accepted. | +| 6 | P1, [GitHub Codex finding 4120959236](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4120959236), source 1784c1530e64bb6c45b512f293a8caeaaa0ff44a: static-method binding incorrectly discarded payload as if it were an instance self parameter. | The same lambda/static-method test accepted C().emit(os.environ) for an @staticmethod that prints payload. | Taint binding now drops the first positional parameter only for bound instance methods. A static method called with a literal reviewed status mapping remains accepted. | + +The RED command +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_named_expression_sink_alias_chain_preserves_environment_taint Issue79RegressionTests.test_path_method_alias_chains_respect_lexical_shadowing Issue79RegressionTests.test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters Issue79RegressionTests.test_sensitive_taint_reaches_string_format_arguments +ran 4 tests in 0.102s and failed with 8 unsafe-subcase assertions. The direct +sink and direct environment-format controls, safe status outputs, and reviewed +relative-path positive control passed during that RED run. + +The focused GREEN command +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_named_expression_callable_sink_preserves_environment_taint Issue79RegressionTests.test_named_expression_sink_alias_chain_preserves_environment_taint Issue79RegressionTests.test_sensitive_values_are_tainted_into_method_and_lambda_parameters Issue79RegressionTests.test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters Issue79RegressionTests.test_sensitive_taint_reaches_string_format_arguments Issue79RegressionTests.test_home_and_decoded_local_paths_are_not_disclosed Issue79RegressionTests.test_current_directory_path_aliases_are_not_disclosed Issue79RegressionTests.test_path_method_alias_chains_respect_lexical_shadowing Issue79RegressionTests.test_resolved_local_paths_are_not_disclosed_to_output_sinks Issue79RegressionTests.test_resolved_paths_keep_taint_through_protocol_and_byte_conversions +ran 10 tests in 0.123s and passed. + +Rollback is limited to the correction represented here: remove the four added +regression methods; restore the incoming python_resolved_local_path_expression +method-alias block and remove python_path_method_alias_visible; revert the +named-expression sink-alias, inline-lambda/static-method binder, and +str.format taint changes; and remove this section. Do not restore either file +wholesale or change the input HEAD. The final exact-head GitHub Codex review and +hosted PR quick check remain pending; this offline record does not claim either +has completed. + +The first full-suite attempt was interrupted before completion after the scoped +alias resolver repeatedly rebuilt the Path import-alias set; that attempt is +inconclusive and is not counted as a pass. The resolver now reuses the +per-AST Path-constructor cache and carries an explicit visited-binding set for +alias cycles; the path-alias regression includes a cyclic-alias control. The +completed command +python3 -I -B scripts/evidence_packet/issue79_regression_test.py +ran all 74 tests in 122.511s and passed. Its packet-wide static scan found 331 +shell commands and 95 Python heredoc bodies with zero violations. A separate +final packet scan is run after this ledger edit. + +### Issue #79 PR #103 review 5337497074: evidence scanner P1 and alias corrections + +This ledger records four exact-head GitHub Codex P1 findings from review +5337497074 at input HEAD `bda0eedb5ba43fba0243c77ef09f50714795490c`, two +independently reproduced local P1 groups (formatter/static-method aliases and +Path method aliases), and one local P2 false positive. The four supplied P1s +were triaged as blocking and corrected; both local P1 groups were reproduced +against the same input and corrected; the P2 rejection was corrected to retain +the reviewed safe behavior. All Python and shell specimens remained inert +scanner data. The only Git execution used a temporary synthetic repository +with isolated HOME and system/global Git configuration disabled; no credential, +real configuration, runner, workflow, App, or host operation was used. + +| # | Severity and immutable finding | RED reproduction at input HEAD | GREEN resolution and safe control | +|---|---|---|---| +| 1 | P1, [GitHub Codex finding 4121296898](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4121296898): newline-separated `secret=$GH_TOKEN` followed by `printf $secret` hid the credential behind a shell assignment alias. | `test_shell_credential_assignment_aliases_are_rejected` failed for both the direct alias and the second alias `copy=$secret`; shell source was scanned through the packet fence parser and never executed. | Shell-fence scanning now carries credential taint through assignment-only commands and alias chains, and clears it at each new shell fence. The literal `secret=reviewed` control remains accepted. | +| 2 | P1, [GitHub Codex finding 4121296906](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4121296906): `secret=os.environ; print(''.join(secret.values()))` passed a sensitive mapping view through `join` without output taint. | `test_join_of_environment_views_keeps_sensitive_taint` accepted the assigned-environment witness; the direct `os.environ` variant was already rejected. | Join taint now follows sensitive `.values()`/`.items()` arguments while retaining reviewed environment-name list behavior. Literal status mappings joined through the same forms remain accepted. | +| 3 | P1, [GitHub Codex finding 4121296915](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4121296915): a pre-existing local `.git/config` `core.worktree` redirect could make status appear clean while the invocation worktree was dirty. | The disposable repository had a modified invocation file while redirected `git status --short` returned empty. The active post-correction verifier had no same-environment top-level comparison, and the inert `git -c core.worktree=... status` scanner specimen was accepted. | The active verification template now runs `require_git_invocation_root(invocation_root, git_environment)` before repository queries and compares bounded `git rev-parse --show-toplevel` output with `Path.cwd().resolve()` under the same isolated environment. The regression extracts and validates that exact helper, accepts the ordinary repository root, then rejects the local-config alternate root before status evidence can be trusted; the `-c` and numbered config-environment overrides are also rejected. | +| 4 | P1, [GitHub Codex finding 4121296923](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4121296923): `git config --global --list --show-origin` and `git config --get-regexp .` exposed unbounded configuration through read-only query classification. | `test_unbounded_git_config_dumps_are_rejected` accepted both inert scanner strings. A follow-up safe-control RED run also showed that removing `--get-regexp` outright rejected the packet's narrow repository-local filter check. | Global/system/worktree and origin/scope queries are rejected; `--list`/`-l` remain unapproved; regex queries require `--local` and the exact bounded `^filter\.` pattern. The unbounded `.` and global-list forms are rejected, while `git config --local --get-regexp '^filter\.'`, exact-key `--get`, and reviewed `git status --short` controls pass. | +| 5 | P1, independent local review (no public URL): formatter callable aliases (`fmt = format` and `fmt = "{}".format`) lost sensitive argument taint; `sm = staticmethod` also made a static method look like a bound instance method, dropping its `payload` parameter. | `test_format_callable_aliases_preserve_sensitive_taint` and the aliased-decorator subcase of `test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters` accepted their inert environment-output witnesses. | The sensitive-value resolver follows assigned format callables and their arguments. Static-method parameter binding also recognizes an assigned `staticmethod` alias; literal format/status and static-method controls remain accepted. | +| 6 | P1, independent local review (no public URL): `Path.home`/`Path.cwd` output escaped through default-argument aliases, aliased `getattr`, and `NamedExpr` call targets. | `test_path_method_aliases_follow_defaults_getattr_and_namedexpr` failed for all four home/cwd shapes. | Path-method resolution now follows default values, literal `getattr` member aliases, and named expressions while respecting the lexical binding that supplies a method. A reviewed repository-relative `Path` output remains accepted. | +| 7 | P2, independent local review (no public URL): a later safe `home = lambda: "reviewed"` did not cancel an earlier `home = Path.home` taint; helper and instance variants needed to remain safe too. | `test_path_home_alias_reassignment_and_helpers_remain_safe` rejected the direct reassignment and helper return; its instance-method positive control was already accepted. | Path alias resolution uses the latest binding in the applicable scope, so the reviewed unconditional overwrite and helper/instance controls pass while the P1 default/getattr/named-expression witnesses remain rejected. | + +The exact focused RED command for findings 1–4 was +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_shell_credential_assignment_aliases_are_rejected Issue79RegressionTests.test_join_of_environment_views_keeps_sensitive_taint Issue79RegressionTests.test_core_worktree_override_cannot_mask_a_dirty_invocation_worktree Issue79RegressionTests.test_unbounded_git_config_dumps_are_rejected`. +It ran 4 tests and failed with 6 unsafe-subcase assertion failures; the +synthetic Git status deception also reproduced. The same command after the +correction ran 4 tests and passed. + +The bounded-config positive control was added after the first green batch. +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_unbounded_git_config_dumps_are_rejected` +ran 1 test and failed because the removed `--get-regexp` option also blocked +the packet's bounded local filter query. After restricting regex queries to +the exact `--local --get-regexp '^filter\.'` form, +the same focused command ran 1 test and passed. + +The focused local alias RED command was +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_format_callable_aliases_preserve_sensitive_taint Issue79RegressionTests.test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters Issue79RegressionTests.test_path_method_aliases_follow_defaults_getattr_and_namedexpr Issue79RegressionTests.test_path_home_alias_reassignment_and_helpers_remain_safe`. +It ran 4 tests and failed with 9 unsafe-subcase assertion failures. The same +command after correction ran 4 tests and passed, including the P2 positive +controls. Each RED case was an AST/string fixture; none was evaluated or +launched. + +The first full offline attempt with general method-call argument propagation +ran 81 tests in 87.693s and failed 4 package-containment/static-scan checks +because a safe list of environment variable names was treated like a list of +environment values. Restricting the added join propagation to sensitive +`.values()`/`.items()` inputs restored those reviewed controls. After the local +`core.worktree` root guard and bounded Git-config query were in place, the final +command +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` +ran all 81 tests in 134.483s and passed. Its packet-wide scan found 331 shell +commands and 95 Python heredoc bodies with zero violations. + +After this ledger was written, the final packet scan command +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations` +ran 1 test and passed; it again found 331 shell commands, 95 Python heredoc +bodies, and zero violations. Final `git diff --check` exited 0. The added-line +credential/private-key/personal-path pattern scan reported zero matches, and +`git diff --name-only` listed exactly the two assigned files. HEAD remained +`bda0eedb5ba43fba0243c77ef09f50714795490c`. + +Rollback for this correction is the exact clean input HEAD +`bda0eedb5ba43fba0243c77ef09f50714795490c`: restore only +`docs/evidence/g01-recovery-packet.md` and +`scripts/evidence_packet/issue79_regression_test.py` to that tree and remove +this section, including the added invocation-root check and callable/path +alias scanner changes. The synthetic Git fixture uses a temporary directory +and its isolated configuration only. No commit, push, merge, GitHub comment, +Project write, exact-final-head review, hosted quick check, or live +qualification is claimed; those remain with the coordinator. + +### Issue #79 PR #103 exact-head review 5338401659 follow-up + +This entry records the three P1 findings from exact-head GitHub Codex review +5338401659 at input HEAD `db8faba6f3a1de384bbde729e3d19a8134f6f7a2`, the +independent shell indirect-expansion P1, the safe-control P2 for a local +user-defined `format` alias, and the coordinator's additional conditional +`Path.home` P1. Python and shell witnesses are inert AST/scanner input strings; +no unsafe source was executed and no real credential, home path, or runner was +read or used. + +| # | Severity and immutable finding | RED reproduction at input HEAD | GREEN resolution and safe control | +|---|---|---|---| +| 1 | P1, [GitHub Codex finding 4121983412](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4121983412): after `secret=$GH_TOKEN`, a skipped `[ 1 = 2 ] && secret=reviewed` assignment could clear taint before a later `printf`. | `test_shell_credential_assignment_aliases_are_rejected` accepted the conditional overwrite witness while its earlier environment assignment tainted `secret`. | Shell tokenization retains the prior taint across conditional assignment segments. Literal and unconditional `secret=reviewed` overwrites remain accepted. | +| 2 | P1, [GitHub Codex finding 4121983428](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4121983428): `join(list(secret.values()))` and tuple, generator, and `map` wrappers could lose environment-value taint. | `test_join_of_environment_views_keeps_sensitive_taint` accepted the wrapper witnesses before the fix. | Taint follows sensitive environment views through the reviewed wrappers. Literal status-value controls pass, and propagation is limited to sensitive `.values()`/`.items()` sources so reviewed environment-name joins remain safe. | +| 3 | P1, [GitHub Codex finding 4121983438](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4121983438): generic Git config read classification accepted credential-bearing keys such as `credential.helper`, `http.*.extraheader`, and `user.email`. | `test_git_config_queries_allow_only_reviewed_keys` accepted disallowed exact-key and URL-match queries before the allowlist. | `--get`, `--get-all`, and `--get-urlmatch` now require their exact reviewed keys; the bounded local `^filter\.` regex remains the only regex query. The packet's `--local --get-all remote.origin.url` and `--get core.repositoryformatversion` queries remain allowed. | +| 4 | P1, independent local review (no public URL): indirect shell expansion such as `secret=${!name}` could be hidden in an assignment and escape environment-taint checks. | `test_shell_indirect_environment_expansion_in_assignment_is_rejected` reproduced the assignment-only gap with inert shell text. | Indirect expansion is rejected, including in assignment-only commands; the same scanner still accepts reviewed literal status output. | +| 5 | P2, independent local review (no public URL): a local user-defined `format` function returning the constant `"reviewed"` was falsely rejected when passed `os.environ`, including through `fmt = format`. | `test_user_defined_format_alias_returning_constant_is_safe` failed for both the direct function and its alias. | Local constant-return analysis exempts those resolved custom calls from environment-output rejection. The existing format taint tests continue to reject standard formatting that exposes sensitive arguments. | +| 6 | P1, coordinator follow-up (no public URL): `home = P.home; if False: home = lambda: "reviewed"; print(home())` could let an unexecuted conditional assignment erase the `Path.home` alias. | `test_path_home_alias_conditional_reassignment_retains_taint` failed because the inert AST specimen was accepted. | Alias resolution retains possible conditional bindings after the latest definite assignment. The unconditional `home = lambda: "reviewed"` safe overwrite control in `test_path_home_alias_reassignment_and_helpers_remain_safe` remains accepted. | + +The initial focused RED batch ran five issue-specific test methods and failed +with 15 unsafe-subcase assertions across the shell, join, Git-query, and +indirect-expansion witnesses. The coordinator-supplied `Path.home` RED command +ran one test and failed because the scanner returned no finding. After the +fixes, the focused review-method tests passed, as did the four Path alias tests +including the unconditional overwrite control. An early full run of all 85 +tests completed in 133.576s but failed only the packet's static audit because +the local loop name `source_value` shadowed an audit helper name; renaming that +loop variable removed the audit collision, and the targeted post-correction +packet scan passed with 331 shell commands, 95 Python heredoc bodies, and zero +violations. The post-ledger command +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` then passed +all 85 tests in 133.460s; its packet-wide audit again found 331 shell commands, +95 Python heredoc bodies, and zero violations. An independent scan is run once +more against this final ledger text. + +The coordinator's subsequent inert self-probe found a further join-alias gap +before commit: a literal string separator stored in a local variable, an +assigned `"".join` callable, a `str().join` receiver, an `iter` wrapper, and a +stored `secret.values()` view were accepted when joined into an output sink. +These were scanner-only strings; no environment values were read. Adding the +unsafe witnesses and safe literal-snapshot controls to +`test_join_of_environment_views_keeps_sensitive_taint` first made the focused +test fail for three receiver/wrapper subcases and then two stored-view +subcases. The correction recognizes reviewed literal-string join aliases and +follows stored view/iterable aliases through the join argument while retaining +the user-defined `format` and literal-snapshot safe controls. The focused +join/format command then passed two tests. After this correction, +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` passed all +85 tests in 137.779s; its packet-wide audit found 331 shell commands and 95 +Python heredoc bodies with zero violations. A final packet-only scan after +this ledger update remains required before commit. + +Rollback for this correction is input HEAD +`db8faba6f3a1de384bbde729e3d19a8134f6f7a2`: restore only +`docs/evidence/g01-recovery-packet.md` and +`scripts/evidence_packet/issue79_regression_test.py` to that tree and remove +this section. No commit, push, GitHub or Project write, workflow operation, +live runner test, exact-final-head Codex review, or hosted PR quick check is +claimed; final review and hosted checks remain with the coordinator. + +### Issue #79 PR #103 exact-head review 5339367722 and final-delta hardening + +Input HEAD is `7e277abf1c257edd5e07590add9b6b6c18c7928f`. Four P1 findings +from [GitHub Codex review 5339367722](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5339367722) +are addressed in this packet and its offline regression harness: + +| Finding | Inert RED witness | Correction and safe boundary | +|---|---|---| +| [URL-encoder argument taint](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4122733322) | `urlencode(os.environ)` and an imported alias were accepted before the fix. | The encoder retains sensitive argument taint; literal reviewed data remains accepted. | +| [Raw origin URL shell output](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4122733336) | Executable `git config --local --get-all remote.origin.url` was allowed to print the raw URL. | The shell context rejects that output, while the captured, compared Python verifier query remains permitted. | +| [Member-stored environment values](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4122733350) | Assigning `os.environ` to an attribute or item and printing it, including through a one-step local alias, was accepted. | Output inspection traces matching member writes without expanding the global taint fixed point; a literal member value remains accepted. | +| [Shell parameter modifier](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4122733359) | `secret=${GH_TOKEN#x}` followed by `printf` lost its sensitive assignment. | Braced parameter modifiers retain taint before output. | + +Independent read-only final-delta review also reproduced conditional `if` +and `env` prefix assignments clearing prior shell taint, and the Python +`str.join` descriptor and nested `next(iter(...))` join forms losing +environment-value taint. The corresponding inert negative tests and literal +safe controls are included in the same harness. No specimen was executed or +fed a real credential. + +RED was checked against the immutable input packet through the offline harness +loader: six focused methods produced ten failing unsafe subcases and zero +harness errors; a compatibility name mapped the new shell wrapper to the +input scanner's original `forbidden_command`. A subsequent self-review added +the one-step member alias witness, which failed once against the intermediate +candidate before its output-only correction. The canonical GREEN command is +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py`. +Repeated intermediate packet scans were interrupted after exceeding five +minutes and are not claimed as passes. The measured input-HEAD packet scan +passed in 57.011s. The final correction shares a visited AST set across the +local-path resolver's branches, preventing repeated expansion of the same +return graph; seven existing path-disclosure tests passed, and the candidate +packet-only scan passed in 60.153s with 331 shell commands, 95 Python heredoc +bodies, and zero violations after unrelated diagnostic caches were removed. + +Before the independent follow-up, the offline command +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` passed all +91 tests in 131.352s, including the packet scan of 331 shell commands and 95 +Python heredoc bodies with zero violations. A separate post-ledger packet scan +and diff hygiene check are recorded after this entry. Exact-head hosted and +GitHub Codex review gates remain pending and are not inferred from offline +results. Rollback is limited to this packet and the offline +issue #79 harness at input HEAD `7e277abf1c257edd5e07590add9b6b6c18c7928f`. +No live runner, workflow dispatch, credential, or production behavior was +exercised. + +### Issue #79 independent final-delta review follow-up + +A read-only GPT-6-Luna/max review of the uncommitted two-file candidate found +five further P1 boundary paths. The reviewer made no file changes or live +calls. The coordinator reproduced all specimens as inert scanner inputs; the +four focused test methods first failed in four unsafe subcases, while a +separate Python subprocess probe was also accepted before its correction. + +| Reproduced path | Correction and control | +|---|---| +| A literal `trap` handler could print the raw `remote.origin.url`; a Python `subprocess.run` literal argv could do the same. | Deferred shell handlers and literal Python command argv now use the same sensitive-output rule as executable shell commands. Direct shell, nested `bash -c`, and the packet's captured/compared verifier controls remain in scope. | +| `import urllib.parse as parse; secret = os.environ; print(parse.urlencode(secret))` was accepted; an unrelated function parameter could also hide a directly imported encoder alias. | Encoder import spellings include module aliases, and an unrelated shadow no longer removes the import from the conservative taint set. Literal data remains accepted. | +| An unrelated function parameter named `str` or `next` hid the built-in join/value wrapper at module scope. | Built-in shadowing is checked against the call's visible lexical scopes rather than the whole AST. Literal reviewed data remains accepted. | +| Member-stored environment values passed through two local aliases were accepted. | Output-only member lookup follows bounded Name/attribute/item alias expressions without enlarging the global taint fixed point. | +| A local helper wrote `obj.payload = value` after a call passed the caller's `box` and environment map; printing `box.payload` was accepted. | Direct local-helper positional object bindings are mapped to matching member writes; unreviewed dynamic helpers are not executed. | + +The focused GREEN command naming the four updated issue methods passed four +tests in 0.105s. The packet-only command then passed in 60.205s with 331 shell +commands, 95 Python heredoc bodies, and zero violations. The post-entry +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` command +passed all 91 tests in 134.300s and again found 331 shell commands, 95 Python +heredoc bodies, and zero violations. `git diff --check` exited cleanly, +`git diff --name-only` listed only the packet and offline harness, and the +added-line credential/private-key/personal-path pattern scan found zero +matches. A packet-only scan after this final ledger update is still required. +Rollback for this follow-up remains only the issue #79 packet and offline +harness at input HEAD `7e277abf1c257edd5e07590add9b6b6c18c7928f`. +GitHub Codex exact-head review and hosted PR quick check remain pending. + +### Issue #79 PR #103 exact-head review 5341432154 correction + +Input HEAD is `a55fb9d1c9402bc65c0f40daa6673d6f447700f4`. The +[exact-head review](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5341432154) +reported seven P1 findings: four in the review body and three inline. The +issue-comment feed had no additional finding. Each witness below was supplied +as inert scanner or isolated temporary-Git test data; no unsafe executable, +private file, real environment value, or runner was used by this correction. + +| P1 finding | RED reproduction | GREEN boundary and control | +|---|---|---| +| Review-body executable path | `/tmp/git` and `./git` with reviewed status arguments were accepted by basename. | Only bare executables resolved through the reviewed `PATH` and exact reviewed absolute executable paths are accepted; bare `git status` remains accepted. | +| Review-body Git configuration state | Assignment-only `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=diff.external`, and an unreviewed value were discarded before a later `git diff`. | Every unreviewed `GIT_CONFIG_*` assignment is retained for violation classification across the fence; exact required preflight settings remain accepted. | +| Review-body `git show --output` | `git show --output=AGENTS.md --format=oneline -s HEAD` was classified read-only. | `git show --output` is rejected before read-only classification; ordinary `git show` remains allowed. | +| Review-body reader option path | `diff --from-file=$HOME/.netrc docs/EXECUTION.md` hid a private path in an option. | Filename-bearing `diff`, `grep`, `rg`, `awk`, and `jq` options require reviewed paths, as ordinary file operands do. | +| [Inline HOME path](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4124408099) | `home = os.environ["HOME"]; print(home)` was accepted. | `HOME` environment values retain path-sensitive taint; benign reviewed relative paths remain accepted. | +| [Inline ADR parity](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4124408105) | The ADR changed in this PR was missing from the two-path intent/HEAD-byte parity list. | The ADR is now checked by the same bounded blob, intent-bit, and byte-parity loop; isolated temporary-Git fixtures exercise all three reviewed paths. | +| [Inline loader assignment](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4124408110) | The offline loader silently skipped a top-level assignment whose value was an unreviewed command call. A subsequent self-review also reproduced the same gap under the special `source` assignment. | Unsupported top-level assignments now fail closed before any value is executed; the special `source` assignment must match its exact reviewed AST. Current safe scanner constants still load. | + +The six focused RED test methods failed with seven unsafe subcase assertions +before correction. Their GREEN rerun passed six methods in 0.935s. The +packet-only command then passed in 62.267s and reported 331 shell commands, +95 Python heredoc bodies, and zero violations. The post-entry command +`python3 -I -B scripts/evidence_packet/issue79_regression_test.py` passed all +95 tests in 134.740s, again finding 331 shell commands, 95 Python heredoc +bodies, and zero violations. `git diff --check` passed, only the packet and +offline harness changed, and the added-line credential/private-key/personal- +path pattern scan found zero matches. A packet-only scan after this final +ledger update remains required before push. +The post-GREEN self-review of the special `source` assignment produced one +additional failing inert loader assertion, then passed after exact-AST +validation. The complete harness was rerun after that change: 95 tests passed +in 135.776s, with 331 shell commands, 95 Python heredoc bodies, and zero +violations. The final post-ledger packet scan remains a separate gate. +Rollback restores only this packet and its offline harness from input HEAD +`a55fb9d1c9402bc65c0f40daa6673d6f447700f4`; ADR 0004 itself is unchanged +in this correction. No live qualification, workflow dispatch, GitHub review of +the next head, or hosted quick check is claimed. + +### Issue #79 follow-up self-review: Git diff output + +During the exact-head review wait for `b3c335bd40def3ff75ef926387d82ac68d955103`, +local source review found that the existing `git_diff_path_violation` accepted +`git diff --output=AGENTS.md HEAD^ HEAD` and the separated option form because +`AGENTS.md` was a reviewed *input* path. Both inert scanner cases failed the +new `test_git_diff_output_cannot_replace_reviewed_source` before correction; +no Git diff output command was executed. The minimal correction rejects all +`git diff --output` forms before any destination path check. Plain `git diff` +remains accepted, `git show --output` remains rejected, and `git log` remains +outside the approved read-only subcommands. The focused test passed after the +correction. The full offline harness then passed 96 tests in 133.122s with +331 shell commands, 95 Python heredoc bodies and zero violations. The later +extra `git log` and separated `git show` negative controls passed in a focused +rerun; full post-ledger packet verification and exact-next-head hosted/Codex +checks remain to be recorded. Rollback is limited to the packet and offline +harness at the reviewed input `b3c335bd40def3ff75ef926387d82ac68d955103`. + +### Issue #79 PR #103 exact-head review 5341817408 correction + +The [Codex review](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5341817408) +covered input `b3c335bd40def3ff75ef926387d82ac68d955103`. Its body had no +finding; all three inline P1 findings were reproduced using inert AST/heredoc +test data and are blocking until a new exact-head review completes. The +issue-comment feed contained only prior `@codex review` requests. + +| P1 finding | RED and correction | +|---|---| +| [Bare top-level expression](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4124733424) | `_validated_scanner_statements` accepted an unsupported expression statement while `_scanner_namespace` silently skipped it. The loader now extracts only the scanner definitions, rejects every unsupported top-level statement and also fails closed at execution. `print(subprocess.os.environ)` is additionally rejected by the heredoc scanner's re-export rule below. | +| [Re-exported OS module](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4124733434) | `subprocess.os.remove` passed despite being a filesystem mutation. Direct, import-alias, from-import and simple assigned-alias forms all failed the new negative test before correction. Access to `subprocess`'s OS re-export and dynamic module lookup now fails closed before ordinary call classification; normal reviewed `subprocess` use remains accepted. | +| [Launcher-returning decorator](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4124733438) | A decorator returning `subprocess.run` could replace a benign function and launch an unreviewed command. The scanner now rejects unreviewed decorators, retaining only the packet's exact synthetic `GoAliasPopen.returncode` property body when `property` is not shadowed. Ordinary undecorated functions remain accepted. | + +The three focused RED methods had six failing unsafe assertions. Four focused +methods, including the prior `git diff --output` regression, passed after the +minimal correction in 0.103s. A packet-only static scan then passed in +62.147s: 331 shell commands, 95 Python heredoc bodies, zero violations. +No real mutator, launcher, private path access, runner or workflow was invoked. +The first full harness run after that correction ran 99 tests in 136.213s but +failed two safe positive controls: built-in `@staticmethod` and its single +reviewed alias. The decorator rule was narrowed to preserve those exact +builtin forms only when the builtin or alias is not shadowed; the focused +unsafe and safe cases then passed. A further inert self-review found that +tuple-destructured and list-indexed `subprocess` aliases still reached `.os`; +both new negative cases failed before the broader fail-closed `.os` access +check, then passed with the existing import, direct-alias, dynamic-access and +safe `subprocess` controls. The final full harness after these corrections +passed 99 tests in 138.127s with 331 shell commands, 95 Python heredoc +bodies and zero violations. `git diff --check` passed and an added-line scan +for credential tokens, private-key markers and personal paths found zero +matches. The final post-ledger packet scan, independent delta review, next +hosted quick check and exact-head Codex review remain separate gates. Rollback +restores only this packet and its offline harness from the input SHA above. + +### Issue #79 independent security delta review after 5341817408 + +A read-only Codex agent launched as `gpt-6-luna` with `max` reasoning reviewed +the uncommitted two-file correction. Its first focused `git diff --output` +pass reported no finding. Its second security pass identified two P1 bypasses +and two P2 over-rejections. No independent agent edited, pushed, ran a live +command or represented the packet tests as independently executed. + +| Finding | Triage and result | +|---|---| +| P1: `lookup = getattr; lookup(subprocess, "os").remove(...)` | RED reproduced with an inert path; an analogous `vars` alias also failed. The re-export guard now resolves reviewed `getattr`/`vars` aliases before accepting a heredoc. The same test retains direct, imported, assigned, destructured and indexed aliases. | +| P1: mutate `__builtins__.property` or `__builtins__["property"]` before the reviewed property decorator | RED reproduced both assignment forms and `setattr`. Executable heredocs now reject direct `__builtins__` access and importing the mutable `builtins` module, so the property and staticmethod exceptions cannot be replaced through those namespace handles. | +| P2: unrelated `Settings.os` access was rejected | Reproduced as a safe positive control, then corrected: the conservative `.os` member guard applies only when `subprocess` is imported into that heredoc. `Settings.os` without such an import remains accepted. | +| P2: an unrelated function parameter named `property` over-shadows the reviewed property exception in whole-tree analysis | Classified as a conservative false positive, not a release/security/data-loss/live blocker and not a current packet or evidence-reuse path. No fix or follow-up issue is warranted solely for this routine hypothetical safe case; the guard deliberately remains fail-closed. | + +The two P1 regression methods failed with five unsafe subcase assertions before +correction. Their GREEN rerun, plus the existing staticmethod safe control, +passed three focused methods in 0.105s. The full offline harness then passed +99 tests in 137.470s, scanning 331 shell commands and 95 Python heredoc bodies +with zero violations. Final independent delta sign-off, the post-ledger +packet-only scan, hosted quick check and exact-next-head Codex review remain +pending; none is claimed here. Rollback remains the two changed files to +`b3c335bd40def3ff75ef926387d82ac68d955103`. + +### Issue #79 independent re-review of `6f3f8c8` correction + +The pushed input head `6f3f8c8b5427222232104304d7c7ba0c41e2187f` +passed its [hosted PR quick check](https://github.com/1XP-AI/gh-runnerd/actions/runs/36457680826/job/109048081622), +but independent read-only GPT-6-Luna/max re-review identified two remaining +P1 scanner bypasses. The GitHub Codex review requested for that head had not +completed at this local correction checkpoint; the passing quick check is not +merge authorization. + +| P1 | RED and correction | +|---|---| +| A destructured `subprocess` alias plus aliased `getattr` and a local dynamic member key could reach `subprocess.os.system`. | The inert `other, = (subprocess,)`, `lookup = getattr`, `member = "os"` witness was accepted before the fix. Subprocess-origin propagation now covers simple, unpacked, indexed and conditional bindings, so the existing fail-closed re-export check applies to the resolved first argument even with a dynamic key. | +| `globals()["__builtins__"]` could mutate `property` and replace the only approved property decorator with a command launcher. | The inert global-mapping witness was accepted before the fix. Executable heredocs now reject mutable global namespace access through `globals()` and its reviewed aliases. Three adjacent acquisition routes—module-scope `locals()`, no-argument `vars()` and `sys.modules["builtins"]`—were also reproduced as failing negative cases and rejected. Ordinary reviewed staticmethod and property controls remain accepted. | + +The two original focused negative methods failed twice for the two P1 +witnesses. The three adjacent namespace acquisition subcases failed before +their correction. The focused GREEN rerun passed three methods in 0.113s, +including the staticmethod safe control. The full offline harness passed 99 +tests in 136.709s and reported 331 shell commands, 95 Python heredoc bodies +and zero violations. All unsafe commands were AST/scanner text only; no GitHub +workflow, filesystem mutator or mutable builtins code was executed. Post-ledger +packet-only verification, final independent delta review, new hosted quick +check and GitHub Codex exact-head review remain pending. Rollback restores +only the packet and harness from this input head; no live gate is claimed. + +### Issue #79 PR #103 review 5342450001 and final-delta correction + +GitHub Codex [review 5342450001](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5342450001) +covered exact input `6f3f8c8b5427222232104304d7c7ba0c41e2187f`. +Its body had no finding, but two inline P1 findings were reproduced as inert +scanner inputs. The issue-comment feed contained only the review request. +A read-only GPT-6-Luna/max focused delta review of the local correction also +reported three P1 candidates and one P2 conservative rejection; each was +triaged against the same local scanner before this next candidate push. + +| Finding | RED, correction or evidence-based disposition | +|---|---| +| [Output method alias](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4125208502), P1 | `emit = sys.stdout.write; emit(str(os.environ))` and a second alias were accepted before correction. The sensitive sink classifier now follows assignments of reviewed sink methods; literal safe output through the alias remains accepted. | +| [Filesystem mutator in containers](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4125208513), P1 | `actions = {"delete": os.remove}; actions["delete"](...)` and list storage were accepted before correction. A mutator function reference that is stored or passed rather than directly called now fails closed; direct reviewed temporary-owned filesystem calls retain their existing checks. | +| Independent P1: dict-indexed `subprocess` alias with dynamic OS key | The inert dict-index witness was accepted before correction. Subprocess-origin propagation now includes dict values as well as tuple/list/set and indexed forms; the re-export check rejects the resulting dynamic lookup. | +| Independent P1: tuple-unpacked `globals` callable | Rebutted with a focused inert reproduction, not left unresolved: `lookup, = (globals,)` followed by `lookup()` already returns `Python heredoc contains an unresolved command-capable call 'lookup' on line 5`. The negative case was added; no extra implementation change was required. | +| Independent P1: `from sys import modules` | The imported registry alias bypassed the direct `sys.modules` check before correction. Importing `modules` from `sys` now fails closed before decorator classification. | +| Independent P2: a list containing `subprocess` is conservatively treated as a module alias | Triage once as a hypothetical safe false positive, not a current packet path or a release/security/data-loss/live blocker. The fail-closed provenance is retained; no fix or follow-up issue is warranted solely for this routine safe case. | + +The two GitHub P1 test methods failed with four unsafe subcase assertions +before correction. The independent dict and imported-registry witnesses each +failed before correction; the unpacked-global witness was rejected without a +fix. The focused GREEN rerun passed four methods in 0.115s. The full offline +harness then passed **101 tests in 140.534s**, including 331 shell commands, +95 Python heredoc bodies and zero violations. No mutator, output sink, GitHub +workflow or mutable-builtins witness was actually executed. `git diff --check`, +added-line sensitive-pattern scan, post-ledger packet-only scan, final +independent delta sign-off, new hosted quick check and exact-next-head GitHub +Codex review remain separate gates. Rollback is the two-file correction to +input `6f3f8c8b5427222232104304d7c7ba0c41e2187f`. + +### Issue #79 pre-push security delta and packet parity + +The read-only GPT-6-Luna/max follow-up to the GitHub P1 corrections identified +four further concrete P1 scanner bypasses. Each witness was an inert heredoc +string, failed a focused regression assertion before correction, and was never +executed as a command or filesystem operation: + +| P1 witness | Correction and nearby control | +|---|---| +| `holder.module = subprocess; other = holder.module; lookup(other, member).system(...)` | Storing a subprocess-origin module in an attribute or subscript now fails closed. Direct reviewed subprocess calls remain accepted. | +| `lookup = getattr; emit = lookup(sys.stdout, "write"); emit(str(os.environ))` | Output sink method aliases now resolve an assigned `getattr` alias. Storing an output method indirectly in a container also failed a separate inert self-review assertion and is rejected; literal output through a direct alias remains accepted. | +| `from os import remove as erase; actions = {"delete": erase}` and `import os as operating` before mutator storage | Imported filesystem mutators, star imports from filesystem-capable modules, and aliases of `os`/`shutil` modules fail closed; ordinary reviewed `import os` remains accepted. | +| `from sys import *; modules["builtins"]` | Star import from `sys` is rejected before access to its mutable module registry. | + +The independent reviewer confirmed those four corrections at source level and +found no new P0/P1 in that delta; it did not claim to execute tests. A later +packet-only run found two conservative false positives: a synthetic namespace +dictionary containing `subprocess` tainted an unrelated key, and the new +helper's `matches` local collided with an existing nested function name in +the packet's self-inspection. A safe unrelated-key control failed before +key-aware literal-dictionary lookup; the unsafe module-key and dictionary +update controls remain rejected. Renaming the new local binding removed the +helper collision. The packet-only scan then passed in 69.001s: 331 shell +commands, 95 Python heredoc bodies, zero violations. The full offline +harness passed **101 tests in 139.560s** with the same counts. These checks +are offline only, not live runner qualification. Final independent review of +the key-aware delta, post-ledger packet-only scan, hosted quick check and +GitHub Codex review of the next exact head remain separate gates. Rollback +restores only this packet and offline harness from the pushed input +`6f3f8c8b5427222232104304d7c7ba0c41e2187f`. + +The final key-aware-dictionary review did not sign off its first candidate: +the reviewer reproduced a P1 `namespace.update({"safe": subprocess})` +mutation followed by an aliased `getattr` with a dynamic `member` key. The +earlier literal-`"os"` negative assertion had been rejected by a different +guard and did not establish origin tracking. The corrected dynamic-key +assertion failed before indirect container mutators were rejected. A separate +conditional reassignment witness failed before multiply-bound names stopped +using the initial literal dictionary as their sole source. Subsequent inert +tuple-unpack and loop-target reassignment witnesses exposed the same missing +Store-binding count; counting all AST Store names closed both. Finally, the +reviewer found that `update = namespace.update` could evade a direct-call-only +mutator guard. Its exact dynamic-key witness failed before rejecting the +method reference itself, then passed. The focused subprocess re-export test +passed after each correction. All named commands and paths in these witnesses +were inert scanner strings. The **101-test, 139.021s** full offline run and +the **64.987s** packet-only scan (331/95/zero) preceded the last loop-target +and stored-method corrections and are not claimed for that final candidate. +Final independent source review, full suite, post-ledger packet scan, hosted +quick check and exact-next-head GitHub Codex review remain pending. + +Another independent source pass verified the loop-target and stored-method +guards but found a type-level P1 mutation route: `dict.update(namespace, +{"safe": subprocess})` could change a subprocess-bearing namespace without +touching the guarded `namespace.update` attribute. Its dynamic-key witness +failed before correction. Access to unreviewed dictionary mutator descriptors +is now rejected; inert `mapping_type = dict; mapping_type.update(...)` and +`getattr(dict, "update")(...)` variants also failed before that closure and +passed after it. The preceding full harness run passed **101 tests in +141.730s**, scanning 331 shell commands and 95 Python heredocs with zero +violations, but preceded this latest descriptor correction. The final full +suite, post-ledger packet-only scan, independent source sign-off, hosted PR +quick check and exact-head GitHub Codex review are still required before +merge. No witness was executed; no live operation was authorized or run. + +The next read-only pass found one additional runtime-type P1 route: +`type(namespace).update(namespace, ...)` obtained the same mutator despite +the direct `dict` descriptor guard. Its inert dynamic-key assertion failed +before correction. Rejecting runtime type acquisition from a +subprocess-bearing name closed it; the adjacent `namespace.__class__` route +also failed before correction and passed afterward. The immediately preceding +**101-test, 140.270s** full run and **65.995s** packet-only scan each reported +331 shell commands, 95 Python heredocs and zero violations but predated these +two final runtime-type guards. Their focused negative test passed after the +correction; a fresh full run, packet-only scan and independent sign-off are +still required. No actual mutator or command witness was executed. + +The next independent source pass found that projecting the same container +through `[namespace][0]` still escaped the direct-name `type`/`__class__` +guards. The exact `type([namespace][0]).update(...)` witness failed before +the existing subprocess-origin expression resolver was applied to these +operands, then passed. The adjacent projected `.__class__` and +`getattr([namespace][0], "__class__")` witnesses also failed before their +corrections and passed afterward. A full suite run immediately before this +projected-receiver correction passed **101 tests in 140.270s**, with 331 +shell commands, 95 Python heredocs and zero violations, but is not claimed +for the corrected head. The final full suite, post-ledger packet scan, +independent source review, hosted quick check and exact-head GitHub Codex +review remain pending; all dangerous witnesses remained inert strings. + +The subsequent independent pass found a copied-container P1: +`namespace.copy().__class__.update(namespace, ...)` could obtain the same +dictionary mutator because the provenance helper did not follow call results. +Its inert dynamic-key witness failed before correction. Subprocess-bearing +container provenance now follows `copy()`, direct built-in container +constructors and value-preserving binary/Boolean compositions before the +existing type/class/access guards; the focused witness passed afterward. +The preceding full harness run passed **101 tests in 141.708s** (331 shell +commands, 95 Python heredocs, zero violations), but preceded this copy +correction. No live or unsafe witness ran. A fresh full suite, post-ledger +packet scan, independent delta sign-off, hosted quick check and exact-head +GitHub Codex review remain pending. + +The next read-only reviewer pass reproduced a P1 descriptor-table route: +`update = dict.__dict__["update"]` followed by a dynamic-key command witness +was accepted. The inert assertion failed before dictionary-type `__dict__` +access was rejected, then passed. `vars(dict)["update"]` failed an adjacent +assertion before the same fail-closed descriptor acquisition check and passed +afterward. The preceding full harness passed **101 tests in 145.723s** and +reported 331 shell commands, 95 Python heredocs and zero violations, but +predated these two corrections. No descriptor or command was actually +executed. A fresh full harness, post-ledger packet scan, independent delta +review, hosted quick check and exact-head GitHub Codex review remain pending. + +The next independent pass identified `dict.__mro__[0].update(...)` as a P1 +route around the enumerated dictionary mutator names. Its inert dynamic-key +witness failed before correction. Dictionary-type attribute access through +`dict` or a tracked alias now fails closed in a subprocess-bearing heredoc, +rather than attempting to enumerate mutator/introspection member names; the +focused negative and unrelated-key safe controls passed after correction. +The immediately preceding full harness and packet scan were run before this +change, so no final whole-packet claim is made yet. No witness or live runner +operation executed. Full offline verification, independent source sign-off, +hosted quick check and exact-head GitHub Codex review remain pending. + +The next independent pass found `type({}).update(namespace, ...)` could +reacquire the same descriptor without mentioning a subprocess-bearing +receiver. This dynamic-key P1 witness and the adjacent +`{}.__class__.update(...)` witness each failed before correction and passed +afterward. The packet scanner's three one-argument `type` checks were +replaced with equivalent `isinstance`/Boolean-exclusion or AST-node checks; +one-argument runtime type queries and `.__class__` access now fail closed in +subprocess-bearing heredocs. The reviewed three-argument synthetic `FakeOS` +class construction remains allowed. The prior full offline harness passed +**101 tests in 139.576s**, with 331 shell commands, 95 Python heredocs and +zero violations, but preceded these guards. Final full and post-ledger +packet-only checks, independent source sign-off, hosted quick check and +exact-head GitHub Codex review remain pending; no witness executed. + +The next independent review found the remaining three-argument `type()` +surface could create a `dict` subclass and call its unbound mutator: +`type("D", (dict,), {}).update(namespace, ...)`. Its inert dynamic-key witness +failed before correction. Runtime type construction is now restricted to +the packet's direct, exact-shape synthetic `FakeOS` class: empty bases and +one `environ` dictionary containing string-only values. The focused unsafe +and safe controls passed afterward. The preceding full harness passed **101 +tests in 142.978s** (331 shell commands, 95 Python heredocs, zero violations) +but preceded this final restriction. No dangerous witness ran. Full offline +verification, independent sign-off, post-ledger packet scan, hosted quick +check and exact-head GitHub Codex review remain pending. + +### Issue #79 post-push independent review of `31c2e60` + +The pushed head `31c2e6018b39b9ae8b6ae57fb7b54c17e41bf7b5` passed its +[hosted Go quick check](https://github.com/1XP-AI/gh-runnerd/actions/runs/36469243663/job/109087027280). +An exact-head GitHub Codex review was requested in +[comment 5876553434](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-5876553434) +and remains pending at this local correction checkpoint. A read-only +GPT-6-Luna/max independent source pass found two more concrete P1 routes: + +| P1 | RED and local correction | +|---|---| +| `type.__new__(type, "D", (dict,), {})` builds a dict subclass outside the `type()` call guard. | The inert dynamic-key mutation witness failed before correction. Any metatype attribute access, including `__new__`, now fails closed. The focused negative case passed after correction. | +| A local function named `type` returns `dict`, while the exact-shape `type("FakeOS", (), {"environ": ...})` exception trusts only spelling. | The inert shadowed-name witness failed before correction. Function, class, assignment, argument and import-alias bindings of `type` now fail closed before the exception. The focused negative and reviewed `FakeOS` positive controls passed afterward. | + +Adjacent `getattr(type, "__new__")` and `vars(type)["__new__"]` negative +controls were already rejected by other scanner checks; no implementation +change was attributed solely to those cases. All witnesses remained AST text; +no metatype construction, filesystem mutator, workflow or live runner ran. +The independent follow-up, full offline suite, post-ledger packet scan and a +fresh exact-head hosted/Codex review after any push remain pending. Rollback +is the two-file local correction against the pushed input SHA above. + +The next independent source pass found one more P1 constructor route: +`class D(dict): pass` followed by `D.update(namespace, ...)` mutated the +subprocess-bearing dictionary without a `type()` call. Its inert dynamic-key +witness failed before correction. Executable heredocs now reject class bases +and metaclasses except literal `Exception`/`ValueError` bases required by the +reviewed synthetic stop-at-child cases. The focused unsafe and +`StopAtChild(Exception)` safe controls passed after correction. The full +offline harness had passed **101 tests in 145.802s** and the separate +packet-only scan passed in **66.677s**, each finding 331 shell commands, +95 Python heredocs and zero violations, but both predated this class-base +guard. No witness ran. Fresh full/packet checks, independent sign-off and +exact-next-head hosted/Codex review remain pending. + +Local adjacent self-review found `Exception = dict; class D(Exception)` could +shadow the newly allowed exception base and recover `D.update`. Its inert +dynamic-key assertion failed before the correction. Bindings of the reviewed +`Exception`/`ValueError` names now fail closed before class-base acceptance; +the focused unsafe case and ordinary `StopAtChild(Exception)` positive case +passed. The earlier full-suite run was interrupted after this code changed +and is not counted as a passing verification. A clean full and packet-only +rerun, independent source conclusion, hosted quick check and exact-next-head +Codex review remain pending. + +### Issue #79 PR #103 review 5343447750 correction + +The [exact-head Codex review](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5343447750) +covered pushed input `31c2e6018b39b9ae8b6ae57fb7b54c17e41bf7b5`. +Its body had no substantive finding, but five inline P1 findings each failed +an inert focused regression before correction. The issue-comment feed after +the review request contained only that request; no bot issue-comment finding +was present. The hosted quick check on this input passed, but neither it nor +this review authorizes merging the now-unreviewed local correction. + +| Finding | RED and local correction | +|---|---| +| [OS-module assignment alias](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126070534) | `alias = os; alias.remove(...)` was accepted. The OS-call classifier now propagates direct module aliases and rejects mutators through them. Adjacent `alias.environ` and `alias.getenv(...)` output witnesses also failed before a conservative alias-environment guard, then passed. Literal safe output after an OS alias remains accepted. | +| [Imported environment mapping alias](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126070549) | `from os import environ as inherited; print(inherited)` was accepted. Imported `environ` names now seed sensitive-value provenance; literal safe output remains accepted. | +| [Bash prompt expansion](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126070559) | A `printf -v`-assembled credential reference followed by `${payload@P}` was accepted. The Bash prompt-expansion transform is now rejected before ordinary parameter-name taint analysis; literal output remains accepted. | +| [Unreviewed relative shell reader](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126070568) | `awk '{print}' maintainer.pem` was accepted. Reader paths now require reviewed prefixes or packet-owned paths; the operand scanner distinguishes AWK/JQ/grep/rg expressions and shell redirections from file operands. The reviewed `docs/EXECUTION.md` reader remains accepted. | +| [Sensitive exception arguments](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126070575) | `raise RuntimeError(os.environ)` was accepted. Extracted constructor arguments now receive sensitive-value analysis before the exception is accepted; a literal reviewed exception remains accepted. | + +The independent read-only GPT-6-Luna/max class-base review also found +`sys._getframe().f_globals["Exception"] = dict` could change an allowed +exception base without an AST Store binding. That inert dynamic-key witness +failed before the frame-namespace guard and passed after it. `sys._getframe`, +`sys._current_frames` and their from-import forms now fail closed alongside +the existing `sys.modules` rule. All six focused methods passed in 0.147s; +unsafe strings were never executed. A packet-only scan after the reader +operand adjustment passed in 67.422s: 331 shell commands, 95 Python +heredocs, zero violations. Full post-ledger offline verification, final +independent delta sign-off, a new hosted quick check and a fresh exact-head +GitHub Codex review remain pending. Rollback is limited to the packet and +offline harness against the pushed input SHA above; no live gate is claimed. + +### Issue #79 PR #103 review 5343672196 correction + +The [exact-head Codex review](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5343672196) +covered pushed input `b902f7df1d910a3fa836e60f983d65f15e7f613d`. +Its body contained no substantive finding, but three inline P1 findings +were reproduced as failing, inert source-only regressions. The issue-comment +feed after the review request contained only that request. + +| Finding | RED and local correction | +|---|---| +| [OS module dictionary](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126263858) | `vars(os)["environ"]` and `os.__dict__["environ"]` were accepted. The OS-module classifier now fails closed on module-dictionary access, including assigned OS aliases; a literal safe output remains accepted. | +| [Assigned shutil alias](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126263874) | `alias = shutil; alias.rmtree(...)` was accepted. The mutator classifier now follows assigned `shutil` module names and rejects aliased mutator access; a literal safe output remains accepted. | +| [AWK ARGV rewrite](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126263883) | An AWK program rewriting `ARGV[1]` to an unreviewed credential path was accepted despite a reviewed visible operand. AWK programs mentioning `ARGV` now fail closed; the literal reviewed print program remains accepted. | + +Local adjacent self-review also found `raise RuntimeError("reviewed") from +RuntimeError(os.environ)` bypassed exception-argument taint. The inert +regression failed before correction; both `Raise.exc` and `Raise.cause` are +now examined, and a literal exception remains accepted. All four focused +methods passed in 0.111s after correction. No witness was executed. Full +offline verification, a post-ledger packet scan, independent review and a +fresh exact-head hosted/Codex review remain pending; no live gate is claimed. + +After this ledger addition, the full isolated offline harness passed **109 +tests in 147.009s**, including the current packet static scan of 331 shell +commands and 95 Python heredoc bodies with zero violations. `git diff +--check` passed; an added-line scan for credential/private-key/personal-path +patterns found no matches. Independent source review and a fresh exact-head +hosted/Codex review remain pending. These offline checks do not authorize a +live runner or workflow operation. + +### Issue #79 adjacent assigned-module alias correction + +Local source review after `9a31f9942dcfa29b3072c29599b31fb1618b2549` +found that `alias = sys; alias._getframe()` bypassed the frame-namespace +guard. An inert focused regression failed before correction, then passed. +Adjacent positional unpacking witnesses `alias, = (sys,)` and `alias, = +(shutil,)` also failed before correction and passed afterward. The shared +module-name fixed point now follows direct and positionally matched tuple/list +assignments for `os`, `shutil`, and `sys`; literal output controls remain +accepted. No specimen was executed. Full offline verification, independent +classification, hosted quick check and exact-head Codex review are required +after the next push; this local delta is not merge-reviewed. + +The GPT-6-Luna/max read-only follow-up on `9a31f99` classified five adjacent +P1s. The `sys` assignment alias and annotated `os`/`shutil` assignments are +covered by the shared fixed point above. Three further inert regressions were +RED before correction and GREEN afterward: + +| Independent finding | Local resolution | +|---|---| +| `grep -e . -- -maintainer.pem` skipped a dash-prefixed file after `--`; `rg --hidden --no-ignore .` searched the current directory implicitly. | Reader parsing now switches to operand mode after `--`, consumes `-e`/`--regexp` pattern arguments, and requires an explicit reviewed path for `rg`. A reviewed `rg . docs/EXECUTION.md` control remains accepted. | +| `lookup = getattr; print(lookup(os, "environ"))` exposed the inherited environment. | The OS-module guard fails closed on direct or assigned `getattr`/`vars` calls whose first argument is an imported/assigned OS module. Literal safe output remains accepted. | +| `cause = RuntimeError(os.environ); raise RuntimeError("reviewed") from cause` hid exception data in a local assignment. | Exception-argument inspection now follows local assignment sources for raised exception and cause names; literal exceptions remain accepted. | + +The focused three-method run passed in 0.106s. None of the witnesses was +executed. Packet/full verification, the next hosted quick check and exact-head +GitHub Codex review remain required before merge. + +### Issue #79 PR #103 review 5343868697 correction + +The [exact-head Codex review](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5343868697) +covered pushed input `9a31f9942dcfa29b3072c29599b31fb1618b2549`. +Its body had no substantive finding; four inline P1s were each reproduced as +failing inert scanner regressions before correction and passed after. No bot +issue-comment finding accompanied the review. + +| Finding | RED and local correction | +|---|---| +| [Assertion message disclosure](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126424411) | `assert False, os.environ` was accepted. Assertion messages now receive sensitive-value analysis; a literal assertion remains accepted. | +| [Unreviewed shutil entry point](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126424421) | `shutil._rmtree_unsafe(...)` was accepted. Attributes of imported/assigned `shutil` now fail closed unless they are reviewed mutators handled by the owned-path policy or the packet's reviewed `which` call; literal safe output remains accepted. | +| [Aliased process signals](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126424430) | `from os import getppid, kill; kill(getppid(), 9)` and `send = os.kill; send(1, 9)` were accepted. The signal classifier now resolves imported, module-assigned and callable-assigned names before applying owned-target review; literal safe output remains accepted. | +| [jq module search](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4126424441) | `jq -n -L/tmp 'include "evil"; leak'` and direct `include` were accepted. jq module search options and external module-loading filter tokens now fail closed; a literal-only filter remains accepted. | + +All four focused methods passed in 0.105s after correction. No witness was +executed. Full offline suite, post-ledger packet scan, independent delta +classification, hosted quick check and exact-next-head Codex review remain +pending; no live operation is authorized by these checks. + +After this ledger entry, the full isolated offline harness passed **116 tests +in 144.328s**. Its packet static scan examined 331 shell commands and 95 +Python heredocs with zero violations. The earlier packet-only scan found one +overbroad `rg` rule against a reviewed here-string input; the rule was +corrected to distinguish explicit stdin from implicit current-directory +search, and the full scan above passed. `git diff --check` passed and the +added-line credential/private-key/personal-path pattern scan found no +matches. A separate post-ledger packet scan passed in 71.363s, again finding +331 shell commands, 95 Python heredocs and zero violations. Final independent +review and fresh exact-head hosted/Codex review remain pending. + +### Issue #79 independent follow-up on `86d90df` + +A read-only GPT-6-Luna/max pass over the preceding local delta found four +further P1 source routes. Each inert witness failed a focused regression +before correction and passed afterward; no witness was executed: + +| Finding | Local resolution | +|---|---| +| `rg --hidden --no-ignore . .` could scan the repository root despite the explicit-path rule. | `rg` reader operands `.` and `./` now fail closed; the reviewed `docs/EXECUTION.md` input remains accepted. | +| `lookup = getattr; lookup([os][0], "environ")` bypassed the direct-name OS module guard. | Dynamic `getattr`/`vars` access fails closed when the receiver expression contains an imported/assigned OS module name; literal safe output remains accepted. | +| `cause = RuntimeError(RuntimeError(os.environ))` hid a credential mapping one constructor level deeper. | Raised exception and cause argument inspection now recursively expands nested calls and local assignment sources; literal exception remains accepted. | +| `for alias in (os,): alias.remove(...)` bypassed assignment-only module alias propagation. | The shared module-name fixed point now follows literal tuple/list loop and comprehension elements; literal output controls remain accepted. | + +The focused four-method run passed in 0.115s. Full offline verification, +packet-only recheck, hosted quick check and exact-head GitHub Codex review +remain required after the next push; the current local correction is not +merge-reviewed. + +The first full-suite run after this correction **failed** four existing +canonical-package positive controls: recursively resolving every name inside +exception constructor arguments conflated unrelated same-spelled variables +across scopes and falsely marked a reviewed `TimeoutExpired` as credential +output. It is not counted as passing verification. Name-source expansion is +now limited to the raised exception or cause alias; nested constructor calls +are still recursively inspected, while ordinary constructor argument names +use the existing sensitive-name analysis. The canonical package control and +the nested credential-cause regression both passed together in 23.040s. +Full-suite and post-ledger packet verification remain pending. + +The clean full isolated rerun subsequently passed **116 tests in 163.401s**, +including a current-packet static scan of 331 shell commands and 95 Python +heredocs with zero violations. `git diff --check` passed; the added-line +credential/private-key/personal-path pattern scan found no matches. A +separate post-ledger packet scan passed in 73.718s (331 shell commands, 95 +Python heredocs, zero violations). Fresh exact-head hosted/Codex review +remains pending. No live or trusted runner test was performed. + +### Issue #79 independent follow-up on `f24c73a` + +The read-only GPT-6-Luna/max follow-up confirmed the four preceding forms +closed and identified three adjacent P1s. Each new inert witness failed before +correction and passed afterward: + +| Finding | Local resolution | +|---|---| +| `grep -R . .` recursively read the repository root. | Recursive grep now rejects `.`/`./` reader operands, as `rg` does. The reviewed `grep -R . docs/` control remains accepted. The suggested `docs` spelling was not an accepted path under the existing prefix rule, so the safe control uses the approved `docs/` spelling. | +| `inner = RuntimeError(os.environ); cause = RuntimeError(inner); raise ... from cause` hid a credential mapping in an intermediate alias. | Exception-object provenance now follows same-scope exception constructor/name assignments through nested arguments. The first broad attempt falsely classified the packet's reviewed `TimeoutExpired` path; limiting source expansion to exception constructors/names restored the canonical positive control while retaining the unsafe witness. | +| `for alias in tuple([os]): alias.remove(...)` hid an OS module alias in a literal container constructor. | Module-alias propagation unwraps static tuple/list/set constructors around literal iterables before binding loop/comprehension targets; literal safe output remains accepted. | + +The focused three methods and canonical package guard passed together in +22.648s after correction. The intermediate focused run failed the canonical +control and a `docs` reader spelling; neither is counted as passing evidence. +No specimen was executed. Full-suite and post-ledger packet verification, +hosted quick check and exact-head GitHub Codex review remain pending. + +The clean isolated full rerun passed **116 tests in 160.633s**, including +331 shell commands and 95 Python heredocs with zero current-packet +violations. `git diff --check` passed; the added-line credential/private-key/ +personal-path pattern scan found no matches. A separate post-ledger packet +scan passed in 74.063s (331 shell commands, 95 Python heredocs, zero +violations). Fresh exact-head hosted/Codex review remains pending. No live +or trusted runner test ran. + +### Issue #79 independent follow-up on `cf4ae9e` + +The read-only GPT-6-Luna/max reviewer confirmed the three preceding direct +witnesses closed, then found three adjacent P1 paths. The `grep` option route +was also independently reproduced locally. All three inert regressions were +RED before correction and GREEN afterward: + +| Finding | Local resolution | +|---|---| +| GNU grep `-d recurse`/`--directories=recurse` could recursively scan `.`. | Recursive option detection now includes both forms and consumes the `-d` option argument before reader operands. `grep -d recurse . docs/` remains accepted. | +| `StopIteration(os.environ)` could hide a sensitive intermediate exception alias because its name lacks the old suffixes. | Exception-constructor provenance includes the reviewed built-in non-suffix exception names alongside `Error`/`Exception`/`Exit` forms; literal exception and canonical package controls remain accepted. | +| `for alias in iter([os]): alias.remove(...)` bypassed static container unwrapping. | Module-alias propagation unwraps `iter` and other static one-argument container-preserving builtins around literal iterables; literal output remains accepted. | + +The three focused methods and canonical package control passed together in +23.176s after correction. No witness was executed. Full offline verification, +post-ledger packet scan, independent delta classification and a fresh exact- +head hosted/Codex review remain pending. + +The clean isolated full rerun passed **116 tests in 160.080s**, including a +current-packet scan of 331 shell commands and 95 Python heredocs with zero +violations. `git diff --check` passed and the added-line credential/private- +key/personal-path pattern scan found no matches. Separate post-ledger packet +verification passed in 72.966s (331 shell commands, 95 Python heredocs, +zero violations). Fresh exact-head hosted/Codex review remains pending; no +live or trusted runner test ran. + +### Issue #79 independent follow-up on `415e551` + +The read-only GPT-6-Luna/max reviewer confirmed the three direct prior +witnesses closed, then found three adjacent P1s. The `UserWarning` route was +also reproduced and corrected during local self-review before the report +arrived. All three inert source witnesses were RED before correction and +GREEN afterward: + +| Finding | Local resolution | +|---|---| +| GNU grep's unique long-option abbreviation `--direct=recurse` could recursively read `.`. | Recursive option detection recognizes the `--dir...=recurse` family and separate `--direct recurse` argument; the reader parser consumes the option value before path inspection. Approved `grep -d recurse . docs/` remains accepted. | +| `UserWarning(os.environ)` and a local `class Halt(Exception)` could hide sensitive data in an intermediate cause alias. | Exception-object provenance includes warning names and local classes with reviewed exception bases, in addition to built-in non-suffix names. Literal exceptions and the canonical package guard remain accepted. | +| `for alias in filter(None, [os]): alias.remove(...)` escaped literal iterable unwrapping. | Module-alias propagation unwraps this statically visible filter form; literal output after an OS alias remains accepted. | + +The three focused methods and canonical package guard passed together in +24.411s. No specimen was executed. Full offline suite, post-ledger packet +scan, independent delta classification and a fresh exact-head hosted/Codex +review remain pending. + +The clean isolated full rerun passed **116 tests in 160.740s**, including a +current-packet scan of 331 shell commands and 95 Python heredocs with zero +violations. `git diff --check` passed; the added-line credential/private-key/ +personal-path pattern scan found no matches. Separate post-ledger packet +verification passed in 72.672s (331 shell commands, 95 Python heredocs, +zero violations). Fresh exact-head hosted/Codex review remains pending; no +trusted or live runner test ran. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py new file mode 100644 index 00000000..e33d580d --- /dev/null +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -0,0 +1,3755 @@ +"""Offline, non-executing regression probes for issue #79 review findings. + +Python examples and shell commands supplied to the packet scanner remain data: +the harness parses/inspects them but never evaluates or launches them. Child +processes are limited to literal Git commands against temporary local +repositories and an isolated ``sys.executable -I -B -c`` probe that verifies a +synthetic local module cannot shadow a standard-library import. +""" + +from __future__ import annotations + +import ast +import builtins +import os +import re +import shlex +import selectors +import signal +import subprocess +import sys +import tempfile +import time +import types +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +PACKET_PATH = ROOT / "docs" / "evidence" / "g01-recovery-packet.md" +PACKET_TEXT = PACKET_PATH.read_text(encoding="utf-8") + + +def _target_names(target: ast.expr) -> set[str]: + if isinstance(target, ast.Name): + return {target.id} + if isinstance(target, (ast.Tuple, ast.List)): + names: set[str] = set() + for item in target.elts: + names.update(_target_names(item)) + return names + return set() + + +def _safe_assignment_expression( + node: ast.AST, namespace: dict[str, object], local_names: set[str] | None = None +) -> bool: + """Allow only literal/pure constants needed to load scanner definitions.""" + if local_names is None: + local_names = set() + if isinstance(node, ast.Constant): + return True + if isinstance(node, ast.Name): + return node.id in namespace or node.id in local_names or node.id == "set" + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + return all(_safe_assignment_expression(item, namespace, local_names) for item in node.elts) + if isinstance(node, ast.Dict): + return all( + (key is None or _safe_assignment_expression(key, namespace, local_names)) + and _safe_assignment_expression(value, namespace, local_names) + for key, value in zip(node.keys, node.values) + ) + if isinstance(node, ast.Starred): + return _safe_assignment_expression(node.value, namespace, local_names) + if isinstance(node, ast.Attribute): + if isinstance(node.value, ast.Name) and node.value.id == "re": + return hasattr(re, node.attr) + return isinstance(node.value, ast.Name) and node.value.id in local_names and node.attr in { + "rsplit" + } + if isinstance(node, ast.Subscript): + return _safe_assignment_expression(node.value, namespace, local_names) and _safe_assignment_expression( + node.slice, namespace, local_names + ) + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id == "set": + return not node.args and not node.keywords + if ( + isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "re" + and node.func.attr == "compile" + ): + return bool(node.args) and all( + isinstance(argument, ast.Constant) for argument in node.args + ) and not node.keywords + if ( + isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id in local_names + and node.func.attr == "rsplit" + ): + return all( + isinstance(argument, ast.Constant) for argument in node.args + ) and not node.keywords + return False + if isinstance(node, (ast.SetComp, ast.ListComp, ast.GeneratorExp)): + scoped_names = set(local_names) + for generator in node.generators: + scoped_names.update(_target_names(generator.target)) + if not _safe_assignment_expression(generator.iter, namespace, scoped_names): + return False + if not all( + _safe_assignment_expression(condition, namespace, scoped_names) + for condition in generator.ifs + ): + return False + return _safe_assignment_expression(node.elt, namespace, scoped_names) + if isinstance(node, (ast.UnaryOp, ast.BinOp, ast.BoolOp, ast.Compare, ast.IfExp)): + return all( + _safe_assignment_expression(child, namespace, local_names) + for child in ast.iter_child_nodes(node) + if not isinstance(child, (ast.operator, ast.unaryop, ast.boolop, ast.cmpop, ast.Load)) + ) + if isinstance(node, ast.Load): + return True + return False + + +def _scanner_module_source(packet: str) -> str: + definition = packet.index("def inspect_python_heredoc(body, safe_marker):") + fence_start = packet.rfind("```sh\n", 0, definition) + if fence_start < 0 or not packet[fence_start:].startswith("```sh\nset -euo pipefail"): + raise AssertionError("packet scanner shell fence could not be identified") + fence_end = packet.index("\n```\n", definition) + code_start = packet.index("\nimport ast\n", fence_start, definition) + 1 + terminator = packet.rfind("\nPY", code_start, fence_end) + if terminator < 0: + raise AssertionError("packet scanner Python heredoc terminator is missing") + source = packet[code_start:terminator] + if "def forbidden_command(tokens, depth=0):" not in source: + raise AssertionError("packet scanner functions were not extracted") + verification_start = source.find("\nmatches = []\n", source.index( + "def inspect_python_heredoc(body, safe_marker):" + )) + if verification_start < 0: + raise AssertionError("packet scanner verification boundary is missing") + return source[:verification_start] + + +def _literal_definition_time_expression(node: ast.AST) -> bool: + try: + ast.literal_eval(node) + except (ValueError, TypeError, SyntaxError, RecursionError): + return False + return True + + +def _validated_scanner_statements(module: ast.Module) -> tuple[ast.stmt, ...]: + """Validate packet-controlled imports and definition-time expressions before exec.""" + top_level = {id(statement) for statement in module.body} + allowed_imports = {"ast", "re", "shlex", "subprocess"} + protected_names = set(dir(builtins)) | { + "Path", "ast", "os", "re", "shlex", "source", "subprocess", + "tempfile", "types", "selectors", "signal", "time", "__builtins__", + } + for node in ast.walk(module): + if isinstance(node, ast.Import): + if id(node) not in top_level or any( + alias.name not in allowed_imports or alias.asname is not None + for alias in node.names + ): + raise AssertionError("packet scanner import is not explicitly reviewed") + elif isinstance(node, ast.ImportFrom): + if ( + id(node) not in top_level + or node.level != 0 + or node.module != "pathlib" + or len(node.names) != 1 + or node.names[0].name != "Path" + or node.names[0].asname is not None + ): + raise AssertionError("packet scanner from-import is not explicitly reviewed") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + argument_annotations = [ + argument.annotation + for argument in ( + list(node.args.posonlyargs) + + list(node.args.args) + + list(node.args.kwonlyargs) + ) + ] + if node.args.vararg is not None: + argument_annotations.append(node.args.vararg.annotation) + if node.args.kwarg is not None: + argument_annotations.append(node.args.kwarg.annotation) + if ( + node.decorator_list + or node.returns is not None + or getattr(node, "type_params", ()) + or any(annotation is not None for annotation in argument_annotations) + or not all( + _literal_definition_time_expression(default) + for default in node.args.defaults + ) + or not all( + default is None or _literal_definition_time_expression(default) + for default in node.args.kw_defaults + ) + ): + raise AssertionError( + "packet scanner function has unreviewed definition-time expressions" + ) + if id(node) in top_level and node.name in protected_names: + raise AssertionError("packet scanner function shadows a protected binding") + elif isinstance(node, ast.Lambda) and not all( + _literal_definition_time_expression(default) + for default in node.args.defaults + ): + raise AssertionError("packet scanner lambda has an unreviewed default expression") + elif isinstance(node, (ast.Assign, ast.AnnAssign)) and id(node) in top_level: + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(not isinstance(target, ast.Name) for target in targets): + raise AssertionError("packet scanner assignment target is not a simple name") + bound_names = {target.id for target in targets} + if bound_names & (protected_names - {"source"}): + raise AssertionError("packet scanner assignment shadows a protected binding") + if any(not isinstance(statement, ( + ast.Import, ast.ImportFrom, ast.FunctionDef, ast.AsyncFunctionDef, + ast.Assign, ast.AnnAssign, + )) for statement in module.body): + raise AssertionError("packet scanner has an unsupported top-level statement") + return tuple(module.body) + + +def _scanner_namespace() -> dict[str, object]: + source = _scanner_module_source(PACKET_TEXT) + module = ast.parse(source, filename="") + namespace: dict[str, object] = { + "__builtins__": __builtins__, + "ast": ast, + "os": os, + "re": re, + "shlex": shlex, + "subprocess": subprocess, + "tempfile": tempfile, + "Path": Path, + "types": types, + "source": PACKET_TEXT, + } + validated_statements = _validated_scanner_statements(module) + for statement in validated_statements: + if isinstance(statement, (ast.Import, ast.ImportFrom)): + # Names used by the scanner are preloaded above; packet text never + # gets to select or execute an import during harness setup. + continue + elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + exec(compile(ast.Module(body=[statement], type_ignores=[]), "", "exec"), namespace) + elif isinstance(statement, (ast.Assign, ast.AnnAssign)): + if isinstance(statement, ast.Assign): + names = set().union(*(_target_names(target) for target in statement.targets)) + value = statement.value + else: + names = _target_names(statement.target) + value = statement.value + if "source" in names: + reviewed_source = ast.parse( + 'Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")', + mode="eval", + ).body + if names != {"source"} or value is None or ast.dump( + value, include_attributes=False + ) != ast.dump(reviewed_source, include_attributes=False): + raise AssertionError( + "packet scanner source assignment is not reviewed" + ) + continue + if value is None: + continue + if _safe_assignment_expression(value, namespace): + safe_statement = statement + if isinstance(statement, ast.AnnAssign): + safe_statement = ast.Assign( + targets=[statement.target], + value=statement.value, + ) + ast.copy_location(safe_statement, statement) + exec(compile(ast.Module(body=[safe_statement], type_ignores=[]), "", "exec"), namespace) + else: + raise AssertionError( + "packet scanner has an unsupported top-level assignment" + ) + else: + raise AssertionError("packet scanner has an unsupported top-level statement") + namespace["source"] = PACKET_TEXT + return namespace + + +def _verification_module(packet: str) -> ast.Module: + anchor = "The following dynamic command is the live final-verification template." + start = packet.index(anchor) + heredoc_start = packet.index("/opt/homebrew/bin/python3 -I - <<'PY'\n", start) + code_start = heredoc_start + len("/opt/homebrew/bin/python3 -I - <<'PY'\n") + code_end = packet.index("\nPY\n", code_start) + return ast.parse(packet[code_start:code_end], filename="") + + +def _top_level_assignment(module: ast.Module, name: str) -> ast.Assign | ast.AnnAssign: + matches = [ + statement + for statement in module.body + if ( + isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == name + for target in statement.targets + ) + ) + or ( + isinstance(statement, ast.AnnAssign) + and isinstance(statement.target, ast.Name) + and statement.target.id == name + ) + ] + if len(matches) != 1: + raise AssertionError( + f"verification template assignment {name!r} is missing or duplicated" + ) + return matches[0] + + +def _literal_assignment_value(statement: ast.Assign | ast.AnnAssign) -> object: + value = statement.value + if value is None: + raise AssertionError("verification template assignment has no value") + return ast.literal_eval(value) + + +def _safe_environment_mapping(module: ast.Module) -> dict[str, str]: + statement = _top_level_assignment(module, "git_environment") + expression = statement.value + assert expression is not None + allowed_names = {"os", "git_environment_override_names", "git_child_environment_names"} + allowed_calls = {"items", "startswith"} + for node in ast.walk(expression): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id not in allowed_names | {"key", "value"}: + raise AssertionError("Git child environment expression has an unresolved name") + if isinstance(node, ast.Call): + if not isinstance(node.func, ast.Attribute) or node.func.attr not in allowed_calls: + raise AssertionError("Git child environment expression is not a passive mapping filter") + if node.func.attr == "items" and not ( + isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "environ" + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "os" + ): + raise AssertionError("Git child environment items source is not the supplied synthetic map") + if node.func.attr == "startswith" and not isinstance(node.func.value, ast.Name): + raise AssertionError("Git child environment prefix check is not passive") + safe_names: dict[str, object] = {} + try: + allowlist = _literal_assignment_value(_top_level_assignment(module, "git_child_environment_names")) + except AssertionError: + allowlist = () + except (ValueError, TypeError, SyntaxError): + raise AssertionError("Git child environment allowlist is not literal") + safe_names["git_child_environment_names"] = allowlist + safe_names["git_environment_override_names"] = set() + synthetic_environment = { + "PATH": "/synthetic/bin", + "LANG": "C", + "LC_ALL": "C", + "GH_TOKEN": "synthetic-only", + "GITHUB_TOKEN": "synthetic-only", + "GITHUB_APP_PRIVATE_KEY": "synthetic-only", + "CUSTOM_SECRET": "synthetic-only", + "HOME": "/synthetic/home", + } + namespace = { + "__builtins__": {}, + "os": types.SimpleNamespace(environ=synthetic_environment), + **safe_names, + } + result = eval(compile(ast.Expression(expression), "", "eval"), namespace) + if not isinstance(result, dict): + raise AssertionError("Git child environment expression did not build a mapping") + for statement in module.body: + if not ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Call) + and isinstance(statement.value.func, ast.Attribute) + and isinstance(statement.value.func.value, ast.Name) + and statement.value.func.value.id == "git_environment" + and statement.value.func.attr == "update" + and len(statement.value.args) == 1 + ): + continue + update = ast.literal_eval(statement.value.args[0]) + if not isinstance(update, dict): + raise AssertionError("Git child environment override is not a literal mapping") + result.update(update) + return result + + +def _verification_function(module: ast.Module, name: str) -> ast.FunctionDef: + matches = [ + statement + for statement in ast.walk(module) + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + and statement.name == name + ] + if ( + len(matches) == 1 + and isinstance(matches[0], ast.FunctionDef) + and matches[0] in module.body + ): + return matches[0] + raise AssertionError(f"verification helper {name!r} is missing or duplicated") + + +def _safe_integer_expression(node: ast.AST) -> int: + if isinstance(node, ast.Constant) and type(node.value) is int: + return node.value + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mult): + return _safe_integer_expression(node.left) * _safe_integer_expression(node.right) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return _safe_integer_expression(node.left) + _safe_integer_expression(node.right) + raise AssertionError("Git query budget/deadline is not a literal integer expression") + + +def _validate_packet_function_definition( + node: ast.FunctionDef, + expected_name: str, + allowed_default_names: set[str] | None = None, +) -> None: + """Reject packet-controlled definition-time expressions before compilation.""" + allowed_default_names = allowed_default_names or set() + + def reviewed_default(expression: ast.AST) -> bool: + return _literal_definition_time_expression(expression) or ( + isinstance(expression, ast.Name) + and expression.id in allowed_default_names + ) + + argument_annotations = [ + argument.annotation + for argument in ( + list(node.args.posonlyargs) + + list(node.args.args) + + list(node.args.kwonlyargs) + ) + ] + if node.args.vararg is not None: + argument_annotations.append(node.args.vararg.annotation) + if node.args.kwarg is not None: + argument_annotations.append(node.args.kwarg.annotation) + if ( + node.name != expected_name + or node.decorator_list + or node.returns is not None + or getattr(node, "type_params", ()) + or any(annotation is not None for annotation in argument_annotations) + or not all( + reviewed_default(default) + for default in node.args.defaults + ) + or not all( + default is None or reviewed_default(default) + for default in node.args.kw_defaults + ) + ): + raise AssertionError( + "packet scanner function has unreviewed definition-time expressions" + ) + + +def _bounded_git_query_namespace(module: ast.Module) -> dict[str, object]: + """Load only the reviewed bounded local-Git query helpers from the template.""" + function_names = { + "close_git_query_streams", + "git_query_group_exists", + "wait_for_git_query_group_exit", + "terminate_git_query_group", + "capture_git_query_output", + "run_bounded_git_query", + "git_query", + "run_bounded_git_packet_blob_query", + } + constant_names = { + "git_query_deadline_seconds", + "git_query_termination_grace_seconds", + "git_query_output_max_bytes", + "git_query_packet_blob_output_max_bytes", + "git_query_stream_chunk_bytes", + } + namespace: dict[str, object] = { + "__builtins__": __builtins__, + "os": os, + "selectors": selectors, + "signal": signal, + "subprocess": subprocess, + "tempfile": tempfile, + "time": time, + "Path": Path, + } + for statement in module.body: + if isinstance(statement, ast.Assign): + names = { + target.id + for target in statement.targets + if isinstance(target, ast.Name) and target.id in constant_names + } + if names: + value = _safe_integer_expression(statement.value) + for name in names: + namespace[name] = value + elif isinstance(statement, ast.FunctionDef) and statement.name in function_names: + allowed_defaults = ( + {"git_query_output_max_bytes"} + if statement.name == "run_bounded_git_query" + else set() + ) + _validate_packet_function_definition( + statement, statement.name, allowed_defaults + ) + exec( + compile(ast.Module(body=[statement], type_ignores=[]), "", "exec"), + namespace, + ) + return namespace + + +def _run_local_git(arguments: list[str], cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + ["git", *arguments], cwd=cwd, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + + +def _run_git_checked(arguments: list[str], cwd: Path, env: dict[str, str]) -> bytes: + result = _run_local_git(arguments, cwd, env) + if result.returncode != 0: + raise AssertionError("synthetic local Git fixture setup failed") + return result.stdout + + +class Issue79RegressionTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.scanner = _scanner_namespace() + cls.verification = _verification_module(PACKET_TEXT) + + def inspect(self, code: str) -> str | None: + return self.scanner["inspect_python_heredoc"](code, False) # type: ignore[operator] + + def markdown_link_target_path_is_reviewed(self, code: str) -> bool: + tree = ast.parse(code, filename="") + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + path = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.Name) + and node.id == "path" + and isinstance(parents.get(node), ast.Attribute) + and parents[node].attr == "is_file" + ) + return self.scanner["python_reviewed_markdown_link_target_path"]( + path, tree, parents + ) # type: ignore[operator] + + def test_markdown_link_containment_guard_must_be_direct_and_reachable(self) -> None: + canonical = ( + 'import subprocess\n' + 'from pathlib import Path\n' + 'files = subprocess.check_output(["git", "ls-files", "*.md"], text=True).splitlines()\n' + 'repository_root = Path.cwd().resolve()\n' + 'for name in files:\n' + ' source = Path(name)\n' + ' markdown = source.read_text(encoding="utf-8")\n' + ' for match in link.finditer(markdown):\n' + ' target = match.group(1).strip().strip("<>")\n' + ' if target.startswith("#"):\n' + ' path, fragment = source, target[1:]\n' + ' else:\n' + ' target, separator, fragment = target.partition("#")\n' + ' path = (source.parent / target).resolve()\n' + ' try:\n' + ' path.relative_to(repository_root)\n' + ' except ValueError:\n' + ' continue\n' + ' if not path.is_file():\n' + ' errors.append(target)\n' + ) + nested_relative_to = canonical.replace( + ' path.relative_to(repository_root)\n', + ' if False:\n' + ' path.relative_to(repository_root)\n', + 1, + ) + unreachable_try = canonical.replace( + ' try:\n' + ' path.relative_to(repository_root)\n' + ' except ValueError:\n' + ' continue\n', + ' if False:\n' + ' try:\n' + ' path.relative_to(repository_root)\n' + ' except ValueError:\n' + ' continue\n', + 1, + ) + path_rebound_after_guard = canonical.replace( + ' if not path.is_file():\n', + ' path = Path("synthetic-private/file")\n' + ' if not path.is_file():\n', + 1, + ) + guard_before_approved_path_assignment = canonical.replace( + ' path = (source.parent / target).resolve()\n' + ' try:\n' + ' path.relative_to(repository_root)\n' + ' except ValueError:\n' + ' continue\n', + ' try:\n' + ' path.relative_to(repository_root)\n' + ' except ValueError:\n' + ' continue\n' + ' path = (source.parent / target).resolve()\n', + 1, + ) + self.assertNotEqual(canonical, nested_relative_to) + self.assertNotEqual(canonical, unreachable_try) + self.assertNotEqual(canonical, path_rebound_after_guard) + self.assertNotEqual(canonical, guard_before_approved_path_assignment) + self.assertTrue(self.markdown_link_target_path_is_reviewed(canonical)) + self.assertFalse(self.markdown_link_target_path_is_reviewed(nested_relative_to)) + self.assertFalse(self.markdown_link_target_path_is_reviewed(unreachable_try)) + for specimen in ( + path_rebound_after_guard, + guard_before_approved_path_assignment, + ): + with self.subTest(specimen=specimen): + self.assertFalse(self.markdown_link_target_path_is_reviewed(specimen)) + + def test_regex_group_exemption_respects_shadowing_parameters(self) -> None: + shadowed = ( + 'import re\n' + 'match = re.match("x", "x")\n' + 'def render(match):\n' + ' print(match.group())\n' + ) + canonical = ( + 'import re\n' + 'match = re.match("x", "x")\n' + 'print(match.group(0))\n' + ) + self.assertIsNotNone(self.inspect(shadowed)) + self.assertIsNone(self.inspect(canonical)) + + def test_sensitive_return_through_factory_created_instance_is_tainted(self) -> None: + unsafe = ( + 'import os\n' + 'class Snapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'def build():\n' + ' instance = Snapshot()\n' + ' return instance\n' + 'print(build().read())\n' + ) + safe = ( + 'class Snapshot:\n' + ' def read(self):\n' + ' return {"status": "ready"}\n' + 'def build():\n' + ' instance = Snapshot()\n' + ' return instance\n' + 'print(build().read())\n' + ) + self.assertIsNotNone(self.inspect(unsafe)) + self.assertIsNone(self.inspect(safe)) + + def test_getattr_default_bound_method_taints_sensitive_arguments(self) -> None: + unsafe = ( + 'import os\n' + 'class Sink:\n' + ' def emit(self, value):\n' + ' print(value)\n' + 'sink = Sink()\n' + 'callback = getattr(sink, "missing", sink.emit)\n' + 'callback(os.environ)\n' + ) + safe = ( + 'class Sink:\n' + ' def emit(self, value):\n' + ' print(value)\n' + 'sink = Sink()\n' + 'callback = getattr(sink, "missing", sink.emit)\n' + 'callback({"status": "ready"})\n' + ) + self.assertIsNotNone(self.inspect(unsafe)) + self.assertIsNone(self.inspect(safe)) + + def package_directory_guard_is_reviewed(self, code: str) -> bool: + tree = ast.parse(code, filename="") + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + package_dir = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.Name) + and node.id == "package_dir" + and isinstance(node.ctx, ast.Load) + and isinstance(parents.get(node), ast.Attribute) + and parents[node].attr == "glob" + ) + return self.scanner["python_reviewed_go_package_directory"]( + package_dir, tree, parents + ) # type: ignore[operator] + + def replace_source_fuzz_guard_fragment( + self, code: str, original: str, replacement: str + ) -> str: + tree = ast.parse(code, filename="") + guards = [ + statement + for statement in tree.body + if isinstance(statement, ast.FunctionDef) + and statement.name == "source_fuzz_guard" + ] + self.assertEqual(len(guards), 1) + source_lines = code.splitlines(keepends=True) + start = sum(len(line) for line in source_lines[: guards[0].lineno - 1]) + end = sum(len(line) for line in source_lines[: guards[0].end_lineno]) + guard_source = code[start:end] + changed_guard = guard_source.replace(original, replacement, 1) + self.assertNotEqual(changed_guard, guard_source) + return code[:start] + changed_guard + code[end:] + + def shell_violation(self, command: str) -> str | None: + self.scanner["shell_owned_path_variables"].clear() # type: ignore[union-attr] + self.scanner["shell_pending_owned_bindings"].clear() # type: ignore[union-attr] + return self.scanner["forbidden_shell_command"](shlex.split(command)) # type: ignore[operator] + + def shell_document_violation(self, commands: str) -> str | None: + """Use the packet's shell-fence parser and scanner on inert source text.""" + markdown = f"```sh\n{commands}\n```\n" + for command, _number in self.scanner["shell_commands"](markdown): # type: ignore[operator] + for segment in self.scanner["shell_token_segments"](command): # type: ignore[operator] + violation = self.scanner["forbidden_shell_command"](segment) # type: ignore[operator] + if violation: + return violation + return None + + def test_path_filesystem_readers_require_reviewed_paths(self) -> None: + unsafe = ( + 'from pathlib import Path\nprint(list(Path("synthetic-private").glob("*")))\n', + 'from pathlib import Path\nprint(Path("synthetic-private/file").stat())\n', + 'from pathlib import Path\nprint(list(Path("synthetic-private").iterdir()))\n', + 'from pathlib import Path\nprint(list(Path("synthetic-private").walk()))\n', + 'from pathlib import Path\nreader = Path("synthetic-private").glob\nprint(list(reader("*")))\n', + 'from pathlib import Path\np: Path = Path("synthetic-private")\nprint(p.read_text())\n', + 'from pathlib import Path\nfactory = Path\nprint(factory("synthetic-private").read_text())\n', + 'from pathlib import Path\nprint(Path("synthetic-private").resolve().read_text())\n', + 'from pathlib import Path\ndef path_factory():\n return Path("synthetic-private")\nprint(path_factory().read_text())\n', + 'from pathlib import Path\n' + 'def reader_factory():\n' + ' return Path("synthetic-private/file").read_text\n' + 'reader = reader_factory()\n' + 'print(reader())\n', + 'from pathlib import Path\n' + 'def reader_factory(flag):\n' + ' if flag:\n' + ' return Path("synthetic-private/file").read_text\n' + ' return Path("docs/evidence/g01-recovery-packet.md").read_text\n' + 'reader = reader_factory(True)\n' + 'print(reader())\n', + 'from pathlib import Path\n' + 'member = "read_text"\n' + 'def reader_factory():\n' + ' return getattr(Path("synthetic-private/file"), member)\n' + 'reader = reader_factory()\n' + 'print(reader())\n', + 'from pathlib import Path\ndef read_private(path: Path):\n return path.read_text()\n', + 'import ast\nfrom pathlib import Path\nast = Path("synthetic-private")\nprint(list(ast.walk()))\n', + 'import re\nfrom pathlib import Path\nmatch = re.match("a", "a")\nmatch = Path("synthetic-private")\nprint(match.group())\n', + 'from pathlib import Path\n' + 'def source_fuzz_guard(go_repo_root, module_dir, package_value):\n' + ' package_dir = (go_repo_root / module_dir / package_value).resolve()\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit("outside caller roots")\n' + ' print(list(package_dir.glob("*")))\n' + 'source_fuzz_guard(Path("/"), Path("etc"), Path(""))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + safe_bodies = ( + 'from pathlib import Path\nprint(Path("docs/evidence/g01-recovery-packet.md").read_text())\n', + 'from pathlib import Path\n' + 'def reviewed_reader_factory():\n' + ' return Path("docs/evidence/g01-recovery-packet.md").read_text\n' + 'reader = reviewed_reader_factory()\n' + 'print(reader())\n', + 'from pathlib import Path\n' + 'member = "read_text"\n' + 'def reviewed_reader_factory():\n' + ' return getattr(Path("docs/evidence/g01-recovery-packet.md"), member)\n' + 'reader = reviewed_reader_factory()\n' + 'print(reader())\n', + 'from pathlib import Path\n' + 'source = Path("scripts/evidence_packet/issue79_regression_test.py").read_bytes()\n' + 'if not source:\n raise SystemExit("reviewed source is empty")\n', + 'from pathlib import Path\nprint(Path("docs/evidence/g01-recovery-packet.md").stat())\n', + 'import ast\nlist(ast.walk(ast.parse("value = 1")))\n', + 'import re\nmatch = re.match("x", "x")\nprint(match.group(0))\n', + ) + for body in safe_bodies: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_resolved_local_paths_are_not_disclosed_to_output_sinks(self) -> None: + unsafe = ( + 'from pathlib import Path\nprint(Path.cwd().resolve())\n', + 'from pathlib import Path\nresolved = Path("/synthetic/worktree").resolve()\n' + 'print(f"root={resolved}")\n', + 'from pathlib import Path\n' + 'value = format(Path.cwd().resolve())\n' + 'print(value)\n', + 'from pathlib import Path\n' + 'value = ascii(Path.cwd().resolve())\n' + 'print(value)\n', + 'from pathlib import Path\n' + 'value = Path.cwd().resolve().__str__()\n' + 'print(value)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + internal_use = ( + 'from pathlib import Path\nresolved = Path.cwd().resolve()\n' + 'if not resolved.is_absolute():\n raise SystemExit("invalid root")\n' + ) + self.assertIsNone(self.inspect(internal_use)) + safe_formatting = ( + 'print(format("reviewed"))\n' + 'print(ascii("reviewed"))\n' + 'print("reviewed".__str__())\n' + ) + self.assertIsNone(self.inspect(safe_formatting)) + + def test_resolved_paths_keep_taint_through_protocol_and_byte_conversions(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'convert = ascii\n' + 'print(convert(Path.cwd().resolve()))\n', + 'from pathlib import Path\n' + 'print(Path.cwd().resolve().__fspath__())\n', + 'from pathlib import Path\n' + 'print(Path.cwd().resolve().as_posix().encode().decode())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'convert = ascii\n' + 'print(convert(Path("docs/evidence/g01-recovery-packet.md")))\n', + 'from pathlib import Path\n' + 'print(Path("docs/evidence/g01-recovery-packet.md").__fspath__())\n', + 'from pathlib import Path\n' + 'print(Path("docs/evidence/g01-recovery-packet.md").as_posix().encode().decode())\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_concatenated_getattr_path_reader_is_rejected(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'member = "read_" + "text"\n' + 'reader = getattr(Path("synthetic-private/file"), member)\n' + 'print(reader())\n', + 'from pathlib import Path\n' + 'member = "read_" + suffix\n' + 'reader = getattr(Path("synthetic-private/file"), member)\n' + 'print(reader())\n', + 'from pathlib import Path\n' + 'lookup = getattr\n' + 'member = "read_" + suffix\n' + 'reader = lookup(Path("synthetic-private/file"), member)\n' + 'print(reader())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'member = "read_" + "text"\n' + 'reader = getattr(Path("docs/evidence/g01-recovery-packet.md"), member)\n' + 'print(reader())\n', + 'from pathlib import Path\n' + 'lookup = getattr\n' + 'member = "read_" + "text"\n' + 'reader = lookup(Path("docs/evidence/g01-recovery-packet.md"), member)\n' + 'print(reader())\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_nested_function_name_collision_does_not_hide_launcher_alias(self) -> None: + body = ( + 'import subprocess\n' + 'def launcher_factory():\n' + ' return subprocess.run\n' + 'def unrelated_scope():\n' + ' def launcher_factory():\n' + ' return print\n' + 'launch = launcher_factory()\n' + 'launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'def value_factory():\n' + ' return print\n' + 'def unrelated_scope():\n' + ' def value_factory():\n' + ' return str.upper\n' + 'value = value_factory()\n' + 'value("reviewed")\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_sensitive_local_helper_returns_are_tainted_at_output_sinks(self) -> None: + unsafe = ( + 'import os\n' + 'def environment_snapshot():\n' + ' return os.environ\n' + 'print(environment_snapshot())\n', + 'import os\n' + 'def environment_snapshot():\n' + ' return dict(os.environ)\n' + 'print(environment_snapshot())\n', + 'import os\n' + 'def environment_snapshot():\n' + ' return dict(os.environ)\n' + 'def forwarded_snapshot():\n' + ' return environment_snapshot()\n' + 'print(forwarded_snapshot())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'def reviewed_status():\n' + ' return {"status": "reviewed"}\n' + 'print(reviewed_status())\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_sensitive_method_and_lambda_returns_are_tainted(self) -> None: + unsafe = ( + 'import os\n' + 'snapshot = lambda: dict(os.environ)\n' + 'print(snapshot())\n', + 'import os\n' + 'class EnvironmentSnapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'print(EnvironmentSnapshot().read())\n', + 'import os\n' + 'class EnvironmentSnapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'snapshot = EnvironmentSnapshot()\n' + 'print(snapshot.read())\n', + 'import os\n' + 'class EnvironmentSnapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'snapshot = EnvironmentSnapshot()\n' + 'alias = snapshot\n' + 'print(alias.read())\n', + 'import os\n' + 'class EnvironmentSnapshot:\n' + ' def __init__(self, label):\n' + ' self.label = label\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'snapshot = EnvironmentSnapshot("reviewed")\n' + 'print(snapshot.read())\n', + 'import os\n' + 'class EnvironmentSnapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'snapshot = EnvironmentSnapshot()\n' + 'reader = snapshot.read\n' + 'print(reader())\n', + 'import os\n' + 'class EnvironmentSnapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'def build():\n' + ' return EnvironmentSnapshot()\n' + 'snapshot = build()\n' + 'print(snapshot.read())\n', + 'import os\n' + 'reader = getattr(object(), "missing", lambda: dict(os.environ))\n' + 'print(reader())\n', + 'import os\n' + 'class Snapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'Alias = Snapshot\n' + 'print(Alias().read())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'class StatusSnapshot:\n' + ' def read(self):\n' + ' return {"status": "reviewed"}\n' + 'print(StatusSnapshot().read())\n' + ) + self.assertIsNone(self.inspect(safe)) + safe_alias = ( + 'class StatusSnapshot:\n' + ' def read(self):\n' + ' return {"status": "reviewed"}\n' + 'Alias = StatusSnapshot\n' + 'print(Alias().read())\n' + ) + self.assertIsNone(self.inspect(safe_alias)) + + def test_sensitive_values_are_tainted_into_method_and_lambda_parameters(self) -> None: + unsafe = ( + 'import os\n' + 'class C:\n' + ' def emit(self, payload):\n' + ' print(payload)\n' + 'C().emit(os.environ)\n', + 'import os\n' + 'emit = lambda payload: print(payload)\n' + 'emit(os.environ)\n', + 'import os\n' + 'class C:\n' + ' def emit(self, payload):\n' + ' print(payload)\n' + 'sink = C()\n' + 'member = "emit"\n' + 'callback = getattr(sink, member)\n' + 'callback(os.environ)\n', + 'import os\n' + 'class C:\n' + ' def emit(self, payload):\n' + ' print(payload)\n' + 'def build():\n' + ' instance = C()\n' + ' return instance\n' + 'sink = build()\n' + 'sink.emit(os.environ)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'class C:\n' + ' def emit(self, payload):\n' + ' print(payload)\n' + 'C().emit({"status": "reviewed"})\n', + 'emit = lambda payload: print(payload)\n' + 'emit({"status": "reviewed"})\n', + 'class C:\n' + ' def emit(self, payload):\n' + ' print(payload)\n' + 'sink = C()\n' + 'member = "emit"\n' + 'callback = getattr(sink, member)\n' + 'callback({"status": "reviewed"})\n', + 'class C:\n' + ' def emit(self, payload):\n' + ' print(payload)\n' + 'def build():\n' + ' instance = C()\n' + ' return instance\n' + 'sink = build()\n' + 'sink.emit({"status": "reviewed"})\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_join_of_environment_views_keeps_sensitive_taint(self) -> None: + unsafe = ( + 'import os\n' + 'secret = os.environ\n' + "print(''.join(secret.values()))\n", + 'import os\n' + "print(''.join(os.environ.values()))\n", + 'import os\n' + 'secret = os.environ\n' + "print(''.join(list(secret.values())))\n", + 'import os\n' + 'secret = os.environ\n' + "print(''.join(tuple(secret.values())))\n", + 'import os\n' + 'secret = os.environ\n' + "print(''.join(value for value in secret.values()))\n", + 'import os\n' + 'secret = os.environ\n' + "print(''.join(map(str, secret.values())))\n", + 'import os\n' + 'secret = os.environ\n' + 'separator = ""\n' + 'print(separator.join(list(secret.values())))\n', + 'import os\n' + 'secret = os.environ\n' + 'join = "".join\n' + 'print(join(list(secret.values())))\n', + 'import os\n' + 'secret = os.environ\n' + 'print(str().join(iter(secret.values())))\n', + 'import os\n' + 'secret = os.environ\n' + 'values = secret.values()\n' + 'print("".join(values))\n', + 'import os\n' + 'secret = os.environ\n' + 'values = list(secret.values())\n' + 'join = "".join\n' + 'print(join(values))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'snapshot = {"status": "reviewed"}\n' + "print(''.join(snapshot.values()))\n", + "print(''.join({'status': 'reviewed'}.values()))\n", + 'snapshot = {"status": "reviewed"}\n' + "print(''.join(list(snapshot.values())))\n", + "print(''.join(tuple({'status': 'reviewed'}.values())))\n", + "print(''.join(value for value in {'status': 'reviewed'}.values()))\n", + "print(''.join(map(str, {'status': 'reviewed'}.values())))\n", + 'snapshot = {"status": "reviewed"}\n' + 'separator = ""\n' + 'print(separator.join(list(snapshot.values())))\n', + 'snapshot = {"status": "reviewed"}\n' + 'join = "".join\n' + 'print(join(list(snapshot.values())))\n', + 'snapshot = {"status": "reviewed"}\n' + 'values = snapshot.values()\n' + 'print("".join(values))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_environment_urlencode_output_keeps_sensitive_taint(self) -> None: + unsafe = ( + 'import os\n' + 'import urllib.parse\n' + 'print(urllib.parse.urlencode(os.environ))\n', + 'import os\n' + 'from urllib.parse import urlencode\n' + 'print(urlencode(os.environ))\n', + 'import os\n' + 'from urllib.parse import urlencode as encode\n' + 'query = encode(os.environ)\n' + 'print(query)\n', + 'import os\n' + 'import urllib.parse as parse\n' + 'secret = os.environ\n' + 'print(parse.urlencode(secret))\n', + 'import os\n' + 'from urllib.parse import urlencode as encode\n' + 'def unrelated(encode):\n' + ' return "reviewed"\n' + 'secret = os.environ\n' + 'print(encode(secret))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from urllib.parse import urlencode\n' + 'print(urlencode({"status": "reviewed"}))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_sensitive_environment_assignment_to_members_keeps_taint(self) -> None: + unsafe = ( + 'import os\n' + 'box = {}\n' + 'box.payload = os.environ\n' + 'print(box.payload)\n', + 'import os\n' + 'box = {}\n' + 'box["payload"] = os.environ\n' + 'print(box["payload"])\n', + 'import os\n' + 'box = {}\n' + 'box.payload = os.environ\n' + 'value = box.payload\n' + 'print(value)\n', + 'import os\n' + 'box = {}\n' + 'box.payload = os.environ\n' + 'first = box.payload\n' + 'second = first\n' + 'print(second)\n', + 'import os\n' + 'def save(obj, value):\n' + ' obj.payload = value\n' + 'box = {}\n' + 'save(box, os.environ)\n' + 'print(box.payload)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'box = {}\n' + 'box["payload"] = {"status": "reviewed"}\n' + 'print(box["payload"])\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_environment_joins_cover_str_descriptor_and_nested_next(self) -> None: + unsafe = ( + 'import os\n' + 'secret = os.environ\n' + 'print(str.join("", secret.values()))\n', + 'import os\n' + 'secret = os.environ\n' + 'print("".join(next(iter(secret.values()))))\n', + 'import os\n' + 'def unrelated(str):\n' + ' return "reviewed"\n' + 'secret = os.environ\n' + 'print(str.join("", secret.values()))\n', + 'import os\n' + 'def unrelated(next):\n' + ' return "reviewed"\n' + 'secret = os.environ\n' + 'print("".join(next(iter(secret.values()))))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'print(str.join("", {"status": "reviewed"}.values()))\n', + 'print("".join(next(iter({"status": "reviewed"}.values()))))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters(self) -> None: + unsafe = ( + 'import os\n' + '(lambda payload: print(payload))(os.environ)\n', + 'import os\n' + 'class C:\n' + ' @staticmethod\n' + ' def emit(payload):\n' + ' print(payload)\n' + 'C().emit(os.environ)\n', + 'import os\n' + 'sm = staticmethod\n' + 'class C:\n' + ' @sm\n' + ' def emit(payload):\n' + ' print(payload)\n' + 'C().emit(os.environ)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'emit = lambda payload: print(payload)\n' + 'emit({"status": "reviewed"})\n', + 'class C:\n' + ' @staticmethod\n' + ' def emit(payload):\n' + ' print(payload)\n' + 'C().emit({"status": "reviewed"})\n', + 'sm = staticmethod\n' + 'class C:\n' + ' @sm\n' + ' def emit(payload):\n' + ' print(payload)\n' + 'C().emit({"status": "reviewed"})\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_sensitive_taint_reaches_string_format_arguments(self) -> None: + unsafe = ( + 'import os\n' + 'secret = os.environ\n' + 'print("{}".format(secret))\n', + 'import os\n' + 'print("{}".format(os.environ))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'secret = {"status": "reviewed"}\n' + 'print("{}".format(secret))\n', + 'print("{}".format({"status": "reviewed"}))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_format_callable_aliases_preserve_sensitive_taint(self) -> None: + unsafe = ( + 'import os\n' + 'secret = os.environ\n' + 'fmt = format\n' + 'print(fmt(secret))\n', + 'import os\n' + 'secret = os.environ\n' + 'fmt = "{}".format\n' + 'print(fmt(secret))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'fmt = format\n' + 'print(fmt("reviewed"))\n', + 'fmt = "{}".format\n' + 'print(fmt({"status": "reviewed"}))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_user_defined_format_alias_returning_constant_is_safe(self) -> None: + safe = ( + 'import os\n' + 'def format(value):\n' + ' return "reviewed"\n' + 'print(format(os.environ))\n', + 'import os\n' + 'def format(value):\n' + ' return "reviewed"\n' + 'fmt = format\n' + 'print(fmt(os.environ))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_factory_returned_bound_method_receives_sensitive_argument(self) -> None: + unsafe = ( + 'import os\n' + 'class C:\n' + ' def emit(self, value):\n' + ' print(value)\n' + 'def make_callback():\n' + ' return C().emit\n' + 'callback = make_callback()\n' + 'callback(os.environ)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'class C:\n' + ' def emit(self, value):\n' + ' print(value)\n' + 'def make_callback():\n' + ' return C().emit\n' + 'callback = make_callback()\n' + 'callback({"status": "reviewed"})\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_container_and_conditional_class_aliases_preserve_return_taint(self) -> None: + unsafe = ( + 'import os\n' + 'class Snapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'Alias = (Snapshot,)[0]\n' + 'print(Alias().read())\n', + 'import os\n' + 'class Snapshot:\n' + ' def read(self):\n' + ' return dict(os.environ)\n' + 'class Status:\n' + ' def read(self):\n' + ' return {"status": "reviewed"}\n' + 'Alias = Snapshot if flag else Status\n' + 'print(Alias().read())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'class StatusSnapshot:\n' + ' def read(self):\n' + ' return {"status": "reviewed"}\n' + 'Alias = (StatusSnapshot,)[0]\n' + 'print(Alias().read())\n', + 'class StatusSnapshot:\n' + ' def read(self):\n' + ' return {"status": "reviewed"}\n' + 'Alias = StatusSnapshot if flag else StatusSnapshot\n' + 'print(Alias().read())\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_sensitive_mapping_return_survives_unrelated_nested_name_collision(self) -> None: + unsafe = ( + 'import os\n' + 'def relay(value):\n' + ' return value\n' + 'def build_snapshot():\n' + ' return relay(dict(os.environ))\n' + 'def unrelated_scope():\n' + ' def relay(other):\n' + ' return {"status": "reviewed"}\n' + 'print(build_snapshot())\n' + ) + self.assertIsNotNone(self.inspect(unsafe)) + + def test_safe_top_level_helper_ignores_unrelated_nested_name_collision(self) -> None: + safe_collision = ( + 'import os\n' + 'def snapshot():\n' + ' return {"status": "reviewed"}\n' + 'def unrelated_scope():\n' + ' def snapshot():\n' + ' return os.environ\n' + 'print(snapshot())\n' + ) + self.assertIsNone(self.inspect(safe_collision)) + + def test_resolved_local_paths_from_helpers_and_globals_reach_output_sinks(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'def worktree_root():\n' + ' return Path.cwd().resolve()\n' + 'print(worktree_root())\n', + 'from pathlib import Path\n' + 'resolved_root = Path.cwd().resolve()\n' + 'def report_root():\n' + ' print(resolved_root)\n' + 'report_root()\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'def worktree_root():\n' + ' return Path.cwd().resolve()\n' + 'root = worktree_root()\n' + 'if not root.is_absolute():\n' + ' raise SystemExit("invalid root")\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_resolved_local_paths_cross_helper_boundaries_to_output_sinks(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'def worktree_root():\n' + ' return Path.cwd().resolve()\n' + 'root_alias = worktree_root\n' + 'print(root_alias())\n', + 'from pathlib import Path\n' + 'def report(root):\n' + ' print(root)\n' + 'report(Path.cwd().resolve())\n', + 'from pathlib import Path\n' + 'def worktree_roots():\n' + ' yield Path.cwd().resolve()\n' + 'print(next(worktree_roots()))\n', + 'from pathlib import Path\n' + 'def outer():\n' + ' root = Path.cwd().resolve()\n' + ' def report():\n' + ' print(root)\n' + ' report()\n' + 'outer()\n', + 'from pathlib import Path\n' + 'def report(root=Path.cwd().resolve()):\n' + ' print(root)\n' + 'report()\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe_default_validation = ( + 'from pathlib import Path\n' + 'def validate_root(root=Path.cwd().resolve()):\n' + ' if not root.is_absolute():\n' + ' raise SystemExit("invalid root")\n' + 'validate_root()\n' + ) + self.assertIsNone(self.inspect(safe_default_validation)) + + def test_resolved_local_paths_cross_expanded_helper_arguments(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'def report(*roots):\n print(roots)\n' + 'report(Path.cwd().resolve())\n', + 'from pathlib import Path\n' + 'def report(**roots):\n print(roots)\n' + 'report(root=Path.cwd().resolve())\n', + 'from pathlib import Path\n' + 'def report(root):\n print(root)\n' + 'report(**{"root": Path.cwd().resolve()})\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + def test_resolved_paths_cross_expanded_keyword_helpers_and_formatting(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'def report(root):\n' + ' print(root)\n' + 'report(**dict(root=Path.cwd().resolve()))\n', + 'from pathlib import Path\n' + 'raise RuntimeError("root={}".format(Path.cwd().resolve()))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'def validate(root):\n' + ' if not root.is_absolute():\n' + ' raise SystemExit("invalid root")\n' + 'validate(**dict(root=Path.cwd().resolve()))\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_resolved_local_paths_in_raised_errors_are_rejected(self) -> None: + unsafe = ( + 'from pathlib import Path\nraise RuntimeError(str(Path.cwd().resolve()))\n', + 'from pathlib import Path\nraise SystemExit(f"root={Path.cwd().resolve()}")\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('raise RuntimeError("reviewed status")\n')) + + def test_sensitive_variadic_and_default_helper_parameters_are_tainted(self) -> None: + unsafe = ( + 'import os\ndef report(*values):\n print(values)\n' + 'report(dict(os.environ))\n', + 'import os\ndef report(**values):\n print(values)\n' + 'report(**dict(os.environ))\n', + 'import os\ndef report(value=dict(os.environ)):\n' + ' print(value)\nreport()\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'def report(*values, **options):\n print(values, options)\n' + 'report("reviewed", status="safe")\n' + )) + + def test_sensitive_mapping_expanded_into_kwargs_is_tainted(self) -> None: + unsafe = ( + 'import os\n' + 'def report(**values):\n' + ' print(values["snapshot"])\n' + 'report(**dict(snapshot=os.environ))\n', + 'import os\n' + 'def report(**values):\n' + ' print(values)\n' + 'report(**dict(snapshot=dict(os.environ)))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'def report(**values):\n' + ' print(values)\n' + 'report(**dict(status="reviewed"))\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_sys_exit_is_an_output_sink_for_sensitive_values(self) -> None: + unsafe = ( + 'import os, sys\nsys.exit(str(dict(os.environ)))\n', + 'import os, sys\ndef snapshot():\n return dict(os.environ)\n' + 'sys.exit(str(snapshot()))\n', + 'from pathlib import Path\nimport sys\n' + 'sys.exit(str(Path.cwd().resolve()))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('import sys\nsys.exit("reviewed status")\n')) + + def test_imported_exit_alias_and_system_exit_preserve_sensitive_taint(self) -> None: + unsafe = ( + 'from sys import exit as leave\n' + 'import os\n' + 'leave(str(dict(os.environ)))\n', + 'import os\n' + 'raise SystemExit(str(dict(os.environ)))\n', + 'import os, sys\n' + 'leave = sys.exit\n' + 'leave(str(dict(os.environ)))\n', + 'import os\n' + 'abort = SystemExit\n' + 'raise abort(dict(os.environ))\n', + 'import os, sys\n' + 'leave = [sys.exit][0]\n' + 'leave(str(dict(os.environ)))\n', + 'import os\n' + 'abort = [SystemExit][0]\n' + 'raise abort(dict(os.environ))\n', + 'import os, sys\n' + 'leave = getattr(sys, "exit")\n' + 'leave(str(dict(os.environ)))\n', + 'import os, sys\n' + 'leave = getattr(sys, "exit", None)\n' + 'leave(str(dict(os.environ)))\n', + 'import os, sys\n' + 'member = "exit"\n' + 'leave = getattr(sys, member, None)\n' + 'leave(str(dict(os.environ)))\n', + 'import os, sys\n' + 'leave = getattr(object(), "missing", sys.exit)\n' + 'leave(str(dict(os.environ)))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from sys import exit as leave\n' + 'leave("reviewed status")\n' + 'raise SystemExit("reviewed status")\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_canonical_package_guard_remains_reviewed(self) -> None: + bodies = [ + body + for _line, body, _safe_marker, _invocation + in self.scanner["python_heredoc_bodies"](PACKET_TEXT) # type: ignore[operator] + if "def source_fuzz_guard():" in body + ] + self.assertEqual(len(bodies), 1) + self.assertIsNone(self.inspect(bodies[0])) + mutated_root = bodies[0].replace( + "invocation_root = Path.cwd().resolve()", + 'invocation_root = Path("/synthetic/unreviewed-root").resolve()', + 1, + ) + self.assertNotEqual(mutated_root, bodies[0]) + self.assertIsNotNone(self.inspect(mutated_root)) + shadowed_root = bodies[0].replace( + "def source_fuzz_guard():\n package_dir =", + 'def source_fuzz_guard():\n' + ' go_repo_root = Path("/synthetic/unreviewed-root")\n' + ' package_dir =', + 1, + ) + self.assertNotEqual(shadowed_root, bodies[0]) + self.assertIsNotNone(self.inspect(shadowed_root)) + + def test_environment_taint_reaches_loop_and_comprehension_targets(self) -> None: + loop = 'import os\nfor value in os.environ.values():\n print(value)\n' + comprehension = 'import os\n[print(value) for value in os.environ.values()]\n' + wrapped = ( + 'import os\nfor _, value in enumerate(os.environ.values()):\n' + ' print(value)\n' + ) + zipped = ( + 'import os\nfor _, value in zip(range(1), os.environ.values()):\n' + ' print(value)\n' + ) + starred = ( + 'import os\nfor *secret, in os.environ.values():\n' + ' print(secret)\n' + ) + for body in (loop, comprehension, wrapped, zipped, starred): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + safe = 'for value in ["reviewed"]:\n print(value)\n' + self.assertIsNone(self.inspect(safe)) + + def test_environment_taint_reaches_comprehension_iterator_outputs(self) -> None: + unsafe = ( + 'import os\n' + 'print([value for value in iter(dict(os.environ).items())])\n', + 'import os\n' + 'values = [value for _, value in enumerate(dict(os.environ).items())]\n' + 'print(values)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = 'print([value for value in ["reviewed"]])\n' + self.assertIsNone(self.inspect(safe)) + + def test_environment_taint_follows_generator_yields(self) -> None: + body = ( + 'import os\n' + 'def inherited_values():\n' + ' yield from os.environ.values()\n' + 'for secret in inherited_values():\n' + ' print(secret)\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_environment_taint_follows_itertools_chain(self) -> None: + body = ( + 'import itertools\nimport os\n' + 'for secret in itertools.chain(("reviewed",), os.environ.values()):\n' + ' print(secret)\n' + ) + tree = ast.parse(body, filename="") + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + tainted_names = self.scanner["python_sensitive_value_names"](tree, parents) # type: ignore[operator] + self.assertIn("secret", tainted_names) + + def test_environment_taint_follows_starred_operands(self) -> None: + body = ( + 'import os\n' + 'for secret in zip(*[os.environ.values()]):\n' + ' print(secret)\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_launcher_aliases_from_iterables_are_rejected(self) -> None: + direct = ( + 'import subprocess\n' + 'for launch in [subprocess.run]:\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + container = ( + 'import subprocess\n' + 'launchers = [subprocess.run]\n' + 'for launch in launchers:\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + comprehension = ( + 'import subprocess\n' + '[launch(["gh", "workflow", "run", "ci.yml"]) ' + 'for launch in [subprocess.run]]\n' + ) + mapping_view = ( + 'import subprocess\n' + 'launchers = {"run": subprocess.run}\n' + 'for launch in launchers.values():\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + literal_mapping_view = ( + 'import subprocess\n' + 'for launch in {"run": subprocess.run}.values():\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + converted_container = ( + 'import subprocess\n' + 'launchers = {"run": subprocess.run}\n' + 'for launch in list(launchers.values()):\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + starred_subscript = ( + 'import subprocess\n' + 'for *launchers, in [subprocess.run]:\n' + ' launchers[0](["gh", "workflow", "run", "ci.yml"])\n' + ) + for body in ( + direct, container, comprehension, mapping_view, + literal_mapping_view, converted_container, starred_subscript, + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + safe = 'for transform in [str.upper]:\n transform("reviewed")\n' + self.assertIsNone(self.inspect(safe)) + + def test_launcher_aliases_survive_reversed_and_dict_conversions(self) -> None: + reversed_values = ( + 'import subprocess\n' + 'launchers = [subprocess.run]\n' + 'for launch in reversed(launchers):\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + dictionary_values = ( + 'import subprocess\n' + 'launchers = {"run": subprocess.run}\n' + 'for launch in dict(launchers).values():\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + for body in (reversed_values, dictionary_values): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + def test_launcher_aliases_survive_sorted_mapping_values(self) -> None: + body = ( + 'import subprocess\n' + 'launchers = {"run": subprocess.run}\n' + 'for launch in sorted(launchers.values()):\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_launcher_aliases_survive_filter_mapping_values(self) -> None: + body = ( + 'import subprocess\n' + 'launchers = {"run": subprocess.run}\n' + 'for launch in filter(None, launchers.values()):\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_launcher_aliases_survive_map_mapping_values(self) -> None: + body = ( + 'import subprocess\n' + 'launchers = {"run": subprocess.run}\n' + 'for launch in map(lambda value: value, launchers.values()):\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_launcher_aliases_yielded_by_local_generator(self) -> None: + body = ( + 'import subprocess\n' + 'def launcher_stream():\n' + ' yield subprocess.run\n' + 'for launch in launcher_stream():\n' + ' launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_launcher_aliases_returned_by_local_helpers_are_rejected(self) -> None: + body = ( + 'import subprocess\n' + 'def launcher_factory():\n' + ' return subprocess.run\n' + 'launch = launcher_factory()\n' + 'launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected(self) -> None: + unsafe = ( + 'import subprocess\n' + 'launcher_factory = lambda: subprocess.run\n' + 'launcher = launcher_factory()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'launcher = (lambda: subprocess.run)()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'class LauncherFactory:\n' + ' def get(self):\n' + ' return subprocess.run\n' + 'launcher = LauncherFactory().get()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'class LauncherFactory:\n' + ' def __init__(self, label):\n' + ' self.label = label\n' + ' def get(self):\n' + ' return subprocess.run\n' + 'factory = LauncherFactory("reviewed")\n' + 'alias = factory\n' + 'launcher = alias.get()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'class LauncherFactory:\n' + ' def get(self):\n' + ' return subprocess.run\n' + 'factory = LauncherFactory()\n' + 'get_launcher = factory.get\n' + 'launcher = get_launcher()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'def make_launcher():\n' + ' return subprocess.run\n' + 'class LauncherFactory:\n' + ' pass\n' + 'factory = LauncherFactory()\n' + 'get_launcher = getattr(factory, "get", make_launcher)\n' + 'launcher = get_launcher()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'class LauncherFactory:\n' + ' def get(self):\n' + ' return subprocess.run\n' + 'factory = LauncherFactory()\n' + 'get_launcher = getattr(factory, "get")\n' + 'launcher = get_launcher()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'class LauncherFactory:\n' + ' def get(self):\n' + ' return subprocess.run\n' + 'factory = LauncherFactory()\n' + 'get_launcher = getattr(factory, "get", None)\n' + 'launcher = get_launcher()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + 'import subprocess\n' + 'class LauncherFactory:\n' + ' def get(self):\n' + ' return subprocess.run\n' + 'factory = LauncherFactory()\n' + 'member = "get"\n' + 'get_launcher = getattr(factory, member, None)\n' + 'launcher = get_launcher()\n' + 'launcher(["gh", "workflow", "run", "ci.yml"])\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'class StatusFactory:\n' + ' def get(self):\n' + ' return str.upper\n' + 'transform = StatusFactory().get()\n' + 'transform("reviewed")\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_launcher_alias_returned_by_mapping_pop_is_rejected(self) -> None: + unsafe = ( + 'import subprocess\n' + 'launchers = {"x": subprocess.run}\n' + 'launch = launchers.pop("x")\n' + 'launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(unsafe)) + + safe = ( + 'callbacks = {"upper": str.upper}\n' + 'transform = callbacks.pop("upper")\n' + 'transform("reviewed")\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_launcher_alias_returned_by_mapping_get_is_rejected(self) -> None: + unsafe = ( + 'import subprocess\n' + 'launchers = {"x": subprocess.run}\n' + 'launch = launchers.get("x")\n' + 'launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(unsafe)) + + safe = ( + 'callbacks = {"upper": str.upper}\n' + 'transform = callbacks.get("upper")\n' + 'transform("reviewed")\n' + ) + self.assertIsNone(self.inspect(safe)) + + def test_mapping_lookup_method_alias_chain_preserves_launcher_provenance(self) -> None: + unsafe = ( + 'import subprocess\n' + 'launchers = {"x": subprocess.run}\n' + 'lookup = launchers.get\n' + 'lookup2 = lookup\n' + 'launch = lookup2("x")\n' + 'launch(["gh", "workflow", "run", "ci.yml"])\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'callbacks = {"upper": str.upper}\n' + 'lookup = callbacks.get\n' + 'lookup2 = lookup\n' + 'transform = lookup2("upper")\n' + 'transform("reviewed")\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_isolated_invocation_ignores_synthetic_local_module(self) -> None: + with tempfile.TemporaryDirectory(prefix="issue79-python-isolation-") as root: + synthetic_module = Path(root) / "json.py" + synthetic_module.write_text('VALUE = "synthetic"\n', encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + "-I", + "-B", + "-c", + 'import json; print(getattr(json, "VALUE", "stdlib"))', + ], + cwd=root, + env={"PYTHONPATH": root}, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual(0, result.returncode, "isolated interpreter probe failed") + self.assertEqual("stdlib", result.stdout.strip()) + + adr = (ROOT / "docs" / "decisions" / "0004-offline-python-ast-regression-tooling.md").read_text( + encoding="utf-8" + ) + self.assertIn("python3 -I -B", adr) + self.assertNotIn("Invoke it with `python3 -B`", adr) + + def test_bounded_git_query_loader_rejects_unreviewed_function_definitions(self) -> None: + specimen = ast.parse( + 'def run_bounded_git_query(value=packet_side_effect()):\n' + ' return value\n', + filename="", + ).body[0] + self.assertIsInstance(specimen, ast.FunctionDef) + compile_events: list[str] = [] + exec_events: list[str] = [] + original_compile = builtins.compile + original_exec = builtins.exec + + def record_compile(*_args: object, **_kwargs: object) -> None: + compile_events.append("compile") + + def record_exec(*_args: object, **_kwargs: object) -> None: + exec_events.append("exec") + + builtins.compile = record_compile # type: ignore[assignment] + builtins.exec = record_exec # type: ignore[assignment] + try: + with self.assertRaises(AssertionError): + _bounded_git_query_namespace( + ast.Module(body=[specimen], type_ignores=[]) + ) + finally: + builtins.compile = original_compile + builtins.exec = original_exec + self.assertEqual([], compile_events) + self.assertEqual([], exec_events) + + def test_parity_helper_rejects_unreviewed_definition_before_compile(self) -> None: + original_verification = self.verification + specimen = ast.parse( + '@packet_side_effect()\n' + 'def require_packet_head_parity(intent, blob, worktree):\n' + ' return None\n', + filename="", + ).body[0] + self.assertIsInstance(specimen, ast.FunctionDef) + self.verification = ast.Module(body=[specimen], type_ignores=[]) + compile_events: list[str] = [] + exec_events: list[str] = [] + original_compile = builtins.compile + original_exec = builtins.exec + + def record_compile(*_args: object, **_kwargs: object) -> None: + compile_events.append("compile") + + def record_exec(*_args: object, **_kwargs: object) -> None: + exec_events.append("exec") + + builtins.compile = record_compile # type: ignore[assignment] + builtins.exec = record_exec # type: ignore[assignment] + try: + with self.assertRaises(AssertionError): + self.test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence() + finally: + builtins.compile = original_compile + builtins.exec = original_exec + self.verification = original_verification + self.assertEqual([], compile_events) + self.assertEqual([], exec_events) + + def test_verification_parity_helper_definition_must_be_unique(self) -> None: + duplicate = ast.parse( + 'def require_packet_head_parity(intent, blob, worktree):\n' + ' return None\n' + 'def require_packet_head_parity(intent, blob, worktree):\n' + ' return None\n', + filename="", + ) + with self.assertRaises(AssertionError): + _verification_function(duplicate, "require_packet_head_parity") + + def test_environment_dump_builtins_are_narrowly_allowed(self) -> None: + for command in ( + "export", "export -p", "set", "set -o posix", "env", "env -0", + "env -u NAME", "env NAME=synthetic", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + for command in ( + "set -euo pipefail", + "export PATH=/opt/homebrew/bin:/usr/bin:/bin", + "export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null", + "export GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=false GIT_CONFIG_KEY_1=core.hooksPath GIT_CONFIG_VALUE_1=/dev/null", + "env -i printf reviewed", + ): + with self.subTest(command=command): + self.assertIsNone(self.shell_violation(command)) + + def test_git_child_environment_uses_a_positive_allowlist(self) -> None: + child_environment = _safe_environment_mapping(self.verification) + for name in ("GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "CUSTOM_SECRET", "HOME"): + self.assertNotIn(name, child_environment) + self.assertEqual(child_environment.get("PATH"), "/synthetic/bin") + self.assertEqual(child_environment.get("LANG"), "C") + self.assertEqual(child_environment.get("GIT_CONFIG_NOSYSTEM"), "1") + self.assertEqual(child_environment.get("GIT_CONFIG_GLOBAL"), "/dev/null") + + def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) -> None: + function = _verification_function(self.verification, "require_packet_head_parity") + verifier_text = PACKET_TEXT[ + PACKET_TEXT.index("The following dynamic command is the live final-verification template."): + ] + reviewed_paths = ast.literal_eval( + _top_level_assignment( + self.verification, "issue79_reviewed_evidence_paths" + ).value + ) + self.assertEqual( + reviewed_paths, + ( + "docs/evidence/g01-recovery-packet.md", + "scripts/evidence_packet/issue79_regression_test.py", + "docs/decisions/0004-offline-python-ast-regression-tooling.md", + ), + ) + required_order = ( + 'git_query(["rev-parse", "HEAD"])', + 'for reviewed_path in issue79_reviewed_evidence_paths:', + 'git_query(["ls-files", "-v", "-z", "--", reviewed_path])', + 'run_bounded_git_packet_blob_query(\n f"{local}:{reviewed_path}"', + 'Path(\n "docs/evidence/g01-recovery-packet.md"\n ).read_bytes()', + 'Path(\n "scripts/evidence_packet/issue79_regression_test.py"\n ).read_bytes()', + 'Path(\n "docs/decisions/0004-offline-python-ast-regression-tooling.md"\n ).read_bytes()', + "require_packet_head_parity(\n intent_result.stdout", + 'git_query(["status", "--porcelain=v1", "--untracked-files=all"])', + ) + order = [verifier_text.index(item) for item in required_order] + self.assertEqual(order, sorted(order)) + namespace: dict[str, object] = { + "__builtins__": __builtins__, + } + _validate_packet_function_definition( + function, "require_packet_head_parity" + ) + exec( + compile(ast.Module(body=[function], type_ignores=[]), "", "exec"), + namespace, + ) + helper = namespace["require_packet_head_parity"] + with tempfile.TemporaryDirectory(prefix="gh-runnerd-issue79-") as directory: + root = Path(directory) + relative_paths = tuple(Path(path) for path in reviewed_paths) + reviewed_bytes = { + relative: f"synthetic reviewed bytes for {relative.as_posix()}\x00\n".encode() + for relative in relative_paths + } + changed_bytes = { + relative: f"synthetic modified bytes for {relative.as_posix()}\x00\n".encode() + for relative in relative_paths + } + for relative in relative_paths: + tracked_path = root / relative + tracked_path.parent.mkdir(parents=True, exist_ok=True) + tracked_path.write_bytes(reviewed_bytes[relative]) + env = { + "PATH": "/usr/bin:/bin", + "HOME": directory, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "LC_ALL": "C", + } + _run_git_checked(["init", "-q"], root, env) + _run_git_checked( + ["add", *(relative.as_posix() for relative in relative_paths)], + root, + env, + ) + _run_git_checked( + ["-c", "user.name=synthetic", "-c", "user.email=synthetic@example.invalid", "commit", "-q", "-m", "baseline"], + root, + env, + ) + head = _run_git_checked( + ["rev-parse", "HEAD"], root, env + ).decode().strip() + for relative in relative_paths: + intent = _run_git_checked( + ["ls-files", "-v", "-z", "--", relative.as_posix()], root, env + ) + blob = _run_git_checked( + ["show", f"{head}:{relative.as_posix()}"], root, env + ) + helper(intent, blob, (root / relative).read_bytes()) + + for flag, clear_flag in ( + ("--skip-worktree", "--no-skip-worktree"), + ("--assume-unchanged", "--no-assume-unchanged"), + ): + for relative in relative_paths: + tracked_path = root / relative + _run_git_checked( + ["update-index", flag, relative.as_posix()], root, env + ) + tracked_path.write_bytes(changed_bytes[relative]) + legacy_status = _run_git_checked( + ["status", "--porcelain=v1", "--untracked-files=all"], root, env + ) + self.assertEqual(legacy_status, b"") + with self.assertRaises(SystemExit): + helper( + _run_git_checked( + ["ls-files", "-v", "-z", "--", relative.as_posix()], + root, + env, + ), + _run_git_checked( + ["show", f"{head}:{relative.as_posix()}"], root, env + ), + tracked_path.read_bytes(), + ) + _run_git_checked( + ["update-index", clear_flag, relative.as_posix()], root, env + ) + tracked_path.write_bytes(reviewed_bytes[relative]) + + for relative in relative_paths: + tracked_path = root / relative + tracked_path.write_bytes(changed_bytes[relative]) + with self.assertRaises(SystemExit): + helper( + _run_git_checked( + ["ls-files", "-v", "-z", "--", relative.as_posix()], + root, + env, + ), + _run_git_checked( + ["show", f"{head}:{relative.as_posix()}"], root, env + ), + tracked_path.read_bytes(), + ) + + def test_large_packet_blob_uses_a_separate_bounded_capture(self) -> None: + runtime = _bounded_git_query_namespace(self.verification) + with tempfile.TemporaryDirectory(prefix="gh-runnerd-issue79-blob-") as directory: + root = Path(directory) + relative = Path("docs/evidence/g01-recovery-packet.md") + packet = root / relative + packet.parent.mkdir(parents=True) + payload_size = max(len(PACKET_TEXT.encode("utf-8")), 64 * 1024 + 1) + payload = b"S" * payload_size + packet.write_bytes(payload) + env = { + "PATH": "/usr/bin:/bin", + "HOME": directory, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_ATTR_NOSYSTEM": "1", + "LC_ALL": "C", + } + _run_git_checked(["init", "-q"], root, env) + _run_git_checked(["add", relative.as_posix()], root, env) + _run_git_checked( + [ + "-c", "user.name=synthetic", + "-c", "user.email=synthetic@example.invalid", + "commit", "-q", "-m", "baseline", + ], + root, + env, + ) + blob_spec = f"HEAD:{relative.as_posix()}" + blob_query = runtime.get("run_bounded_git_packet_blob_query") + if not callable(blob_query): + result = runtime["run_bounded_git_query"]( + runtime["git_query"](["show", blob_spec]), cwd=root, env=env + ) + else: + result = blob_query(blob_spec, cwd=root, env=env) + self.assertEqual(result.stdout, payload) + self.assertEqual(runtime["git_query_output_max_bytes"], 64 * 1024) + self.assertEqual(runtime["git_query_packet_blob_output_max_bytes"], 8 * 1024 * 1024) + with self.assertRaises(SystemExit): + blob_query("HEAD:README.md", cwd=root, env=env) + with self.assertRaisesRegex(SystemExit, "output exceeded the reviewed budget"): + runtime["run_bounded_git_query"]( + runtime["git_query"](["show", blob_spec]), cwd=root, env=env + ) + + def test_packet_blob_query_ignores_replace_refs(self) -> None: + runtime = _bounded_git_query_namespace(self.verification) + self.assertIn( + "--no-replace-objects", + runtime["git_query"](["rev-parse", "HEAD"]), # type: ignore[operator] + ) + with tempfile.TemporaryDirectory(prefix="gh-runnerd-issue79-replace-") as directory: + root = Path(directory) + relative = Path("docs/evidence/g01-recovery-packet.md") + packet = root / relative + packet.parent.mkdir(parents=True) + reviewed_bytes = b"synthetic reviewed HEAD packet\n" + replacement_bytes = b"synthetic replacement packet\n" + packet.write_bytes(reviewed_bytes) + env = { + "PATH": "/usr/bin:/bin", + "HOME": directory, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_ATTR_NOSYSTEM": "1", + "LC_ALL": "C", + } + _run_git_checked(["init", "-q"], root, env) + _run_git_checked(["add", relative.as_posix()], root, env) + _run_git_checked( + [ + "-c", "user.name=synthetic", + "-c", "user.email=synthetic@example.invalid", + "commit", "-q", "-m", "reviewed", + ], + root, + env, + ) + reviewed_head = _run_git_checked(["rev-parse", "HEAD"], root, env).decode().strip() + packet.write_bytes(replacement_bytes) + _run_git_checked(["add", relative.as_posix()], root, env) + _run_git_checked( + [ + "-c", "user.name=synthetic", + "-c", "user.email=synthetic@example.invalid", + "commit", "--amend", "-q", "--no-edit", + ], + root, + env, + ) + replacement_head = _run_git_checked(["rev-parse", "HEAD"], root, env).decode().strip() + _run_git_checked(["switch", "--detach", reviewed_head], root, env) + _run_git_checked(["replace", reviewed_head, replacement_head], root, env) + + replaced_blob = _run_git_checked( + ["show", f"{reviewed_head}:{relative.as_posix()}"], root, env + ) + self.assertEqual(replaced_blob, replacement_bytes) + result = runtime["run_bounded_git_packet_blob_query"]( + f"{reviewed_head}:{relative.as_posix()}", cwd=root, env=env + ) + self.assertEqual(result.stdout, reviewed_bytes) + + def test_git_config_include_options_are_rejected_before_read_only_classification(self) -> None: + for command in ( + "git -c include.path=synthetic/included.cfg status", + "git -c includeIf.gitdir:/synthetic/repo.path=synthetic/included.cfg status", + "git -cinclude.path=synthetic/included.cfg status", + "git --config-env=include.path=SYNTHETIC_INCLUDE status", + "git config --includes --list", + "git config --incl --list", + "git config --inc --list", + "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=include.path GIT_CONFIG_VALUE_0=synthetic/included.cfg git status", + "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=includeIf.gitdir:/synthetic/repo.path GIT_CONFIG_VALUE_0=synthetic/included.cfg git status", + "GIT_CONFIG_PARAMETERS=\"'include.path=synthetic/included.cfg'\" git status", + "env GIT_CONFIG_PARAMETERS=\"'includeIf.gitdir:/synthetic/repo.path=synthetic/included.cfg'\" git status", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + for command in ("git -P status", "git -c core.fsmonitor=false status"): + with self.subTest(command=command): + self.assertIsNone(self.shell_violation(command)) + + def test_core_worktree_override_cannot_mask_a_dirty_invocation_worktree(self) -> None: + with tempfile.TemporaryDirectory(prefix="issue79-core-worktree-") as temporary: + root = Path(temporary) + repository = root / "invocation" + alternate = root / "alternate" + repository.mkdir() + alternate.mkdir() + (root / "home").mkdir() + environment = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": str(root / "home"), + "LC_ALL": "C", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_TERMINAL_PROMPT": "0", + } + _run_git_checked(["init", "-q"], repository, environment) + tracked = repository / "tracked.txt" + tracked.write_text("reviewed\n", encoding="utf-8") + _run_git_checked(["add", "tracked.txt"], repository, environment) + _run_git_checked( + [ + "-c", "user.name=synthetic", + "-c", "user.email=synthetic@example.invalid", + "commit", "-q", "-m", "fixture", + ], + repository, + environment, + ) + root_guard = _verification_function( + self.verification, "require_git_invocation_root" + ) + _validate_packet_function_definition( + root_guard, "require_git_invocation_root" + ) + runtime = _bounded_git_query_namespace(self.verification) + exec( + compile( + ast.Module(body=[root_guard], type_ignores=[]), + "", + "exec", + ), + runtime, + ) + runtime["require_git_invocation_root"](repository, environment) + (alternate / "tracked.txt").write_text("reviewed\n", encoding="utf-8") + tracked.write_text("dirty invocation worktree\n", encoding="utf-8") + _run_git_checked( + ["config", "--local", "core.worktree", str(alternate)], + repository, + environment, + ) + redirected_status = _run_git_checked( + ["status", "--short"], repository, environment + ) + self.assertEqual(b"", redirected_status) + self.assertEqual(b"dirty invocation worktree\n", tracked.read_bytes()) + with self.assertRaisesRegex( + SystemExit, + "post-correction Git top-level does not match invocation root", + ): + runtime["require_git_invocation_root"](repository, environment) + + # This command remains inert scanner data; only the disposable fixture + # above is used to demonstrate the worktree-selection failure mode. + self.assertIsNotNone( + self.shell_violation( + "git -c core.worktree=/synthetic/alternate status --short" + ) + ) + self.assertIsNone(self.shell_violation("git status --short")) + + def test_unbounded_git_config_dumps_are_rejected(self) -> None: + for command in ( + "git config --global --list --show-origin", + "git config --get-regexp .", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + for command in ( + "git config --get core.repositoryformatversion", + "git config --local --get-regexp '^filter\\.'", + "git status --short", + ): + with self.subTest(command=command): + self.assertIsNone(self.shell_violation(command)) + + def test_git_config_queries_allow_only_reviewed_keys(self) -> None: + for command in ( + "git config --get credential.helper", + "git config --get-all credential.helper", + "git config --get-urlmatch http.extraheader https://github.com/1XP-AI/gh-runnerd", + "git config --get-urlmatch http.https://github.com/.extraheader https://github.com/1XP-AI/gh-runnerd", + "git config --get user.email", + "git config --get-all user.email", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + + for command in ( + "git config --get core.repositoryformatversion", + "git config --local --get-regexp '^filter\\.'", + "git config --get-urlmatch http.sslverify https://github.com/1XP-AI/gh-runnerd", + ): + with self.subTest(command=command): + self.assertIsNone(self.shell_violation(command)) + + def test_shell_origin_url_query_is_rejected_but_verifier_capture_remains(self) -> None: + query = "git config --local --get-all remote.origin.url" + self.assertIsNone( + self.scanner["git_read_only_violation"](shlex.split(query)) # type: ignore[operator] + ) + self.assertIsNotNone(self.shell_violation(query)) + self.assertIsNotNone( + self.shell_violation("trap 'git config --local --get-all remote.origin.url' EXIT") + ) + self.assertIsNotNone( + self.inspect( + 'import subprocess\n' + 'subprocess.run(["git", "config", "--local", "--get-all", ' + '"remote.origin.url"], check=True)\n' + ) + ) + + origin_capture = _top_level_assignment(self.verification, "origin_result") + self.assertEqual( + self.scanner["python_dotted_name"](origin_capture.value.func), # type: ignore[operator,union-attr] + "run_bounded_git_query", + ) + self.assertIn("remote.origin.url", ast.unparse(origin_capture.value)) + origin_check = next( + statement + for statement in self.verification.body + if isinstance(statement, ast.If) + and any( + isinstance(node, ast.Name) and node.id == "origin_urls" + for node in ast.walk(statement.test) + ) + ) + self.assertIsInstance(origin_check.test, ast.Compare) + + def test_explicit_executable_paths_require_reviewed_locations(self) -> None: + for command in ( + "/tmp/git status --porcelain=v1", + "./git status --porcelain=v1", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + self.assertIsNone(self.shell_violation("git status --porcelain=v1")) + + def test_git_config_assignments_cannot_replace_reviewed_fence_state(self) -> None: + commands = ( + "GIT_CONFIG_COUNT=1\n" + "GIT_CONFIG_KEY_0=diff.external\n" + "GIT_CONFIG_VALUE_0=/tmp/reviewed-hook\n" + "git diff HEAD^ HEAD" + ) + self.assertIsNotNone(self.shell_document_violation(commands)) + + def test_git_show_output_and_reader_option_paths_are_reviewed(self) -> None: + self.assertIsNotNone( + self.shell_violation( + "git show --output=AGENTS.md --format=oneline -s HEAD" + ) + ) + self.assertIsNotNone( + self.shell_violation( + "diff --from-file=$HOME/.netrc docs/EXECUTION.md" + ) + ) + + def test_git_diff_output_cannot_replace_reviewed_source(self) -> None: + for command in ( + "git diff --output=AGENTS.md HEAD^ HEAD", + "git diff --output AGENTS.md HEAD^ HEAD", + "git log --output=AGENTS.md -1", + "git show --output AGENTS.md HEAD", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + self.assertIsNone(self.shell_violation("git diff HEAD^ HEAD")) + + def test_nested_raise_does_not_prove_module_root_guard(self) -> None: + bodies = [ + body + for _line, body, _safe_marker, _invocation + in self.scanner["python_heredoc_bodies"](PACKET_TEXT) # type: ignore[operator] + if "def source_fuzz_guard():" in body + ] + self.assertEqual(len(bodies), 1) + original = ( + 'if module_dir != "experiments/g01-scaleset":\n' + ' raise SystemExit(f"{label}: unexpected module directory {module_dir!r}")' + ) + unreachable = ( + 'if module_dir != "experiments/g01-scaleset":\n' + ' if False:\n' + ' raise SystemExit(f"{label}: unexpected module directory {module_dir!r}")' + ) + body_tree = ast.parse(bodies[0], filename="") + guards = [ + statement + for statement in body_tree.body + if isinstance(statement, ast.If) + and isinstance(statement.test, ast.Compare) + and isinstance(statement.test.left, ast.Name) + and statement.test.left.id == "module_dir" + ] + self.assertEqual(len(guards), 1) + source_lines = bodies[0].splitlines(keepends=True) + start = sum(len(line) for line in source_lines[: guards[0].lineno - 1]) + end = sum(len(line) for line in source_lines[: guards[0].end_lineno]) + guard_source = bodies[0][start:end] + mutated_guard = guard_source.replace(original, unreachable, 1) + self.assertNotEqual(mutated_guard, guard_source) + mutated = bodies[0][:start] + mutated_guard + bodies[0][end:] + self.assertFalse(self.package_directory_guard_is_reviewed(mutated)) + + def test_unreachable_raise_does_not_prove_package_path_containment(self) -> None: + bodies = [ + body + for _line, body, _safe_marker, _invocation + in self.scanner["python_heredoc_bodies"](PACKET_TEXT) # type: ignore[operator] + if "def source_fuzz_guard():" in body + ] + self.assertEqual(len(bodies), 1) + original = ( + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ) + unreachable = ( + ' except ValueError:\n' + ' return\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ) + mutated = self.replace_source_fuzz_guard_fragment( + bodies[0], original, unreachable + ) + self.assertFalse(self.package_directory_guard_is_reviewed(mutated)) + + def test_unreachable_package_containment_try_is_not_reviewed(self) -> None: + bodies = [ + body + for _line, body, _safe_marker, _invocation + in self.scanner["python_heredoc_bodies"](PACKET_TEXT) # type: ignore[operator] + if "def source_fuzz_guard():" in body + ] + self.assertEqual(len(bodies), 1) + original = ( + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ) + self.assertIsNone(self.inspect(bodies[0])) + unreachable_variants = ( + ( + ' if False:\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ( + ' if True:\n' + ' pass\n' + ' else:\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ( + ' if 0:\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ( + ' if module_dir == "experiments/g01-scaleset":\n' + ' pass\n' + ' else:\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ( + ' if not False:\n' + ' pass\n' + ' else:\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ( + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except Exception:\n' + ' pass\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ) + for unreachable in unreachable_variants: + with self.subTest(unreachable=unreachable): + mutated = self.replace_source_fuzz_guard_fragment( + bodies[0], original, unreachable + ) + self.assertIsNotNone(self.inspect(mutated)) + + def test_package_containment_try_requires_direct_reachable_body(self) -> None: + bodies = [ + body + for _line, body, _safe_marker, _invocation + in self.scanner["python_heredoc_bodies"](PACKET_TEXT) # type: ignore[operator] + if "def source_fuzz_guard():" in body + ] + self.assertEqual(len(bodies), 1) + original = ( + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ) + self.assertIsNone(self.inspect(bodies[0])) + conditional_variants = ( + ( + ' if 0 == 1:\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ( + ' if 1 == 1:\n' + ' pass\n' + ' else:\n' + ' try:\n' + ' package_dir.relative_to(go_repo_root / module_dir)\n' + ' except ValueError:\n' + ' raise SystemExit(f"{label}: package source escaped the reviewed module")' + ), + ) + for replacement in conditional_variants: + with self.subTest(replacement=replacement): + mutated = self.replace_source_fuzz_guard_fragment( + bodies[0], original, replacement + ) + self.assertFalse(self.package_directory_guard_is_reviewed(mutated)) + + def test_packet_loader_rejects_packet_controlled_definition_time_code(self) -> None: + specimens = ( + "import synthetic_side_effect\n", + "@synthetic_side_effect()\ndef scanner():\n return None\n", + "def scanner(value=synthetic_side_effect()):\n return value\n", + "def set():\n return None\n", + "def ast():\n return None\n", + "set = lambda: None\n", + "import re\nre.compile = synthetic_side_effect\n", + "def scanner(*args: synthetic_side_effect()):\n return None\n", + "def scanner(**kwargs: synthetic_side_effect()):\n return None\n", + ) + for source in specimens: + with self.subTest(source=source): + module = ast.parse(source, filename="") + with self.assertRaises(AssertionError): + _validated_scanner_statements(module) + + def test_packet_loader_rejects_unsupported_top_level_assignment(self) -> None: + original = globals()["PACKET_TEXT"] + try: + globals()["PACKET_TEXT"] = original.replace( + "def inspect_python_heredoc(body, safe_marker):", + 'probe = os.system("gh workflow run ci.yml")\n' + 'def inspect_python_heredoc(body, safe_marker):', + 1, + ) + self.assertNotEqual(globals()["PACKET_TEXT"], original) + with self.assertRaises(AssertionError): + _scanner_namespace() + source_assignment = ( + 'source = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")' + ) + source_position = original.rfind( + source_assignment, + 0, + original.index("def inspect_python_heredoc(body, safe_marker):"), + ) + self.assertGreaterEqual(source_position, 0) + globals()["PACKET_TEXT"] = ( + original[:source_position] + + 'source = os.system("gh workflow run ci.yml")' + + original[source_position + len(source_assignment):] + ) + with self.assertRaises(AssertionError): + _scanner_namespace() + finally: + globals()["PACKET_TEXT"] = original + + def test_packet_loader_rejects_bare_top_level_expression(self) -> None: + module = ast.parse('print(subprocess.os.environ)\n') + with self.assertRaises(AssertionError): + _validated_scanner_statements(module) + self.assertIsNotNone(self.inspect('import subprocess\nprint(subprocess.os.environ)\n')) + + def test_reexported_os_module_does_not_bypass_heredoc_checks(self) -> None: + unsafe = ( + 'import subprocess\nsubprocess.os.remove("/tmp/maintainer-owned")\n', + 'import subprocess as sp\nsp.os.remove("/tmp/maintainer-owned")\n', + 'from subprocess import os as operating\noperating.remove("/tmp/maintainer-owned")\n', + 'import subprocess\nsp = subprocess\nsp.os.remove("/tmp/maintainer-owned")\n', + 'import subprocess\ngetattr(subprocess, "os").remove("/tmp/maintainer-owned")\n', + 'import subprocess\nsubprocess.__dict__["os"].remove("/tmp/maintainer-owned")\n', + 'import subprocess\nother, = (subprocess,)\nother.os.remove("/tmp/maintainer-owned")\n', + 'import subprocess\nother = [subprocess][0]\nother.os.remove("/tmp/maintainer-owned")\n', + 'import subprocess\nlookup = getattr\nlookup(subprocess, "os").remove("/tmp/maintainer-owned")\n', + 'import subprocess\nlookup = vars\nlookup(subprocess)["os"].remove("/tmp/maintainer-owned")\n', + 'import subprocess\nother, = (subprocess,)\nlookup = getattr\nmember = "os"\nlookup(other, member).system("gh workflow run ci.yml")\n', + 'import subprocess\nother = {"module": subprocess}["module"]\nlookup = getattr\nmember = "os"\nlookup(other, member).system("gh workflow run ci.yml")\n', + 'import subprocess\nclass Holder:\n pass\nholder = Holder()\nholder.module = subprocess\nother = holder.module\nlookup = getattr\nmember = "os"\nlookup(other, member).system("gh workflow run ci.yml")\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('import subprocess\nprint("reviewed")\n')) + self.assertIsNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'value = namespace["safe"]\nnamespace["safe"] = value\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess}\n' + 'other = namespace["subprocess"]\ngetattr(other, "os").system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'namespace.update({"safe": subprocess})\n' + 'other = namespace["safe"]\ngetattr(other, "os").system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'namespace.update({"safe": subprocess})\nother = namespace["safe"]\n' + 'lookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"safe": subprocess}\n' + 'if False:\n namespace = {"safe": "reviewed", "subprocess": subprocess}\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"safe": "reviewed", "subprocess": subprocess}\n' + 'namespace, = ({"safe": subprocess},)\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"safe": "reviewed", "subprocess": subprocess}\n' + 'for namespace in [{"safe": subprocess}]:\n other = namespace["safe"]\n' + 'lookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'update = namespace.update\nupdate({"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'dict.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'mapping_type = dict\nmapping_type.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'getattr(dict, "update")(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'type(namespace).update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'namespace.__class__.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'type([namespace][0]).update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + '[namespace][0].__class__.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'getattr([namespace][0], "__class__").update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'namespace.copy().__class__.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'update = dict.__dict__["update"]\nupdate(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'update = vars(dict)["update"]\nupdate(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'dict.__mro__[0].update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'type({}).update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + '{}.__class__.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'type("D", (dict,), {}).update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNone(self.inspect( + 'import subprocess\n' + 'fake_os = type("FakeOS", (), {"environ": {"fixture": "reviewed"}})\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'dict_type = type.__new__(type, "D", (dict,), {})\n' + 'dict_type.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'def type(*args):\n return dict\n' + 'type("FakeOS", (), {"environ": {"fixture": "reviewed"}}).update(' + 'namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'getattr(type, "__new__")(type, "D", (dict,), {}).update(' + 'namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nnamespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'vars(type)["__new__"](type, "D", (dict,), {}).update(' + 'namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nclass D(dict):\n pass\n' + 'namespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'D.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNone(self.inspect( + 'import subprocess\nclass StopAtChild(Exception):\n pass\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\nException = dict\nclass D(Exception):\n pass\n' + 'namespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'D.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess, sys\nsys._getframe().f_globals["Exception"] = dict\n' + 'class D(Exception):\n pass\n' + 'namespace = {"subprocess": subprocess, "safe": "reviewed"}\n' + 'D.update(namespace, {"safe": subprocess})\n' + 'other = namespace["safe"]\nlookup = getattr\nmember = "os"\n' + 'lookup(other, member).system("gh workflow run ci.yml")\n' + )) + self.assertIsNone(self.inspect( + 'class Settings:\n os = "darwin"\nprint(Settings.os)\n' + )) + + def test_command_capable_decorator_cannot_replace_safe_function(self) -> None: + body = ( + 'import subprocess\n' + 'def deco(function):\n' + ' return subprocess.run\n' + '@deco\n' + 'def launch(command):\n' + ' return None\n' + 'launch(["gh", "workflow", "run", "ci.yml"])\n' + ) + self.assertIsNotNone(self.inspect(body)) + self.assertIsNotNone(self.inspect( + 'import subprocess\n' + 'def sm(function):\n return subprocess.run\n' + 'class C:\n @sm\n def launch(command):\n return None\n' + 'C().launch(["gh", "workflow", "run", "ci.yml"])\n' + )) + self.assertIsNotNone(self.inspect( + 'import subprocess\n' + 'class GoAliasPopen:\n' + ' @property\n' + ' def returncode(self):\n' + ' return subprocess.run\n' + )) + for mutation in ( + '__builtins__["property"] = replace\n', + '__builtins__.property = replace\n', + 'setattr(__builtins__, "property", replace)\n', + 'ns = globals()["__builtins__"]\n' + 'if isinstance(ns, dict):\n ns["property"] = replace\n' + 'else:\n ns.property = replace\n', + ): + body = ( + 'import subprocess\n' + 'def replace(function):\n return subprocess.run\n' + + mutation + + 'class GoAliasPopen:\n' + ' @property\n' + ' def returncode(self):\n' + ' return self._process.returncode\n' + 'GoAliasPopen.returncode(["gh", "workflow", "run", "ci.yml"])\n' + ) + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect(body)) + for acquisition in ( + 'ns = locals()["__builtins__"]\n', + 'ns = vars()["__builtins__"]\n', + 'import sys\nns = sys.modules["builtins"]\n', + 'from sys import modules\nns = modules["builtins"]\n', + 'from sys import *\nns = modules["builtins"]\n', + 'lookup, = (globals,)\nns = lookup()["__builtins__"]\n', + ): + body = ( + 'import subprocess\n' + 'def replace(function):\n return subprocess.run\n' + + acquisition + + 'if isinstance(ns, dict):\n ns["property"] = replace\n' + 'else:\n ns.property = replace\n' + 'class GoAliasPopen:\n' + ' @property\n' + ' def returncode(self):\n' + ' return self._process.returncode\n' + 'GoAliasPopen.returncode(["gh", "workflow", "run", "ci.yml"])\n' + ) + with self.subTest(acquisition=acquisition): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('def reviewed():\n return "safe"\nprint(reviewed())\n')) + + def test_output_sink_method_alias_keeps_sensitive_taint(self) -> None: + unsafe = ( + 'import os, sys\nemit = sys.stdout.write\nemit(str(os.environ))\n', + 'import os, sys\nemit = sys.stdout.write\nagain = emit\nagain(str(os.environ))\n', + 'import os, sys\nsinks = {"emit": sys.stdout.write}\nsinks["emit"](str(os.environ))\n', + 'import os, sys\nsinks = [sys.stdout.write]\nsinks[0](str(os.environ))\n', + 'import os, sys\nlookup = getattr\nemit = lookup(sys.stdout, "write")\nemit(str(os.environ))\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import sys\nemit = sys.stdout.write\nemit("reviewed")\n' + )) + + def test_filesystem_mutator_cannot_hide_in_container_binding(self) -> None: + unsafe = ( + 'import os\nactions = {"delete": os.remove}\nactions["delete"]("/tmp/maintainer-owned")\n', + 'import os\nactions = [os.remove]\nactions[0]("/tmp/maintainer-owned")\n', + 'from os import remove as erase\nactions = {"delete": erase}\nactions["delete"]("/tmp/maintainer-owned")\n', + 'import os as operating\nactions = {"delete": operating.remove}\nactions["delete"]("/tmp/maintainer-owned")\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + def test_constructor_and_output_sink_aliases_preserve_sensitive_taint(self) -> None: + unsafe = ( + 'import os\n' + 'maker = dict\n' + 'value = maker(os.environ)\n' + 'print(value)\n', + 'import os\n' + 'emit = print\n' + 'emit(os.environ)\n', + 'import os\n' + 'import warnings\n' + 'emit = warnings.warn\n' + 'emit(os.environ)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'maker = dict\n' + 'print(maker({"status": "ready"}))\n', + 'emit = print\n' + 'emit("reviewed")\n', + 'import warnings\n' + 'emit = warnings.warn\n' + 'emit("reviewed")\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_assigned_sensitive_constructor_aliases_preserve_taint(self) -> None: + unsafe = ( + 'import os\nmaker = list\nvalue = maker(os.environ)\nprint(value)\n', + 'import os\nmaker = tuple\nvalue = maker(os.environ)\nprint(value)\n', + 'import os\nmaker = set\nvalue = maker(os.environ)\nprint(value)\n', + 'import os\nmaker = str\nvalue = maker(os.environ)\nprint(value)\n', + 'import os\nmaker = repr\nvalue = maker(os.environ)\nprint(value)\n', + 'import os\nmaker = bytes\n' + 'value = maker(next(iter(os.environ.values()), "").encode())\n' + 'print(value)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'maker = list\nvalue = maker(["status: reviewed"])\nprint(value)\n', + 'maker = tuple\nvalue = maker(("status: reviewed",))\nprint(value)\n', + 'maker = set\nvalue = maker({"status: reviewed"})\nprint(value)\n', + 'maker = str\nvalue = maker("status: reviewed")\nprint(value)\n', + 'maker = repr\nvalue = maker("status: reviewed")\nprint(value)\n', + 'maker = bytes\nvalue = maker(b"status: reviewed")\nprint(value)\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_home_and_decoded_local_paths_are_not_disclosed(self) -> None: + unsafe = ( + 'from pathlib import Path\nprint(Path.home())\n', + 'from pathlib import Path as P\nprint(P.home())\n', + 'import pathlib as pl\nprint(pl.Path.home())\n', + 'from pathlib import Path\nhome = Path.home\nprint(home())\n', + 'from pathlib import Path\nprint(Path("~").expanduser())\n', + 'import os\nprint(os.path.expanduser("~"))\n', + 'import os\nfrom pathlib import Path\n' + 'print(os.fsdecode(Path.cwd().resolve()))\n', + 'import os\nhome = os.environ["HOME"]\nprint(home)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'root = Path.home()\n' + 'if not root.is_absolute():\n' + ' raise SystemExit("invalid home root")\n', + 'from pathlib import Path as P\n' + 'print(P("docs/evidence/g01-recovery-packet.md"))\n', + 'import pathlib as pl\n' + 'print(pl.Path("docs/evidence/g01-recovery-packet.md"))\n', + 'from pathlib import Path as P\n' + 'is_absolute = P("docs/evidence/g01-recovery-packet.md").is_absolute\n' + 'if is_absolute():\n raise SystemExit("unexpected absolute path")\n', + 'from pathlib import Path\n' + 'print(Path("docs/evidence/g01-recovery-packet.md").expanduser())\n', + 'import os\nprint(os.path.expanduser("docs/evidence/g01-recovery-packet.md"))\n', + 'import os\nfrom pathlib import Path\n' + 'print(os.fsdecode(Path("docs/evidence/g01-recovery-packet.md").as_posix().encode()))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_current_directory_path_aliases_are_not_disclosed(self) -> None: + unsafe = ( + 'from pathlib import Path as P\nprint(P.cwd())\n', + 'import pathlib as pl\nprint(pl.Path.cwd())\n', + 'from pathlib import Path\ncwd = Path.cwd\nprint(cwd())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'cwd = Path.cwd\n' + 'if not cwd().is_absolute():\n' + ' raise SystemExit("invalid working root")\n', + 'from pathlib import Path as P\n' + 'print(P("docs/evidence/g01-recovery-packet.md"))\n', + 'import pathlib as pl\n' + 'print(pl.Path("docs/evidence/g01-recovery-packet.md"))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_path_method_alias_chains_respect_lexical_shadowing(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'home = Path.home\n' + 'other = home\n' + 'print(other())\n', + 'from pathlib import Path\n' + 'cwd = Path.cwd\n' + 'other = cwd\n' + 'print(other())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'home = Path.home\n' + 'def report(home):\n' + ' print(home())\n', + 'from pathlib import Path\n' + 'cwd = Path.cwd\n' + 'def report(cwd):\n' + ' print(cwd())\n', + 'from pathlib import Path\n' + 'home = other\n' + 'other = home\n' + 'print(other())\n', + 'from pathlib import Path\n' + 'print(Path("docs/evidence/g01-recovery-packet.md"))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_path_method_aliases_follow_defaults_getattr_and_namedexpr(self) -> None: + unsafe = ( + 'from pathlib import Path as P\n' + 'def report(home=P.home):\n' + ' print(home())\n' + 'report()\n', + 'from pathlib import Path as P\n' + 'get = getattr\n' + 'home = get(P, "home")\n' + 'print(home())\n', + 'from pathlib import Path as P\n' + 'print((home := P.home)())\n', + 'from pathlib import Path as P\n' + 'print((cwd := getattr(P, "cwd"))())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path as P\n' + 'print(P("docs/evidence/g01-recovery-packet.md"))\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_path_home_alias_reassignment_and_helpers_remain_safe(self) -> None: + safe = ( + 'from pathlib import Path as P\n' + 'home = P.home\n' + 'home = lambda: "reviewed"\n' + 'print(home())\n', + 'from pathlib import Path as P\n' + 'home = P.home\n' + 'home = lambda: "reviewed"\n' + 'def report():\n' + ' return home()\n' + 'print(report())\n', + 'from pathlib import Path as P\n' + 'home = P.home\n' + 'home = lambda: "reviewed"\n' + 'class Report:\n' + ' def home(self):\n' + ' return "reviewed"\n' + 'print(Report().home())\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_path_home_alias_conditional_reassignment_retains_taint(self) -> None: + body = ( + 'from pathlib import Path as P\n' + 'home = P.home\n' + 'if False:\n' + ' home = lambda: "reviewed"\n' + 'print(home())\n' + ) + self.assertIsNotNone(self.inspect(body)) + + def test_named_expression_callable_sink_preserves_environment_taint(self) -> None: + self.assertIsNotNone(self.inspect('import os\n(emit := print)(os.environ)\n')) + self.assertIsNone(self.inspect('emit = print\nemit("status: reviewed")\n')) + + def test_named_expression_sink_alias_chain_preserves_environment_taint(self) -> None: + unsafe = ( + 'import os\n' + 'emit = print\n' + '(alias := emit)(os.environ)\n', + 'import os\n' + '(alias := print)(os.environ)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'emit = print\n' + '(alias := emit)({"status": "reviewed"})\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_bash_indirect_environment_expansion_rejects_credential_names(self) -> None: + self.assertIsNotNone(self.shell_violation('name=GH_TOKEN; printf "%s\\n" "${!name}"')) + self.assertIsNone(self.shell_violation('printf "%s\\n" "status: reviewed"')) + + def test_shell_credential_assignment_aliases_are_rejected(self) -> None: + unsafe = ( + "secret=$GH_TOKEN\nprintf '%s\\n' $secret", + "secret=$GH_TOKEN\ncopy=$secret\nprintf '%s\\n' $copy", + "secret=$GH_TOKEN\n[ 1 = 2 ] && secret=reviewed\nprintf '%s\\n' \"$secret\"", + ) + for commands in unsafe: + with self.subTest(commands=commands): + self.assertIsNotNone(self.shell_document_violation(commands)) + + for commands in ( + "secret=reviewed\nprintf '%s\\n' $secret", + "secret=$GH_TOKEN\nsecret=reviewed\nprintf '%s\\n' $secret", + ): + with self.subTest(commands=commands): + self.assertIsNone(self.shell_document_violation(commands)) + + def test_conditional_and_env_prefix_assignments_do_not_clear_shell_taint(self) -> None: + commands_by_case = { + "conditional-if": ( + "secret=$GH_TOKEN\n" + "if [ 1 = 2 ]; then secret=reviewed; fi\n" + "printf '%s\\n' \"$secret\"" + ), + "env-prefix": ( + "secret=$GH_TOKEN\n" + "env secret=reviewed printf '%s\\n' \"$secret\"" + ), + } + for label, commands in commands_by_case.items(): + with self.subTest(label=label): + markdown = f"```sh\n{commands}\n```\n" + output_violation = None + for command, _line in self.scanner["shell_commands"](markdown): # type: ignore[operator] + for segment in self.scanner["shell_token_segments"](command): # type: ignore[operator] + if "printf" in segment: + output_violation = self.scanner["forbidden_shell_command"](segment) # type: ignore[operator] + self.assertIsNotNone(output_violation) + + def test_shell_parameter_modifier_keeps_sensitive_assignment_taint(self) -> None: + commands = ( + "secret=${GH_TOKEN#x}\n" + "printf '%s\\n' \"$secret\"" + ) + markdown = f"```sh\n{commands}\n```\n" + output_violation = None + for command, _line in self.scanner["shell_commands"](markdown): # type: ignore[operator] + for segment in self.scanner["shell_token_segments"](command): # type: ignore[operator] + if segment and segment[0] == "printf": + output_violation = self.scanner["forbidden_shell_command"](segment) # type: ignore[operator] + self.assertIsNotNone(output_violation) + + def test_shell_indirect_environment_expansion_in_assignment_is_rejected(self) -> None: + self.assertIsNotNone( + self.shell_document_violation( + "name=GH_TOKEN\nsecret=${!name}\nprintf '%s\\n' \"$secret\"" + ) + ) + + def test_shell_environment_dump_readers_are_rejected(self) -> None: + for command in ( + 'awk \'BEGIN { print ENVIRON["GH_TOKEN"] }\'', + 'awk \'BEGIN { for (name in ENVIRON) print ENVIRON[name] }\'', + 'jq -n env', + 'jq -n \'env.GH_TOKEN\'', + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + + for command in ( + 'awk \'BEGIN { print "reviewed" }\'', + 'jq -n \'"reviewed"\'', + ): + with self.subTest(command=command): + self.assertIsNone(self.shell_violation(command)) + + def test_os_module_assignment_alias_cannot_hide_filesystem_mutation(self) -> None: + self.assertIsNotNone(self.inspect( + 'import os\nalias = os\nalias.remove("/tmp/maintainer-owned")\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nalias, = (os,)\nalias.remove("/tmp/maintainer-owned")\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nfor alias in (os,):\n alias.remove("synthetic-maintainer-owned")\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nfor alias in tuple([os]):\n alias.remove("synthetic-maintainer-owned")\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nfor alias in iter([os]):\n alias.remove("synthetic-maintainer-owned")\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nfor alias in filter(None, [os]):\n' + ' alias.remove("synthetic-maintainer-owned")\n' + )) + self.assertIsNotNone(self.inspect('import os\nalias = os\nprint(alias.environ)\n')) + self.assertIsNotNone(self.inspect( + 'import os\nalias = os\nprint(alias.getenv("GH_TOKEN"))\n' + )) + self.assertIsNone(self.inspect('import os\nalias = os\nprint("reviewed")\n')) + + def test_from_import_environment_alias_remains_sensitive(self) -> None: + self.assertIsNotNone(self.inspect( + 'from os import environ as inherited\nprint(inherited)\n' + )) + self.assertIsNone(self.inspect( + 'from os import environ as inherited\nprint("reviewed")\n' + )) + + def test_bash_prompt_expansion_cannot_evaluate_credential_name(self) -> None: + self.assertIsNotNone(self.shell_document_violation( + "printf -v payload '%s%s' '$' 'GH_TOKEN'; printf '%s\\n' \"${payload@P}\"" + )) + self.assertIsNone(self.shell_violation("printf '%s\\n' 'reviewed'")) + + def test_shell_reader_rejects_unreviewed_relative_credential_file(self) -> None: + self.assertIsNotNone(self.shell_violation("awk '{print}' maintainer.pem")) + self.assertIsNone(self.shell_violation("awk '{print}' docs/EXECUTION.md")) + + def test_exception_arguments_keep_environment_taint(self) -> None: + self.assertIsNotNone(self.inspect('import os\nraise RuntimeError(os.environ)\n')) + self.assertIsNotNone(self.inspect( + 'import os\nraise RuntimeError("reviewed") from RuntimeError(os.environ)\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\ncause = RuntimeError(os.environ)\n' + 'raise RuntimeError("reviewed") from cause\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\ncause = RuntimeError(RuntimeError(os.environ))\n' + 'raise RuntimeError("reviewed") from cause\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\ninner = RuntimeError(os.environ)\n' + 'cause = RuntimeError(inner)\nraise RuntimeError("reviewed") from cause\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\ninner = StopIteration(os.environ)\n' + 'cause = RuntimeError(inner)\nraise RuntimeError("reviewed") from cause\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\ninner = UserWarning(os.environ)\n' + 'cause = RuntimeError(inner)\nraise RuntimeError("reviewed") from cause\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nclass Halt(Exception):\n pass\n' + 'inner = Halt(os.environ)\ncause = RuntimeError(inner)\n' + 'raise RuntimeError("reviewed") from cause\n' + )) + self.assertIsNone(self.inspect('raise RuntimeError("reviewed")\n')) + + def test_assertion_message_cannot_disclose_environment(self) -> None: + self.assertIsNotNone(self.inspect('import os\nassert False, os.environ\n')) + self.assertIsNone(self.inspect('assert True, "reviewed"\n')) + + def test_module_dictionary_environment_access_is_rejected(self) -> None: + for body in ( + 'import os\nprint(vars(os)["environ"])\n', + 'import os\nprint(os.__dict__["environ"])\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('import os\nprint("reviewed")\n')) + + def test_getattr_alias_cannot_expose_os_environment(self) -> None: + self.assertIsNotNone(self.inspect( + 'import os\nlookup = getattr\nprint(lookup(os, "environ"))\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nlookup = getattr\nprint(lookup([os][0], "environ"))\n' + )) + self.assertIsNone(self.inspect( + 'import os\nlookup = getattr\nprint("reviewed")\n' + )) + + def test_assigned_sys_alias_cannot_reach_frame_namespace(self) -> None: + self.assertIsNotNone(self.inspect( + 'import sys\nalias = sys\nalias._getframe()\n' + )) + self.assertIsNotNone(self.inspect( + 'import sys\nalias, = (sys,)\nalias._getframe()\n' + )) + self.assertIsNone(self.inspect('import sys\nalias = sys\nprint("reviewed")\n')) + + def test_shutil_module_assignment_alias_cannot_hide_mutation(self) -> None: + self.assertIsNotNone(self.inspect( + 'import shutil\nalias = shutil\nalias.rmtree("/tmp/maintainer-owned")\n' + )) + self.assertIsNotNone(self.inspect( + 'import shutil\nalias, = (shutil,)\nalias.rmtree("/tmp/maintainer-owned")\n' + )) + self.assertIsNone(self.inspect('import shutil\nalias = shutil\nprint("reviewed")\n')) + + def test_unreviewed_shutil_entry_point_is_rejected(self) -> None: + self.assertIsNotNone(self.inspect( + 'import shutil\nshutil._rmtree_unsafe("/tmp/maintainer-owned", None, lambda *args: None)\n' + )) + self.assertIsNotNone(self.inspect( + 'import shutil\ngetattr(shutil, "_rmtree_unsafe")("/tmp/maintainer-owned", None, lambda *args: None)\n' + )) + self.assertIsNotNone(self.inspect( + 'import shutil\nvars(shutil)["_rmtree_unsafe"]("/tmp/maintainer-owned", None, lambda *args: None)\n' + )) + self.assertIsNone(self.inspect('import shutil\nprint("reviewed")\n')) + + def test_signal_aliases_require_owned_targets(self) -> None: + self.assertIsNotNone(self.inspect( + 'from os import getppid, kill\nkill(getppid(), 9)\n' + )) + self.assertIsNotNone(self.inspect( + 'import os\nsend = os.kill\nsend(1, 9)\n' + )) + self.assertIsNone(self.inspect('import os\nprint("reviewed")\n')) + + def test_awk_program_cannot_rewrite_reader_argv(self) -> None: + self.assertIsNotNone(self.shell_violation( + 'awk \'BEGIN { ARGV[1]="maintainer.pem" } {print}\' docs/EXECUTION.md' + )) + self.assertIsNone(self.shell_violation("awk '{print}' docs/EXECUTION.md")) + + def test_shell_reader_requires_explicit_reviewed_operand(self) -> None: + self.assertIsNotNone(self.shell_violation('grep -e . -- -maintainer.pem')) + self.assertIsNotNone(self.shell_violation('rg --hidden --no-ignore .')) + self.assertIsNotNone(self.shell_violation('rg --hidden --no-ignore . .')) + self.assertIsNotNone(self.shell_violation('grep -R . .')) + self.assertIsNotNone(self.shell_violation('grep -d recurse . .')) + self.assertIsNotNone(self.shell_violation('grep --directories=recurse . .')) + self.assertIsNotNone(self.shell_violation('grep --direct=recurse . .')) + self.assertIsNotNone(self.shell_violation('grep --direct recurse . .')) + self.assertIsNone(self.shell_violation('rg . docs/EXECUTION.md')) + self.assertIsNone(self.shell_violation('grep -R . docs/')) + self.assertIsNone(self.shell_violation('grep -d recurse . docs/')) + self.assertIsNone(self.shell_violation('rg -n . <<< reviewed')) + + def test_jq_environment_object_references_are_rejected(self) -> None: + for command in ( + "jq -n '$ENV'", + "jq -n '$ENV.GH_TOKEN'", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + + self.assertIsNone(self.shell_violation('jq -n \'"reviewed"\'')) + + def test_jq_external_module_loading_is_rejected(self) -> None: + self.assertIsNotNone(self.shell_violation('jq -n -L/tmp \'include "evil"; leak\'')) + self.assertIsNotNone(self.shell_violation('jq -n \'include "evil"; leak\'')) + self.assertIsNone(self.shell_violation('jq -n \'"reviewed"\'')) + + def test_path_getattr_readers_follow_local_path_and_member_returns(self) -> None: + unsafe = ( + 'from pathlib import Path\n' + 'def private_path():\n' + ' return Path("synthetic-private/file")\n' + 'def reader_name():\n' + ' return "read_text"\n' + 'reader = getattr(private_path(), reader_name())\n' + 'print(reader())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'from pathlib import Path\n' + 'def reader_name():\n' + ' return "read_text"\n' + 'reader = getattr(Path("docs/evidence/g01-recovery-packet.md"), reader_name())\n' + 'print(reader())\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_getattr_mapping_lookup_alias_preserves_launcher_provenance(self) -> None: + unsafe = ( + 'import subprocess\n' + 'launchers = {"x": subprocess.run}\n' + 'lookup = getattr(launchers, "get")\n' + 'launch = lookup("x")\n' + 'launch(["gh", "workflow", "run", "ci.yml"])\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + safe = ( + 'callbacks = {"upper": str.upper}\n' + 'lookup = getattr(callbacks, "get")\n' + 'transform = lookup("upper")\n' + 'transform("reviewed")\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + def test_mapping_lookup_alias_tracking_respects_function_scopes(self) -> None: + safe = ( + 'import subprocess\n' + 'launchers = {"x": subprocess.run}\n' + 'def unused_launcher_lookup():\n' + ' lookup = launchers.get\n' + 'def safe_local_lookup():\n' + ' lookup = str.upper\n' + ' lookup("reviewed")\n', + ) + for body in safe: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + unsafe = ( + 'import subprocess\n' + 'launchers = {"x": subprocess.run}\n' + 'def launcher_lookup():\n' + ' lookup = getattr(launchers, "get")\n' + ' lookup("x")\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + def test_reviewed_evidence_path_assignment_must_be_unique(self) -> None: + duplicate = ast.parse( + 'issue79_reviewed_evidence_paths = ("canonical",)\n' + 'issue79_reviewed_evidence_paths = ("later",)\n', + filename="", + ) + with self.assertRaises(AssertionError): + _top_level_assignment(duplicate, "issue79_reviewed_evidence_paths") + + def test_current_packet_has_no_static_scanner_violations(self) -> None: + matches: list[str] = [] + shell_command_count = 0 + for command, number in self.scanner["shell_commands"](PACKET_TEXT): # type: ignore[operator] + shell_command_count += 1 + if self.scanner["shell_process_substitution"](command): # type: ignore[operator] + matches.append(f"line {number}: shell process substitutions are not allowed") + continue + for segment in self.scanner["shell_token_segments"](command): # type: ignore[operator] + if self.scanner["python_stdin_command"](segment): # type: ignore[operator] + if self.scanner["reviewed_python_heredoc_segment"](command, segment): # type: ignore[operator] + continue + matches.append( + f"line {number}: Python stdin/heredoc execution must be " + "an isolated AST-inspected heredoc" + ) + continue + violation = self.scanner["forbidden_shell_command"](segment) # type: ignore[operator] + if violation: + matches.append(f"line {number}: {violation}") + + python_body_count = 0 + for number, body, safe_marker, invocation in self.scanner["python_heredoc_bodies"](PACKET_TEXT): # type: ignore[operator] + python_body_count += 1 + if not invocation["isolated"]: + matches.append( + f"line {number}: executable Python heredoc must use -I before body inspection" + ) + continue + if invocation["interpreter"] != "/opt/homebrew/bin/python3": + matches.append( + f"line {number}: executable Python heredoc must use absolute /opt/homebrew/bin/python3" + ) + continue + violation = self.scanner["inspect_python_heredoc"](body, safe_marker) # type: ignore[operator] + if violation: + matches.append(f"line {number}: {violation}") + + self.assertGreater(shell_command_count, 0) + self.assertGreater(python_body_count, 0) + self.assertEqual([], matches, "\n".join(matches)) + print( + f"current packet static scan: {shell_command_count} shell commands, " + f"{python_body_count} Python heredoc bodies, zero violations" + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)