From 387a647355e48d44333954fadf48d5b02335290c Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 26 Sep 2026 20:24:56 +0900 Subject: [PATCH 001/123] fix(docs): harden G01 evidence packet for issue 79 --- docs/evidence/g01-recovery-packet.md | 713 +++++++++++++++++- .../issue79_regression_test.py | 582 ++++++++++++++ 2 files changed, 1257 insertions(+), 38 deletions(-) create mode 100644 scripts/evidence_packet/issue79_regression_test.py diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a6408ed4..000d2bab 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -5825,6 +5825,7 @@ 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 @@ -5962,7 +5963,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 +5989,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) @@ -6009,6 +6022,48 @@ def git_query(arguments): ] +def run_bounded_git_packet_blob_query(blob_spec, *, cwd, env): + """Capture only the reviewed packet's HEAD blob under its separate cap.""" + expected_path = "docs/evidence/g01-recovery-packet.md" + if not isinstance(blob_spec, str) or ":" not in blob_spec: + raise SystemExit("Git packet blob revision/path was malformed") + revision, path = blob_spec.split(":", 1) + if path != expected_path or not ( + revision == "HEAD" + or ( + len(revision) in {40, 64} + and all(character in "0123456789abcdefABCDEF" for character in revision) + ) + ): + raise SystemExit("Git packet 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 the reviewed packet path and bytes to the current HEAD blob.""" + expected_path = b"docs/evidence/g01-recovery-packet.md" + if not isinstance(intent_output, bytes): + raise SystemExit("post-correction packet intent output was not bytes") + records = intent_output.split(b"\0") + if records[-1] != b"" or len(records) != 2: + raise SystemExit("post-correction packet intent output was malformed") + record = records[0] + if len(record) < 3 or record[1:2] != b" " or record[2:] != expected_path: + raise SystemExit("post-correction packet index entry was missing or malformed") + if record[:1] in {b"S", b"s", b"h"}: + raise SystemExit("post-correction packet has skip-worktree or assume-unchanged intent") + if not isinstance(head_blob, bytes) or not isinstance(worktree_bytes, bytes): + raise SystemExit("post-correction packet byte comparison was not binary") + if head_blob != worktree_bytes: + raise SystemExit("post-correction packet bytes differ from the current HEAD blob") + + git_transport_override_names = { "GIT_EXEC_PATH", "GIT_SSH", @@ -6064,11 +6119,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( { @@ -6151,13 +6208,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 +6216,37 @@ 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() +packet_path = Path("docs/evidence/g01-recovery-packet.md") +intent_result = run_bounded_git_query( + git_query(["ls-files", "-v", "-z", "--", packet_path.as_posix()]), + cwd=Path.cwd(), + env=git_environment, +) +if intent_result.returncode != 0 or intent_result.stderr: + raise SystemExit("post-correction packet intent-bit query failed") +packet_blob_result = run_bounded_git_packet_blob_query( + f"{local}:{packet_path.as_posix()}", + cwd=Path.cwd(), + env=git_environment, +) +if packet_blob_result.returncode != 0 or packet_blob_result.stderr: + raise SystemExit("post-correction packet HEAD blob query failed") +try: + packet_worktree_bytes = packet_path.read_bytes() +except OSError: + raise SystemExit("post-correction packet worktree bytes could not be read") +require_packet_head_parity( + intent_result.stdout, + packet_blob_result.stdout, + packet_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() @@ -8073,6 +8154,14 @@ def git_subcommand(tokens): return 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_filter_attribute_violation(tokens): """Reject Git filters and external attribute configuration before reads.""" if not tokens or executable_basename(tokens[0]) != "git": @@ -8082,6 +8171,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,6 +8211,11 @@ 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_key(token.split("=", 1)[0]) + for token in tokens[1:] + ): + return "Git configuration includes are not allowed before read-only commands" return None @@ -8611,6 +8707,40 @@ 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 forbidden_command(tokens, depth=0): tokens = list(tokens) if not tokens: @@ -8644,6 +8774,9 @@ def forbidden_command(tokens, depth=0): tokens = executable_tokens(tokens) if not tokens: return None + 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: @@ -9037,6 +9170,10 @@ def python_sensitive_value_names(tree, parents): 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): @@ -9967,15 +10104,104 @@ def python_import_bindings(tree): 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) + + iterable_bindings = [] + 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 + 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() - for _ in range(len(assignment_bindings) + 1): + + def iterable_may_contain_launcher(value, seen_names=None, seen_nodes=None): + if value is None: + return False + if seen_names is None: + seen_names = set() + if seen_nodes is None: + seen_nodes = 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, seen_names, seen_nodes) + 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.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, seen_names, seen_nodes) + 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 in assignment_bindings: + 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 @@ -9991,6 +10217,26 @@ def python_import_bindings(tree): 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 @@ -11240,7 +11486,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): def python_path_receiver_expression(node, tree, parents, seen=None): - """Recognize a Path-like receiver for ambiguous mutating method names.""" + """Recognize Path-like receivers through constructors and annotations.""" if node is None: return False if seen is None: @@ -11254,6 +11500,12 @@ def python_path_receiver_expression(node, tree, parents, seen=None): dotted = python_dotted_name(node.func) if dotted in {"Path", "pathlib.Path"}: return True + if ( + dotted in {"Path.cwd", "pathlib.Path.cwd"} + and not node.args + and not node.keywords + ): + return True if ( isinstance(node.func, ast.Attribute) and node.func.attr == "joinpath" @@ -11262,14 +11514,23 @@ def python_path_receiver_expression(node, tree, parents, seen=None): return False if isinstance(node, ast.Name): for candidate in ast.walk(tree): - if not isinstance(candidate, ast.Assign): + 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 == node.id - for target in candidate.targets + for target in targets ): continue - if python_path_receiver_expression(candidate.value, tree, parents, seen): + if python_path_receiver_expression(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): @@ -11985,11 +12246,268 @@ def python_sensitive_output_sink(node): ) +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_reviewed_go_package_directory(node, tree, parents): + """Accept source enumeration only after the packet's package-root guard.""" + scope = python_enclosing_scope(node, parents) + if not ( + isinstance(node, ast.Name) + and node.id == "package_dir" + and isinstance(scope, ast.FunctionDef) + and scope.name == "source_fuzz_guard" + ): + 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: + continue + if python_enclosing_scope(candidate, parents) is not scope: + continue + guarded = any( + isinstance(call, ast.Call) + and python_dotted_name(call.func) == "package_dir.relative_to" + and len(call.args) == 1 + and python_path_division_names(call.args[0]) == expected_root + for statement in candidate.body + for call in ast.walk(statement) + ) + fail_closed = any( + isinstance(handler.type, ast.Name) + and handler.type.id == "ValueError" + and any(isinstance(statement, ast.Raise) for statement in handler.body) + for handler in candidate.handlers + ) + 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.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_assignment = any( + 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" + for candidate in ast.walk(link_loop) + ) + guarded = False + for candidate in ast.walk(link_loop): + if not isinstance(candidate, ast.Try) or candidate.end_lineno >= node.lineno: + continue + checks_repository_root = any( + isinstance(call, ast.Call) + and python_dotted_name(call.func) == "path.relative_to" + and len(call.args) == 1 + and isinstance(call.args[0], ast.Name) + and call.args[0].id == "repository_root" + for statement in candidate.body + for call in ast.walk(statement) + ) + 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 checks_repository_root and skips_outside_paths: + 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_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) + path_reader_aliases = python_path_reader_aliases(tree, parents) 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" @@ -12070,12 +12588,28 @@ 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 + and python_path_receiver_expression( + node.func.value, tree, parents + ) + ): + 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 +12852,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): @@ -12335,7 +12896,11 @@ def python_path_reader_aliases(tree): if not isinstance(target, ast.Name): continue receiver = None - if isinstance(value, ast.Attribute) and value.attr in {"read_text", "read_bytes"}: + if ( + isinstance(value, ast.Attribute) + and value.attr in python_path_filesystem_read_methods + and python_path_receiver_expression(value.value, tree, parents) + ): receiver = value.value elif isinstance(value, ast.Name) and value.id in aliases: receiver = aliases[value.id] @@ -25183,19 +25748,91 @@ 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 -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 +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. + +#### Current issue #79 candidate certification + +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`. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py new file mode 100644 index 00000000..543cd247 --- /dev/null +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -0,0 +1,582 @@ +"""Offline, non-executing regression probes for the seven issue #79 findings. + +Python examples and shell commands supplied to the packet scanner remain data: +the harness parses/inspects them but never evaluates or launches them. The only +child processes created below are literal Git commands against temporary local +repositories owned by these tests. +""" + +from __future__ import annotations + +import ast +import os +import re +import shlex +import selectors +import signal +import subprocess +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") + return source + + +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, + } + for statement in module.body: + if isinstance(statement, (ast.Import, ast.ImportFrom)): + exec(compile(ast.Module(body=[statement], type_ignores=[]), "", "exec"), namespace) + 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 or value is None: + continue + if _safe_assignment_expression(value, namespace): + exec(compile(ast.Module(body=[statement], type_ignores=[]), "", "exec"), namespace) + 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: + 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 + ): + return statement + if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name) and statement.target.id == name: + return statement + raise AssertionError(f"verification template assignment {name!r} is missing") + + +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: + for statement in module.body: + if isinstance(statement, ast.FunctionDef) and statement.name == name: + return statement + raise AssertionError(f"verification helper {name!r} is missing") + + +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 _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: + 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 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_command"](shlex.split(command)) # type: ignore[operator] + + 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', + ) + 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\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', + '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 reviewed package root")\n' + ' return list(package_dir.glob("*.go"))\n', + ) + for body in safe_bodies: + with self.subTest(body=body): + self.assertIsNone(self.inspect(body)) + + 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' + self.assertIsNotNone(self.inspect(loop)) + self.assertIsNotNone(self.inspect(comprehension)) + safe = 'for value in ["reviewed"]:\n print(value)\n' + self.assertIsNone(self.inspect(safe)) + + 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' + ) + for body in (direct, container, comprehension): + 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_environment_dump_builtins_are_narrowly_allowed(self) -> None: + for command in ("export", "export -p", "set", "set -o posix"): + 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", + ): + 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."): + ] + required_order = ( + 'git_query(["rev-parse", "HEAD"])', + 'git_query(["ls-files", "-v", "-z", "--", packet_path.as_posix()])', + 'run_bounded_git_packet_blob_query(\n f"{local}:{packet_path.as_posix()}"', + "packet_path.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__, + } + 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 = Path("docs/evidence/g01-recovery-packet.md") + packet = root / relative + packet.parent.mkdir(parents=True) + reviewed_bytes = b"synthetic reviewed packet bytes\x00\n" + changed_bytes = b"synthetic modified packet bytes\x00\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, + "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, + ) + head = _run_git_checked(["rev-parse", "HEAD"], root, env).decode().strip() + 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, packet.read_bytes()) + + for flag, clear_flag in ( + ("--skip-worktree", "--no-skip-worktree"), + ("--assume-unchanged", "--no-assume-unchanged"), + ): + _run_git_checked(["update-index", flag, relative.as_posix()], root, env) + packet.write_bytes(changed_bytes) + 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), + packet.read_bytes(), + ) + _run_git_checked(["update-index", clear_flag, relative.as_posix()], root, env) + packet.write_bytes(reviewed_bytes) + + packet.write_bytes(changed_bytes) + 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), + packet.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_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", + ): + 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)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 24c1253ad5d62fc02a56bab0b426edc69e0e1708 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 27 Sep 2026 21:22:00 +0900 Subject: [PATCH 002/123] Harden issue 79 evidence scanner review findings --- docs/evidence/g01-recovery-packet.md | 790 +++++++++++++++++- .../issue79_regression_test.py | 324 ++++++- 2 files changed, 1080 insertions(+), 34 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 000d2bab..616a79b2 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -6013,6 +6013,7 @@ def run_bounded_git_query( def git_query(arguments): return [ "git", + "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", @@ -8211,6 +8212,8 @@ def git_filter_attribute_violation(tokens): for token in tokens ): return "Git filter/external-attributes configuration is not allowed" + if subcommand == "config" and "--includes" in tokens: + 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:] @@ -8741,10 +8744,25 @@ def shell_environment_builtin_violation(tokens): 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 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" + return None + + def forbidden_command(tokens, depth=0): tokens = list(tokens) if not tokens: return None + original_tokens = list(tokens) sensitive_parameter_violation = shell_sensitive_parameter_violation(tokens) if sensitive_parameter_violation: return sensitive_parameter_violation @@ -8773,6 +8791,8 @@ 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 environment_builtin_violation = shell_environment_builtin_violation(tokens) if environment_builtin_violation: @@ -8789,6 +8809,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 @@ -9120,6 +9146,16 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen for argument in node.args ): return True + if isinstance(node.func, ast.Name) and node.func.id in { + "enumerate", "filter", "iter", "map", "next", "reversed", + "sorted", "zip", + }: + return any( + python_sensitive_value_expression( + argument, sensitive_names, tree, parents, seen.copy() + ) + for argument in node.args + ) if isinstance(node.func, ast.Attribute): return python_sensitive_value_expression( node.func.value, sensitive_names, tree, parents, seen.copy() @@ -9178,6 +9214,8 @@ def python_sensitive_value_names(tree, parents): 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: @@ -10110,6 +10148,7 @@ def python_import_bindings(tree): assigned_values.setdefault(target.id, []).append(value) 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 @@ -10117,6 +10156,10 @@ def python_import_bindings(tree): 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) @@ -10165,6 +10208,22 @@ def python_import_bindings(tree): return False if python_call_derived_command_alias(value, modules): return True + if isinstance(value, ast.Call): + if ( + isinstance(value.func, ast.Attribute) + and value.func.attr in {"items", "values"} + ): + return iterable_may_contain_launcher( + value.func.value, seen_names, seen_nodes + ) + if ( + isinstance(value.func, ast.Name) + and value.func.id in {"enumerate", "iter", "list", "set", "tuple", "zip"} + ): + return any( + iterable_may_contain_launcher(argument, seen_names, seen_nodes) + for argument in value.args + ) if isinstance( value, ( @@ -10244,7 +10303,11 @@ def python_import_bindings(tree): break for alias in candidate_aliases: modules.pop(alias, None) - functions[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) @@ -10386,8 +10449,15 @@ def python_class_command_attribute_violation(tree, modules, functions): 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 @@ -11498,17 +11568,34 @@ def python_path_receiver_expression(node, tree, parents, seen=None): return True if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) - if dotted in {"Path", "pathlib.Path"}: + module_aliases, constructor_aliases = python_path_constructor_aliases(tree) + if isinstance(node.func, ast.Name) and node.func.id in constructor_aliases: + return True + 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 + ): return True if ( dotted in {"Path.cwd", "pathlib.Path.cwd"} - and not node.args + 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 == "joinpath" + and node.func.attr in {"joinpath", "resolve"} ): return python_path_receiver_expression(node.func.value, tree, parents, seen) return False @@ -11532,6 +11619,8 @@ def python_path_receiver_expression(node, tree, parents, seen=None): continue 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) @@ -12255,15 +12344,330 @@ def python_path_division_names(node): def python_reviewed_go_package_directory(node, tree, parents): - """Accept source enumeration only after the packet's package-root guard.""" + """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 + 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 any(isinstance(candidate, ast.Raise) for candidate in ast.walk(statement)) + ): + continue + 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 + + 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 + + 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 + + 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): @@ -12503,6 +12907,292 @@ def python_reviewed_path_reader(node, method, 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 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_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) + ): + current = parents.get(current) + if current is None: + return False + 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_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 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_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 + if attribute.attr == "group" and python_regex_match_receiver( + attribute.value, tree + ): + return True + if attribute.attr != "walk" or not isinstance(attribute.value, ast.Name): + return False + 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 = {} + 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(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 + 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): + 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 + + if seen is not None: + return yields_match(node, set(seen)) + return yields_match(node) + + def python_sensitive_read_violation(tree, parents): """Reject environment/credential reads and unreviewed file read sinks.""" sensitive_names = python_sensitive_value_names(tree, parents) @@ -12591,10 +13281,20 @@ def python_sensitive_read_violation(tree, parents): if ( isinstance(node.func, ast.Attribute) and node.func.attr in python_path_filesystem_read_methods - and python_path_receiver_expression( - node.func.value, tree, parents - ) ): + 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 ): @@ -12899,7 +13599,7 @@ def python_path_reader_aliases(tree, parents): if ( isinstance(value, ast.Attribute) and value.attr in python_path_filesystem_read_methods - and python_path_receiver_expression(value.value, tree, parents) + and not python_known_non_path_reader_call(value, tree) ): receiver = value.value elif isinstance(value, ast.Name) and value.id in aliases: @@ -24660,10 +25360,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 @@ -24755,10 +25457,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 ~~~ @@ -25824,7 +26526,7 @@ 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. -#### Current issue #79 candidate certification +#### 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; @@ -25836,3 +26538,55 @@ 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 coordinator-owned `docs/decisions/0004-offline-python-ast-regression-tooling.md`; that file is outside this worker commit and remains for coordinator integration/review. | +| 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 543cd247..8890764a 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -1,4 +1,4 @@ -"""Offline, non-executing regression probes for the seven issue #79 findings. +"""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. The only @@ -9,6 +9,7 @@ from __future__ import annotations import ast +import builtins import os import re import shlex @@ -129,6 +130,86 @@ def _scanner_module_source(packet: str) -> str: return source +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") + return tuple(module.body) + + def _scanner_namespace() -> dict[str, object]: source = _scanner_module_source(PACKET_TEXT) module = ast.parse(source, filename="") @@ -144,9 +225,12 @@ def _scanner_namespace() -> dict[str, object]: "types": types, "source": PACKET_TEXT, } - for statement in module.body: + validated_statements = _validated_scanner_statements(module) + for statement in validated_statements: if isinstance(statement, (ast.Import, ast.ImportFrom)): - exec(compile(ast.Module(body=[statement], type_ignores=[]), "", "exec"), namespace) + # 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)): @@ -159,7 +243,14 @@ def _scanner_namespace() -> dict[str, object]: if "source" in names or value is None: continue if _safe_assignment_expression(value, namespace): - exec(compile(ast.Module(body=[statement], type_ignores=[]), "", "exec"), 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) namespace["source"] = PACKET_TEXT return namespace @@ -359,6 +450,21 @@ def test_path_filesystem_readers_require_reviewed_paths(self) -> None: '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\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): @@ -368,24 +474,55 @@ def test_path_filesystem_readers_require_reviewed_paths(self) -> None: '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', - '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 reviewed package root")\n' - ' return list(package_dir.glob("*.go"))\n', ) for body in safe_bodies: with self.subTest(body=body): self.assertIsNone(self.inspect(body)) + 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' - self.assertIsNotNone(self.inspect(loop)) - self.assertIsNotNone(self.inspect(comprehension)) + 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)) @@ -406,14 +543,42 @@ def test_launcher_aliases_from_iterables_are_rejected(self) -> None: '[launch(["gh", "workflow", "run", "ci.yml"]) ' 'for launch in [subprocess.run]]\n' ) - for body in (direct, container, comprehension): + 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_environment_dump_builtins_are_narrowly_allowed(self) -> None: - for command in ("export", "export -p", "set", "set -o posix"): + 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 ( @@ -421,6 +586,7 @@ def test_environment_dump_builtins_are_narrowly_allowed(self) -> None: "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)) @@ -564,12 +730,74 @@ def test_large_packet_blob_uses_a_separate_bounded_capture(self) -> None: 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_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", ): with self.subTest(command=command): self.assertIsNotNone(self.shell_violation(command)) @@ -577,6 +805,70 @@ def test_git_config_include_options_are_rejected_before_read_only_classification with self.subTest(command=command): self.assertIsNone(self.shell_violation(command)) + 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_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_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) From 00fc5c49a91964a44b6622cd014791ba2589dccf Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 27 Sep 2026 21:23:26 +0900 Subject: [PATCH 003/123] docs: record scoped Python evidence harness exception --- ...4-offline-python-ast-regression-tooling.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/decisions/0004-offline-python-ast-regression-tooling.md 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..aee97447 --- /dev/null +++ b/docs/decisions/0004-offline-python-ast-regression-tooling.md @@ -0,0 +1,75 @@ +# 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 -B` to avoid bytecode artifacts +and 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. From 9548096f8c8eb3ba7746b939a645901f58cb2835 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 27 Sep 2026 23:07:26 +0900 Subject: [PATCH 004/123] fix evidence scanner review gaps --- docs/evidence/g01-recovery-packet.md | 307 ++++++++++++++++-- .../issue79_regression_test.py | 297 +++++++++++++++++ 2 files changed, 583 insertions(+), 21 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 616a79b2..2a88a468 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8163,6 +8163,12 @@ def git_config_include_key(key): ) 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.""" if not tokens or executable_basename(tokens[0]) != "git": @@ -8212,7 +8218,9 @@ def git_filter_attribute_violation(tokens): for token in tokens ): return "Git filter/external-attributes configuration is not allowed" - if subcommand == "config" and "--includes" in tokens: + 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]) @@ -8750,6 +8758,8 @@ def git_config_environment_include_violation(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] @@ -9146,9 +9156,41 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen for argument in node.args ): return True + if isinstance(node.func, ast.Name): + local_generators = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) + and candidate.name == node.func.id + ] + for function in local_generators: + yielded_values = [ + candidate.value + for candidate in ast.walk(function) + if isinstance(candidate, (ast.Yield, ast.YieldFrom)) + and candidate.value is not None + and python_enclosing_scope(candidate, parents) is function + ] + if yielded_values and any( + python_sensitive_value_expression( + value, sensitive_names, tree, parents, seen.copy() + ) + for value in yielded_values + ): + return True if isinstance(node.func, ast.Name) and node.func.id in { "enumerate", "filter", "iter", "map", "next", "reversed", - "sorted", "zip", + "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( @@ -9187,6 +9229,10 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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()) @@ -10146,6 +10192,16 @@ def python_import_bindings(tree): for target, value, _destructured in assignment_bindings: if isinstance(target, ast.Name) and value is not None: assigned_values.setdefault(target.id, []).append(value) + local_functions = { + node.name: node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } iterable_bindings = [] starred_iterable_aliases = set() @@ -10172,13 +10228,17 @@ def python_import_bindings(tree): # 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): + 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)) @@ -10194,7 +10254,12 @@ def python_import_bindings(tree): return False seen_names.add(value.id) return any( - iterable_may_contain_launcher(candidate, seen_names, seen_nodes) + 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): @@ -10209,21 +10274,73 @@ def python_import_bindings(tree): if python_call_derived_command_alias(value, modules): return True if isinstance(value, ast.Call): + if isinstance(value.func, ast.Name) and value.func.id in local_functions: + function_name = value.func.id + if function_name not in seen_functions: + function = local_functions[function_name] + returned_values = [ + candidate.value + for candidate in ast.walk(function) + if isinstance(candidate, ast.Return) + and candidate.value is not None + and python_enclosing_scope(candidate, parents) is function + ] + if any( + iterable_may_contain_launcher( + candidate, + set(seen_names), + set(seen_nodes), + seen_functions | {function_name}, + ) + for candidate in returned_values + ): + return True if ( isinstance(value.func, ast.Attribute) - and value.func.attr in {"items", "values"} + and value.func.attr in {"items", "keys", "values"} ): return iterable_may_contain_launcher( - value.func.value, seen_names, seen_nodes + value.func.value, + set(seen_names), + set(seen_nodes), + set(seen_functions), ) if ( isinstance(value.func, ast.Name) - and value.func.id in {"enumerate", "iter", "list", "set", "tuple", "zip"} + and value.func.id in { + "dict", "enumerate", "iter", "list", "reversed", "set", + "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, seen_names, seen_nodes) + 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, ( @@ -10242,7 +10359,12 @@ def python_import_bindings(tree): ), ): return any( - iterable_may_contain_launcher(child, seen_names, seen_nodes) + 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, @@ -10270,6 +10392,10 @@ def python_import_bindings(tree): 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 @@ -12316,6 +12442,72 @@ python_sensitive_sink_methods = { } +def python_resolved_local_path_expression( + node, + tree, + parents, + assignments_by_name, + seen=None, +): + """Track resolved local paths into output sinks without rejecting checks.""" + if node is None: + return False + if seen is None: + seen = set() + if id(node) in seen: + return False + seen.add(id(node)) + if isinstance(node, ast.Name): + scope = python_enclosing_scope(node, parents) + for assigned_scope, value in assignments_by_name.get(node.id, ()): + if assigned_scope is scope and python_resolved_local_path_expression( + value, + tree, + parents, + assignments_by_name, + seen.copy(), + ): + return True + return False + if isinstance(node, ast.Call): + dotted = python_dotted_name(node.func) + if dotted in {"Path.cwd", "pathlib.Path.cwd"}: + return True + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "resolve" + ): + return True + path_preserving_calls = { + "Path", + "pathlib.Path", + "str", + "repr", + "os.fspath", + "os.path.abspath", + "os.path.realpath", + } + if dotted not in path_preserving_calls and not ( + isinstance(node.func, ast.Attribute) + and node.func.attr in {"as_posix", "as_uri"} + ): + return False + return any( + python_resolved_local_path_expression( + child, + tree, + parents, + assignments_by_name, + seen.copy(), + ) + 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_sensitive_output_sink(node): """Recognize output/error sinks without tainting ordinary containers/helpers.""" if not isinstance(node, ast.Call): @@ -12407,7 +12599,9 @@ def python_reviewed_go_package_directory(node, tree, parents): and test.comparators[0].value == expected ) ) - and any(isinstance(candidate, ast.Raise) for candidate in ast.walk(statement)) + and len(statement.body) == 1 + and isinstance(statement.body[0], ast.Raise) + and not statement.orelse ): continue return True @@ -12697,18 +12891,21 @@ def python_reviewed_go_package_directory(node, tree, parents): continue if python_enclosing_scope(candidate, parents) is not scope: continue - guarded = any( - isinstance(call, ast.Call) - and python_dotted_name(call.func) == "package_dir.relative_to" - and len(call.args) == 1 - and python_path_division_names(call.args[0]) == expected_root - for statement in candidate.body - for call in ast.walk(statement) + 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 any(isinstance(statement, ast.Raise) for statement in handler.body) + and len(handler.body) == 1 + and isinstance(handler.body[0], ast.Raise) for handler in candidate.handlers ) if guarded and fail_closed: @@ -13198,6 +13395,21 @@ def python_sensitive_read_violation(tree, parents): 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 + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets = [candidate.target] + else: + targets = [] + if targets and isinstance(candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + 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), candidate.value) + ) 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" @@ -13256,6 +13468,22 @@ 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}" ) + output_arguments = list(node.args) + [ + keyword.value for keyword in node.keywords + ] + if python_sensitive_output_sink(node) 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) and any( python_sensitive_value_expression( argument, sensitive_names, tree, parents @@ -13265,8 +13493,7 @@ def python_sensitive_read_violation(tree, parents): and python_dotted_name(candidate) == "os.environ" for candidate in ast.walk(argument) ) - 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 " @@ -26556,7 +26783,7 @@ trusted-code review boundary, not a Python sandbox. | 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 coordinator-owned `docs/decisions/0004-offline-python-ast-regression-tooling.md`; that file is outside this worker commit and remains for coordinator integration/review. | +| 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. | @@ -26590,3 +26817,41 @@ ledgers remain preserved. `python3 -B scripts/evidence_packet/issue79_regression 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, 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 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. +Full local verification after this findings subsection was added: +`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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 8890764a..78906609 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -366,6 +366,52 @@ def _safe_integer_expression(node: ast.AST) -> int: 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 = { @@ -407,6 +453,14 @@ def _bounded_git_query_namespace(module: ast.Module) -> dict[str, object]: 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, @@ -437,6 +491,45 @@ def setUpClass(cls) -> None: def inspect(self, code: str) -> str | None: return self.scanner["inspect_python_heredoc"](code, False) # type: ignore[operator] + 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] @@ -479,6 +572,21 @@ def test_path_filesystem_readers_require_reviewed_paths(self) -> None: 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', + ) + 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)) + def test_canonical_package_guard_remains_reviewed(self) -> None: bodies = [ body @@ -526,6 +634,39 @@ def test_environment_taint_reaches_loop_and_comprehension_targets(self) -> None: safe = 'for value in ["reviewed"]:\n print(value)\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' @@ -574,6 +715,97 @@ def test_launcher_aliases_from_iterables_are_rejected(self) -> None: 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_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_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_environment_dump_builtins_are_narrowly_allowed(self) -> None: for command in ( "export", "export -p", "set", "set -o posix", "env", "env -0", @@ -618,6 +850,9 @@ def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) - 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, @@ -796,8 +1031,12 @@ def test_git_config_include_options_are_rejected_before_read_only_classification "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)) @@ -805,6 +1044,64 @@ def test_git_config_include_options_are_rejected_before_read_only_classification with self.subTest(command=command): self.assertIsNone(self.shell_violation(command)) + 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_packet_loader_rejects_packet_controlled_definition_time_code(self) -> None: specimens = ( "import synthetic_side_effect\n", From 8b5f35b3d6bfea965aad3d515a35b1a3485dae6a Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 27 Sep 2026 23:23:02 +0900 Subject: [PATCH 005/123] fix launcher iterable scanner bypasses --- docs/evidence/g01-recovery-packet.md | 43 ++++++++++++++++--- .../issue79_regression_test.py | 41 +++++++++++++++++- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 2a88a468..0bcbd32b 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10281,7 +10281,9 @@ def python_import_bindings(tree): returned_values = [ candidate.value for candidate in ast.walk(function) - if isinstance(candidate, ast.Return) + if isinstance( + candidate, (ast.Return, ast.Yield, ast.YieldFrom) + ) and candidate.value is not None and python_enclosing_scope(candidate, parents) is function ] @@ -10309,7 +10311,7 @@ def python_import_bindings(tree): isinstance(value.func, ast.Name) and value.func.id in { "dict", "enumerate", "iter", "list", "reversed", "set", - "tuple", "zip", + "filter", "map", "sorted", "tuple", "zip", } ): return any( @@ -26825,7 +26827,8 @@ 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, and local helper returns; +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()` @@ -26834,7 +26837,8 @@ 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 and helper-returned callables; validates packet-derived +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; @@ -26849,9 +26853,38 @@ 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. -Full local verification after this findings subsection was added: +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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 78906609..72879e87 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -732,6 +732,43 @@ def test_launcher_aliases_survive_reversed_and_dict_conversions(self) -> None: 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' @@ -1035,8 +1072,8 @@ def test_git_config_include_options_are_rejected_before_read_only_classification "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", + "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)) From 184701d0ff26b4e8d15ce02adbaadfd6913bdb9e Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 00:08:39 +0900 Subject: [PATCH 006/123] Harden issue 79 evidence scanner --- docs/evidence/g01-recovery-packet.md | 147 ++++++++++++++---- .../issue79_regression_test.py | 105 +++++++++++++ 2 files changed, 219 insertions(+), 33 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 0bcbd32b..b01e5e35 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9157,25 +9157,25 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ): return True if isinstance(node.func, ast.Name): - local_generators = [ + local_helpers = [ candidate for candidate in ast.walk(tree) if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) and candidate.name == node.func.id ] - for function in local_generators: - yielded_values = [ + for function in local_helpers: + returned_values = [ candidate.value for candidate in ast.walk(function) - if isinstance(candidate, (ast.Yield, ast.YieldFrom)) + if isinstance(candidate, (ast.Return, ast.Yield, ast.YieldFrom)) and candidate.value is not None and python_enclosing_scope(candidate, parents) is function ] - if yielded_values and any( + if returned_values and any( python_sensitive_value_expression( value, sensitive_names, tree, parents, seen.copy() ) - for value in yielded_values + for value in returned_values ): return True if isinstance(node.func, ast.Name) and node.func.id in { @@ -10192,11 +10192,10 @@ def python_import_bindings(tree): for target, value, _destructured in assignment_bindings: if isinstance(target, ast.Name) and value is not None: assigned_values.setdefault(target.id, []).append(value) - local_functions = { - node.name: node - for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - } + local_functions = {} + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + local_functions.setdefault(node.name, []).append(node) parents = { child: parent for parent in ast.walk(tree) @@ -10277,26 +10276,26 @@ def python_import_bindings(tree): if isinstance(value.func, ast.Name) and value.func.id in local_functions: function_name = value.func.id if function_name not in seen_functions: - function = local_functions[function_name] - returned_values = [ - 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 - ] - if any( - iterable_may_contain_launcher( - candidate, - set(seen_names), - set(seen_nodes), - seen_functions | {function_name}, - ) - for candidate in returned_values - ): - return True + for function in local_functions[function_name]: + returned_values = [ + 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 + ] + if any( + iterable_may_contain_launcher( + candidate, + set(seen_names), + set(seen_nodes), + seen_functions | {function_name}, + ) + for candidate in returned_values + ): + return True if ( isinstance(value.func, ast.Attribute) and value.func.attr in {"items", "keys", "values"} @@ -12462,7 +12461,16 @@ def python_resolved_local_path_expression( if isinstance(node, ast.Name): scope = python_enclosing_scope(node, parents) for assigned_scope, value in assignments_by_name.get(node.id, ()): - if assigned_scope is scope and python_resolved_local_path_expression( + if ( + assigned_scope is scope + or ( + isinstance( + scope, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda), + ) + and assigned_scope is tree + ) + ) and python_resolved_local_path_expression( value, tree, parents, @@ -12475,6 +12483,32 @@ def python_resolved_local_path_expression( dotted = python_dotted_name(node.func) if dotted in {"Path.cwd", "pathlib.Path.cwd"}: return True + if isinstance(node.func, ast.Name): + local_helpers = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) + and candidate.name == node.func.id + ] + for function in local_helpers: + returned_values = [ + candidate.value + for candidate in ast.walk(function) + if isinstance(candidate, ast.Return) + and candidate.value is not None + and python_enclosing_scope(candidate, parents) is function + ] + if returned_values and any( + python_resolved_local_path_expression( + value, + tree, + parents, + assignments_by_name, + seen.copy(), + ) + for value in returned_values + ): + return True if ( isinstance(node.func, ast.Attribute) and node.func.attr == "resolve" @@ -12537,6 +12571,21 @@ def python_path_division_names(node): return [node.id] if isinstance(node, ast.Name) else [] +def python_try_in_unreachable_if_body(node, parents): + current = node + while current in parents: + parent = parents[current] + if ( + isinstance(parent, ast.If) + and isinstance(parent.test, ast.Constant) + and parent.test.value is False + and current in parent.body + ): + 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) @@ -12889,7 +12938,11 @@ def python_reviewed_go_package_directory(node, tree, parents): 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: + if ( + not isinstance(candidate, ast.Try) + or candidate.end_lineno >= node.lineno + or python_try_in_unreachable_if_body(candidate, parents) + ): continue if python_enclosing_scope(candidate, parents) is not scope: continue @@ -26888,3 +26941,31 @@ 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`. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 72879e87..d88cef45 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -587,6 +587,84 @@ def test_resolved_local_paths_are_not_disclosed_to_output_sinks(self) -> None: ) self.assertIsNone(self.inspect(internal_use)) + 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_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_canonical_package_guard_remains_reviewed(self) -> None: bodies = [ body @@ -1139,6 +1217,33 @@ def test_unreachable_raise_does_not_prove_package_path_containment(self) -> None ) 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")' + ) + unreachable = ( + ' 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")' + ) + mutated = self.replace_source_fuzz_guard_fragment( + bodies[0], original, unreachable + ) + self.assertIsNone(self.inspect(bodies[0])) + self.assertIsNotNone(self.inspect(mutated)) + def test_packet_loader_rejects_packet_controlled_definition_time_code(self) -> None: specimens = ( "import synthetic_side_effect\n", From 9e921283cf338c5b6d1b1c358d3735ae42f26cdd Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 01:23:10 +0900 Subject: [PATCH 007/123] fix: harden issue 79 evidence scanner flows --- docs/evidence/g01-recovery-packet.md | 279 ++++++++++++++---- .../issue79_regression_test.py | 108 ++++++- 2 files changed, 320 insertions(+), 67 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index b01e5e35..86f9b353 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9117,6 +9117,70 @@ def python_credential_reader_aliases(tree): return aliases +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_sensitive_value_expression(node, sensitive_names, tree, parents, seen=None): """Track credential values through aliases without trusting variable names.""" if node is None: @@ -9157,12 +9221,9 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ): return True if isinstance(node.func, ast.Name): - local_helpers = [ - candidate - for candidate in ast.walk(tree) - if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) - and candidate.name == node.func.id - ] + local_helpers = python_local_function_candidates( + node.func.id, node, tree, parents + ) for function in local_helpers: returned_values = [ candidate.value @@ -9269,23 +9330,20 @@ def python_sensitive_value_names(tree, parents): return names return [] - 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) + positional_parameters = list(function.args.posonlyargs) + list( + function.args.args + ) bound = [] for index, argument in enumerate(call.args): - if index >= len(parameters): + if index >= len(positional_parameters): break - bound.append((parameters[index].arg, argument)) + bound.append((positional_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: @@ -9295,7 +9353,11 @@ def python_sensitive_value_names(tree, parents): # 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): + 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 target, value in assignments: if not python_sensitive_value_expression( @@ -9309,15 +9371,15 @@ def python_sensitive_value_names(tree, parents): 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 + for function in python_local_function_candidates( + node.func.id, node, tree, parents + ): + 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 @@ -12460,17 +12522,9 @@ def python_resolved_local_path_expression( seen.add(id(node)) 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 is scope - or ( - isinstance( - scope, - (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda), - ) - and assigned_scope is tree - ) - ) and python_resolved_local_path_expression( + if assigned_scope in lexical_scopes and python_resolved_local_path_expression( value, tree, parents, @@ -12484,17 +12538,14 @@ def python_resolved_local_path_expression( if dotted in {"Path.cwd", "pathlib.Path.cwd"}: return True if isinstance(node.func, ast.Name): - local_helpers = [ - candidate - for candidate in ast.walk(tree) - if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) - and candidate.name == node.func.id - ] + local_helpers = python_local_function_candidates( + node.func.id, node, tree, parents + ) for function in local_helpers: returned_values = [ candidate.value for candidate in ast.walk(function) - if isinstance(candidate, ast.Return) + if isinstance(candidate, (ast.Return, ast.Yield, ast.YieldFrom)) and candidate.value is not None and python_enclosing_scope(candidate, parents) is function ] @@ -12509,6 +12560,19 @@ def python_resolved_local_path_expression( for value in returned_values ): 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.copy(), + ) + for argument in node.args + ) if ( isinstance(node.func, ast.Attribute) and node.func.attr == "resolve" @@ -12575,13 +12639,12 @@ def python_try_in_unreachable_if_body(node, parents): current = node while current in parents: parent = parents[current] - if ( - isinstance(parent, ast.If) - and isinstance(parent.test, ast.Constant) - and parent.test.value is False - and current in parent.body - ): - return True + if isinstance(parent, ast.If) and isinstance(parent.test, ast.Constant): + condition = bool(parent.test.value) + if (not condition and current in parent.body) or ( + condition and current in parent.orelse + ): + return True current = parent return False @@ -13454,17 +13517,64 @@ def python_sensitive_read_violation(tree, parents): 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: - targets = [] - if targets and isinstance(candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): - 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), candidate.value) - ) + 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) + + 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 parameter, argument in zip(positional, call.args): + assignments_by_name.setdefault(parameter.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) + ) 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" @@ -26969,3 +27079,60 @@ The complete command and result, `git diff --check`, and final two-file scope ar #### 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index d88cef45..541ff93f 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -639,6 +639,32 @@ def test_sensitive_local_helper_returns_are_tainted_at_output_sinks(self) -> Non ) self.assertIsNone(self.inspect(safe)) + 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' @@ -665,6 +691,46 @@ def test_resolved_local_paths_from_helpers_and_globals_reach_output_sinks(self) ) 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_canonical_package_guard_remains_reviewed(self) -> None: bodies = [ body @@ -1231,18 +1297,38 @@ def test_unreachable_package_containment_try_is_not_reviewed(self) -> None: ' except ValueError:\n' ' raise SystemExit(f"{label}: package source escaped the reviewed module")' ) - unreachable = ( - ' 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")' - ) - mutated = self.replace_source_fuzz_guard_fragment( - bodies[0], original, unreachable - ) self.assertIsNone(self.inspect(bodies[0])) - self.assertIsNotNone(self.inspect(mutated)) + 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")' + ), + ) + 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_packet_loader_rejects_packet_controlled_definition_time_code(self) -> None: specimens = ( From dac58b4adde5b3f552254700e85a229cd6d0ad1b Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 12:49:01 +0900 Subject: [PATCH 008/123] fix: close issue 79 review boundary gaps --- docs/evidence/g01-recovery-packet.md | 164 ++++++++++++++++-- .../issue79_regression_test.py | 82 +++++++++ 2 files changed, 234 insertions(+), 12 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 86f9b353..8804868f 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9341,13 +9341,33 @@ def python_sensitive_value_names(tree, parents): ) bound = [] for index, argument in enumerate(call.args): - if index >= len(positional_parameters): - break - bound.append((positional_parameters[index].arg, argument)) + 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)) + 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 # Iterate assignments and direct local-helper calls to a fixed point. The @@ -9359,6 +9379,18 @@ def python_sensitive_value_names(tree, parents): ) 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 @@ -12615,6 +12647,7 @@ def python_sensitive_output_sink(node): dotted = python_dotted_name(node.func) if dotted in { "print", + "sys.exit", "warnings.warn", "warnings.warn_explicit", "traceback.print_exc", @@ -12636,13 +12669,34 @@ def python_path_division_names(node): 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) and isinstance(parent.test, ast.Constant): - condition = bool(parent.test.value) - if (not condition and current in parent.body) or ( - condition and current in parent.orelse + 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 @@ -13024,7 +13078,7 @@ def python_reviewed_go_package_directory(node, tree, parents): and handler.type.id == "ValueError" and len(handler.body) == 1 and isinstance(handler.body[0], ast.Raise) - for handler in candidate.handlers + for handler in candidate.handlers[:1] ) if guarded and fail_closed: return True @@ -13562,10 +13616,20 @@ def python_sensitive_read_violation(tree, parents): call.func.id, call, tree, parents ): positional = helper_parameters(function) - for parameter, argument in zip(positional, call.args): - assignments_by_name.setdefault(parameter.arg, []).append( - (function, argument) - ) + 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 @@ -13575,6 +13639,27 @@ def python_sensitive_read_violation(tree, parents): assignments_by_name.setdefault(keyword.arg, []).append( (function, keyword.value) ) + if keyword.arg is None: + 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 named_parameters: + assignments_by_name.setdefault(key.value, []).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) + ) 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" @@ -13594,6 +13679,20 @@ def python_sensitive_read_violation(tree, parents): "Python credential/environment value is sent to an exception " f"on line {node.lineno}" ) + exception_values = ( + list(node.exc.args) + [keyword.value for keyword in node.exc.keywords] + if isinstance(node.exc, ast.Call) else [node.exc] + ) + 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": @@ -27136,3 +27235,44 @@ Rollback point is the immutable starting SHA `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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 541ff93f..045d9572 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -731,6 +731,62 @@ def test_resolved_local_paths_cross_helper_boundaries_to_output_sinks(self) -> 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_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_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_canonical_package_guard_remains_reviewed(self) -> None: bodies = [ body @@ -1322,6 +1378,32 @@ def test_unreachable_package_containment_try_is_not_reviewed(self) -> None: ' 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): From b79709bf4c8d9d2762c9fbaa8128ba6e6a06f969 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 13:54:13 +0900 Subject: [PATCH 009/123] Harden issue 79 evidence scanner against reviewed alias bypasses --- docs/evidence/g01-recovery-packet.md | 459 ++++++++++++++---- .../issue79_regression_test.py | 278 ++++++++++- 2 files changed, 645 insertions(+), 92 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 8804868f..049f8dbb 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9181,6 +9181,206 @@ def python_local_function_candidates(name, call, tree, parents): ] +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 + ] + + 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): + if seen_names is None: + seen_names = set() + if isinstance(value, ast.Call) and isinstance(value.func, ast.Name): + class_names.add(value.func.id) + elif isinstance(value, ast.Name) and value.id not in seen_names: + seen_names.add(value.id) + if value.id != "self": + for scope in visible_scopes: + for assignment in assignments_by_scope.get( + (id(scope), value.id), () + ): + resolve_receiver(assignment, seen_names.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) + ] + + 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} + and isinstance(value.args[1], ast.Constant) + and isinstance(value.args[1].value, str) + ): + values.extend(method_return_values(ast.Attribute( + value=value.args[0], attr=value.args[1].value, + ctx=ast.Load(), + ))) + 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_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 + + 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} + and isinstance(value.args[1], ast.Constant) + and isinstance(value.args[1].value, str) + and f"{python_dotted_name(value.args[0])}.{value.args[1].value}" == target + ): + return True + if ( + isinstance(value, ast.Subscript) + and isinstance(value.value, (ast.List, ast.Tuple)) + and isinstance(value.slice, ast.Constant) + and type(value.slice.value) is int + ): + 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_sensitive_value_expression(node, sensitive_names, tree, parents, seen=None): """Track credential values through aliases without trusting variable names.""" if node is None: @@ -9215,30 +9415,31 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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 + 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 isinstance(node.func, ast.Name): - local_helpers = python_local_function_candidates( - node.func.id, node, tree, parents + 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] ) - for function in local_helpers: - returned_values = [ - 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 - ] - if returned_values and any( - python_sensitive_value_expression( - value, sensitive_names, tree, parents, seen.copy() - ) - for value in returned_values - ): - return True if isinstance(node.func, ast.Name) and node.func.id in { "enumerate", "filter", "iter", "map", "next", "reversed", "sorted", "zip", "chain", @@ -9268,7 +9469,8 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen python_sensitive_value_expression( argument, sensitive_names, tree, parents, seen.copy() ) - for argument in node.args + for argument in list(node.args) + + [keyword.value for keyword in node.keywords] ) if isinstance(node, (ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp)): if any( @@ -9277,14 +9479,12 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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") + return any( + python_sensitive_value_expression( + generator.iter, sensitive_names, tree, parents, seen.copy() + ) 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()) @@ -9362,6 +9562,15 @@ def python_sensitive_value_names(tree, parents): 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: @@ -10286,10 +10495,6 @@ def python_import_bindings(tree): for target, value, _destructured in assignment_bindings: if isinstance(target, ast.Name) and value is not None: assigned_values.setdefault(target.id, []).append(value) - local_functions = {} - for node in ast.walk(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - local_functions.setdefault(node.name, []).append(node) parents = { child: parent for parent in ast.walk(tree) @@ -10367,29 +10572,14 @@ def python_import_bindings(tree): if python_call_derived_command_alias(value, modules): return True if isinstance(value, ast.Call): - if isinstance(value.func, ast.Name) and value.func.id in local_functions: - function_name = value.func.id - if function_name not in seen_functions: - for function in local_functions[function_name]: - returned_values = [ - 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 - ] - if any( - iterable_may_contain_launcher( - candidate, - set(seen_names), - set(seen_nodes), - seen_functions | {function_name}, - ) - for candidate in returned_values - ): - return True + 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 {"items", "keys", "values"} @@ -12569,29 +12759,37 @@ def python_resolved_local_path_expression( dotted = python_dotted_name(node.func) if dotted in {"Path.cwd", "pathlib.Path.cwd"}: return True - if isinstance(node.func, ast.Name): - local_helpers = python_local_function_candidates( - node.func.id, node, tree, parents + if any( + python_resolved_local_path_expression( + value, + tree, + parents, + assignments_by_name, + seen.copy(), ) - for function in local_helpers: - returned_values = [ - 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 - ] - if returned_values and any( - python_resolved_local_path_expression( - value, - tree, - parents, - assignments_by_name, - seen.copy(), - ) - for value in returned_values - ): - return True + for value in python_local_call_return_values(node, tree, parents) + ): + return True + if dotted == "dict" and any( + python_resolved_local_path_expression( + value, tree, parents, assignments_by_name, seen.copy() + ) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ): + return True + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "format" + and any( + python_resolved_local_path_expression( + value, tree, parents, assignments_by_name, 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 in { "iter", "list", "next", "reversed", "set", "sorted", "tuple" }: @@ -12640,7 +12838,7 @@ def python_resolved_local_path_expression( ) -def python_sensitive_output_sink(node): +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 @@ -12654,6 +12852,17 @@ def python_sensitive_output_sink(node): "traceback.print_exception", }: return True + if ( + isinstance(node.func, ast.Name) + and tree is not None + and ( + python_imported_function_alias_is_stable( + node.func.id, "sys", "exit", tree + ) + or python_assigned_callable_alias(node.func.id, "sys.exit", tree) + ) + ): + return True return ( isinstance(node.func, ast.Attribute) and node.func.attr.casefold() in python_sensitive_sink_methods @@ -13058,6 +13267,7 @@ def python_reviewed_go_package_directory(node, tree, parents): 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 @@ -13590,6 +13800,21 @@ def python_sensitive_read_violation(tree, parents): 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 @@ -13640,13 +13865,11 @@ def python_sensitive_read_violation(tree, parents): (function, keyword.value) ) if keyword.arg is None: - 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 named_parameters: - assignments_by_name.setdefault(key.value, []).append( + 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: @@ -13735,7 +13958,7 @@ def python_sensitive_read_violation(tree, parents): output_arguments = list(node.args) + [ keyword.value for keyword in node.keywords ] - if python_sensitive_output_sink(node) and any( + if python_sensitive_output_sink(node, tree) and any( python_resolved_local_path_expression( argument, tree, @@ -13748,7 +13971,7 @@ def python_sensitive_read_violation(tree, parents): "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) and any( + if python_sensitive_output_sink(node, tree) and any( python_sensitive_value_expression( argument, sensitive_names, tree, parents ) @@ -27276,3 +27499,63 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 045d9572..286ad927 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -350,10 +350,19 @@ def _safe_environment_mapping(module: ast.Module) -> dict[str, str]: def _verification_function(module: ast.Module, name: str) -> ast.FunctionDef: - for statement in module.body: - if isinstance(statement, ast.FunctionDef) and statement.name == name: - return statement - raise AssertionError(f"verification helper {name!r} is missing") + 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: @@ -639,6 +648,57 @@ def test_sensitive_local_helper_returns_are_tainted_at_output_sinks(self) -> Non ) 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', + ) + 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)) + def test_sensitive_mapping_return_survives_unrelated_nested_name_collision(self) -> None: unsafe = ( 'import os\n' @@ -747,6 +807,28 @@ def test_resolved_local_paths_cross_expanded_helper_arguments(self) -> None: 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', @@ -774,6 +856,28 @@ def test_sensitive_variadic_and_default_helper_parameters_are_tainted(self) -> 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', @@ -787,6 +891,43 @@ def test_sys_exit_is_an_output_sink_for_sensitive_values(self) -> None: 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', + ) + 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 @@ -834,6 +975,21 @@ def test_environment_taint_reaches_loop_and_comprehension_targets(self) -> None: 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' @@ -979,6 +1135,69 @@ def test_launcher_aliases_returned_by_local_helpers_are_rejected(self) -> None: ) 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' + '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', + ) + 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_bounded_git_query_loader_rejects_unreviewed_function_definitions(self) -> None: specimen = ast.parse( 'def run_bounded_git_query(value=packet_side_effect()):\n' @@ -1043,6 +1262,17 @@ def record_exec(*_args: object, **_kwargs: object) -> None: 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", @@ -1412,6 +1642,46 @@ def test_unreachable_package_containment_try_is_not_reviewed(self) -> None: ) 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", From 9233241cbd55f35746dffac8f98ff06509cd3c38 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 14:54:02 +0900 Subject: [PATCH 010/123] Close issue 79 review gaps in scanner and evidence parity --- docs/evidence/g01-recovery-packet.md | 406 +++++++++++++++--- .../issue79_regression_test.py | 219 ++++++++-- 2 files changed, 532 insertions(+), 93 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 049f8dbb..9a220e83 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -5827,6 +5827,10 @@ 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", +) def close_git_query_streams(process): @@ -6024,19 +6028,22 @@ def git_query(arguments): def run_bounded_git_packet_blob_query(blob_spec, *, cwd, env): - """Capture only the reviewed packet's HEAD blob under its separate cap.""" - expected_path = "docs/evidence/g01-recovery-packet.md" + """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", + } if not isinstance(blob_spec, str) or ":" not in blob_spec: - raise SystemExit("Git packet blob revision/path was malformed") + raise SystemExit("Git evidence blob revision/path was malformed") revision, path = blob_spec.split(":", 1) - if path != expected_path or not ( + 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 packet blob query was outside the reviewed revision/path") + raise SystemExit("Git evidence blob query was outside the reviewed revision/path") command = git_query(["show", blob_spec]) return run_bounded_git_query( command, @@ -6047,22 +6054,25 @@ def run_bounded_git_packet_blob_query(blob_spec, *, cwd, env): def require_packet_head_parity(intent_output, head_blob, worktree_bytes): - """Bind the reviewed packet path and bytes to the current HEAD blob.""" - expected_path = b"docs/evidence/g01-recovery-packet.md" + """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", + } if not isinstance(intent_output, bytes): - raise SystemExit("post-correction packet intent output was not 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 packet intent output was malformed") + raise SystemExit("post-correction evidence intent output was malformed") record = records[0] - if len(record) < 3 or record[1:2] != b" " or record[2:] != expected_path: - raise SystemExit("post-correction packet index entry was missing or malformed") + 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 packet has skip-worktree or assume-unchanged intent") + 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 packet byte comparison was not binary") + raise SystemExit("post-correction evidence byte comparison was not binary") if head_blob != worktree_bytes: - raise SystemExit("post-correction packet bytes differ from the current HEAD blob") + raise SystemExit("post-correction evidence bytes differ from the current HEAD blob") git_transport_override_names = { @@ -6217,30 +6227,39 @@ 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() -packet_path = Path("docs/evidence/g01-recovery-packet.md") -intent_result = run_bounded_git_query( - git_query(["ls-files", "-v", "-z", "--", packet_path.as_posix()]), - cwd=Path.cwd(), - env=git_environment, -) -if intent_result.returncode != 0 or intent_result.stderr: - raise SystemExit("post-correction packet intent-bit query failed") -packet_blob_result = run_bounded_git_packet_blob_query( - f"{local}:{packet_path.as_posix()}", - cwd=Path.cwd(), - env=git_environment, -) -if packet_blob_result.returncode != 0 or packet_blob_result.stderr: - raise SystemExit("post-correction packet HEAD blob query failed") -try: - packet_worktree_bytes = packet_path.read_bytes() -except OSError: - raise SystemExit("post-correction packet worktree bytes could not be read") -require_packet_head_parity( - intent_result.stdout, - packet_blob_result.stdout, - packet_worktree_bytes, -) +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() + 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(), @@ -9181,6 +9200,77 @@ def python_local_function_candidates(name, call, tree, parents): ] +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 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_local_call_return_values(call, tree, parents): """Resolve returned expressions from visible local functions, methods, and lambdas.""" if not isinstance(call, ast.Call): @@ -9252,11 +9342,18 @@ def python_local_call_return_values(call, tree, parents): def method_return_values(attribute): class_names = set() - def resolve_receiver(value, seen_names=None): + 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.add(value.func.id) + 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": @@ -9264,7 +9361,7 @@ def python_local_call_return_values(call, tree, parents): for assignment in assignments_by_scope.get( (id(scope), value.id), () ): - resolve_receiver(assignment, seen_names.copy()) + resolve_receiver(assignment, seen_names.copy(), seen_nodes.copy()) else: enclosing = call_scope while enclosing is not None and not isinstance(enclosing, ast.ClassDef): @@ -9298,13 +9395,23 @@ def python_local_call_return_values(call, tree, parents): isinstance(value, ast.Call) and python_dotted_name(value.func) == "getattr" and len(value.args) in {2, 3} - and isinstance(value.args[1], ast.Constant) - and isinstance(value.args[1].value, str) ): - values.extend(method_return_values(ast.Attribute( - value=value.args[0], attr=value.args[1].value, - ctx=ast.Load(), - ))) + 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 @@ -9326,6 +9433,69 @@ def python_local_call_return_values(call, tree, parents): return [] +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.add(value.func.id) + 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": + for scope in visible_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 [ + 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 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) @@ -9350,11 +9520,14 @@ def python_assigned_callable_alias(name, target, tree): isinstance(value, ast.Call) and python_dotted_name(value.func) == "getattr" and len(value.args) in {2, 3} - and isinstance(value.args[1], ast.Constant) - and isinstance(value.args[1].value, str) - and f"{python_dotted_name(value.args[0])}.{value.args[1].value}" == target ): - return True + 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)) @@ -9534,11 +9707,13 @@ def python_sensitive_value_names(tree, parents): positional = list(function.args.posonlyargs) + list(function.args.args) return positional + list(function.args.kwonlyargs) - def call_arguments(call, function): - parameters = function_parameters(function) + def call_arguments(call, function, bound_method=False): positional_parameters = list(function.args.posonlyargs) + list( function.args.args ) + if bound_method and positional_parameters: + 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): @@ -9610,12 +9785,36 @@ def python_sensitive_value_names(tree, parents): 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): + if not isinstance(node, ast.Call): continue - for function in python_local_function_candidates( - node.func.id, node, tree, parents + 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 ): - for parameter, argument in call_arguments(node, function): + continue + if isinstance(node.func, ast.Name): + candidates = python_local_function_candidates( + node.func.id, node, tree, parents + ) + python_local_lambda_candidates( + node.func.id, node, tree, parents + ) + bound_method = False + elif isinstance(node.func, ast.Attribute): + candidates = python_local_method_candidates( + node.func, node, tree, parents + ) + bound_method = True + else: + continue + for function 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: @@ -10582,7 +10781,7 @@ def python_import_bindings(tree): return True if ( isinstance(value.func, ast.Attribute) - and value.func.attr in {"items", "keys", "values"} + and value.func.attr in {"items", "keys", "values", "pop"} ): return iterable_may_contain_launcher( value.func.value, @@ -12507,6 +12706,9 @@ python_reviewed_read_path_prefixes = ( "go.mod", "go.sum", ) +python_reviewed_read_path_exact_paths = { + "scripts/evidence_packet/issue79_regression_test.py", +} python_reviewed_read_path_names = { "path", "source", @@ -12635,6 +12837,8 @@ def python_reviewed_read_path(node, tree, parents, seen=None): 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("/") @@ -14318,6 +14522,26 @@ def python_path_reader_aliases(tree, parents): receiver = value.value elif isinstance(value, ast.Name) and value.id in aliases: receiver = aliases[value.id] + elif isinstance(value, ast.Call): + for returned in python_local_call_return_values( + value, tree, parents + ): + if ( + isinstance(returned, ast.Attribute) + and returned.attr in python_path_filesystem_read_methods + and not python_known_non_path_reader_call( + returned, tree + ) + ): + if receiver is None or not python_reviewed_read_path( + returned.value, tree, parents + ): + receiver = returned.value + 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 @@ -27559,3 +27783,71 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 286ad927..942ec589 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -555,6 +555,18 @@ def test_path_filesystem_readers_require_reviewed_paths(self) -> None: '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\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', @@ -573,6 +585,14 @@ def test_path_filesystem_readers_require_reviewed_paths(self) -> None: 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' + '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', @@ -686,6 +706,17 @@ def test_sensitive_method_and_lambda_returns_are_tainted(self) -> None: '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', ) for body in unsafe: with self.subTest(body=body): @@ -699,6 +730,33 @@ def test_sensitive_method_and_lambda_returns_are_tainted(self) -> None: ) self.assertIsNone(self.inspect(safe)) + 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', + ) + 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', + ) + 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' @@ -916,6 +974,13 @@ def test_imported_exit_alias_and_system_exit_preserve_sensitive_taint(self) -> 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): @@ -1169,6 +1234,15 @@ def test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected(self) -> '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' @@ -1184,6 +1258,15 @@ def test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected(self) -> '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): @@ -1198,6 +1281,22 @@ def test_launcher_aliases_returned_by_methods_and_lambdas_are_rejected(self) -> ) 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_bounded_git_query_loader_rejects_unreviewed_function_definitions(self) -> None: specimen = ast.parse( 'def run_bounded_git_query(value=packet_side_effect()):\n' @@ -1304,12 +1403,26 @@ def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) - 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", + ), + ) required_order = ( 'git_query(["rev-parse", "HEAD"])', - 'git_query(["ls-files", "-v", "-z", "--", packet_path.as_posix()])', - 'run_bounded_git_packet_blob_query(\n f"{local}:{packet_path.as_posix()}"', - "packet_path.read_bytes()", - "require_packet_head_parity(\n intent_result.stdout", + '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()', + "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] @@ -1327,12 +1440,19 @@ def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) - helper = namespace["require_packet_head_parity"] with tempfile.TemporaryDirectory(prefix="gh-runnerd-issue79-") 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 packet bytes\x00\n" - changed_bytes = b"synthetic modified packet bytes\x00\n" - packet.write_bytes(reviewed_bytes) + 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, @@ -1342,47 +1462,74 @@ def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) - "LC_ALL": "C", } _run_git_checked(["init", "-q"], root, env) - _run_git_checked(["add", relative.as_posix()], 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() - 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, packet.read_bytes()) + 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"), ): - _run_git_checked(["update-index", flag, relative.as_posix()], root, env) - packet.write_bytes(changed_bytes) - legacy_status = _run_git_checked( - ["status", "--porcelain=v1", "--untracked-files=all"], root, env - ) - self.assertEqual(legacy_status, b"") + 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 + ["ls-files", "-v", "-z", "--", relative.as_posix()], + root, + env, + ), + _run_git_checked( + ["show", f"{head}:{relative.as_posix()}"], root, env ), - _run_git_checked(["show", f"{head}:{relative.as_posix()}"], root, env), - packet.read_bytes(), + tracked_path.read_bytes(), ) - _run_git_checked(["update-index", clear_flag, relative.as_posix()], root, env) - packet.write_bytes(reviewed_bytes) - - packet.write_bytes(changed_bytes) - 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), - packet.read_bytes(), - ) def test_large_packet_blob_uses_a_separate_bounded_capture(self) -> None: runtime = _bounded_git_query_namespace(self.verification) From a61c35fb8ad7a1425e363a8fcfc6f5748eaa5029 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 15:32:42 +0900 Subject: [PATCH 011/123] fix(evidence): close issue 79 review bypasses --- ...4-offline-python-ast-regression-tooling.md | 9 +- docs/evidence/g01-recovery-packet.md | 234 +++++++++++++++--- .../issue79_regression_test.py | 119 +++++++++ 3 files changed, 321 insertions(+), 41 deletions(-) diff --git a/docs/decisions/0004-offline-python-ast-regression-tooling.md b/docs/decisions/0004-offline-python-ast-regression-tooling.md index aee97447..a7294bd5 100644 --- a/docs/decisions/0004-offline-python-ast-regression-tooling.md +++ b/docs/decisions/0004-offline-python-ast-regression-tooling.md @@ -39,10 +39,11 @@ 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 -B` to avoid bytecode artifacts -and 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. +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 diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 9a220e83..6562937b 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9351,7 +9351,10 @@ def python_local_call_return_values(call, tree, parents): return seen_nodes.add(id(value)) if isinstance(value, ast.Call) and isinstance(value.func, ast.Name): - class_names.add(value.func.id) + 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: @@ -9433,6 +9436,29 @@ def python_local_call_return_values(call, tree, parents): 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(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), ()): + if isinstance(assigned, ast.Name): + visit(assigned.id, set(seen)) + + visit(name, set()) + return found + + 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): @@ -9463,13 +9489,19 @@ def python_local_method_candidates(attribute, call, tree, parents): return seen_nodes.add(id(value)) if isinstance(value, ast.Call) and isinstance(value.func, ast.Name): - class_names.add(value.func.id) + 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": - for scope in visible_scopes: + 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), () ): @@ -9496,6 +9528,49 @@ def python_local_method_candidates(attribute, call, tree, parents): ] +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 + )) + + 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} + ): + 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 isinstance(value, ast.Name): + found.extend(methods(value.id, set(seen))) + return found + + return methods(name, set()) + + 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) @@ -9798,20 +9873,29 @@ def python_sensitive_value_names(tree, parents): ): continue if isinstance(node.func, ast.Name): - candidates = python_local_function_candidates( - node.func.id, node, tree, parents - ) + python_local_lambda_candidates( - node.func.id, node, tree, parents - ) - bound_method = False + 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 = python_local_method_candidates( - node.func, node, tree, parents - ) - bound_method = True + candidates = [ + (method, True) + for method in python_local_method_candidates( + node.func, node, tree, parents + ) + ] else: continue - for function in candidates: + for function, bound_method in candidates: for parameter, argument in call_arguments( node, function, bound_method=bound_method ): @@ -10781,7 +10865,7 @@ def python_import_bindings(tree): return True if ( isinstance(value.func, ast.Attribute) - and value.func.attr in {"items", "keys", "values", "pop"} + and value.func.attr in {"get", "items", "keys", "values", "pop"} ): return iterable_may_contain_launcher( value.func.value, @@ -13013,6 +13097,8 @@ def python_resolved_local_path_expression( ): return True path_preserving_calls = { + "ascii", + "format", "Path", "pathlib.Path", "str", @@ -13023,7 +13109,7 @@ def python_resolved_local_path_expression( } if dotted not in path_preserving_calls and not ( isinstance(node.func, ast.Attribute) - and node.func.attr in {"as_posix", "as_uri"} + and node.func.attr in {"__str__", "as_posix", "as_uri"} ): return False return any( @@ -14508,35 +14594,51 @@ def python_path_reader_aliases(tree, parents): 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" + and len(value.args) in {2, 3} + ): + receivers = [] + for method in python_static_string_values(value.args[1], tree): + 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 python_path_filesystem_read_methods - and not python_known_non_path_reader_call(value, tree) - ): - 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] - elif isinstance(value, ast.Call): - for returned in python_local_call_return_values( - value, tree, parents + 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 ): - if ( - isinstance(returned, ast.Attribute) - and returned.attr in python_path_filesystem_read_methods - and not python_known_non_path_reader_call( - returned, tree - ) - ): - if receiver is None or not python_reviewed_read_path( - returned.value, tree, parents - ): - receiver = returned.value + receiver = candidate previous = aliases.get(target.id) if previous is not None and not python_reviewed_read_path( previous, tree, parents @@ -27851,3 +27953,61 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 942ec589..55359520 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -16,6 +16,7 @@ import selectors import signal import subprocess +import sys import tempfile import time import types @@ -567,6 +568,12 @@ def test_path_filesystem_readers_require_reviewed_paths(self) -> None: ' 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', @@ -591,6 +598,12 @@ def test_path_filesystem_readers_require_reviewed_paths(self) -> None: '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', @@ -606,6 +619,15 @@ def test_resolved_local_paths_are_not_disclosed_to_output_sinks(self) -> None: '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): @@ -615,6 +637,12 @@ def test_resolved_local_paths_are_not_disclosed_to_output_sinks(self) -> None: '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_nested_function_name_collision_does_not_hide_launcher_alias(self) -> None: body = ( @@ -717,6 +745,12 @@ def test_sensitive_method_and_lambda_returns_are_tainted(self) -> None: '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): @@ -729,6 +763,14 @@ def test_sensitive_method_and_lambda_returns_are_tainted(self) -> None: '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 = ( @@ -740,6 +782,23 @@ def test_sensitive_values_are_tainted_into_method_and_lambda_parameters(self) -> '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): @@ -752,6 +811,21 @@ def test_sensitive_values_are_tainted_into_method_and_lambda_parameters(self) -> '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): @@ -1297,6 +1371,51 @@ def test_launcher_alias_returned_by_mapping_pop_is_rejected(self) -> None: ) 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_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' From 2c755af9dca8c44f902ad82879010743da2cc62c Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 16:46:25 +0900 Subject: [PATCH 012/123] fix(evidence): close reviewed AST provenance gaps --- docs/evidence/g01-recovery-packet.md | 454 +++++++++++++++++- .../issue79_regression_test.py | 311 +++++++++++- 2 files changed, 743 insertions(+), 22 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 6562937b..2d597a21 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9261,6 +9261,15 @@ def python_static_string_values(node, tree): 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(*( @@ -9360,7 +9369,10 @@ def python_local_call_return_values(call, tree, parents): elif isinstance(value, ast.Name) and value.id not in seen_names: seen_names.add(value.id) if value.id != "self": - for scope in visible_scopes: + 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), () ): @@ -9440,6 +9452,24 @@ def python_local_class_alias_names(name, method_name, scopes, methods_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 type(index.value) is int: + 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 @@ -9452,8 +9482,7 @@ def python_local_class_alias_names(name, method_name, scopes, methods_by_scope, return for scope in scopes: for assigned in assignments_by_scope.get((id(scope), candidate), ()): - if isinstance(assigned, ast.Name): - visit(assigned.id, set(seen)) + visit_expression(assigned, set(seen)) visit(name, set()) return found @@ -9564,8 +9593,20 @@ def python_local_bound_method_candidates(name, 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()) @@ -10632,6 +10673,59 @@ def python_mapping_bindings(tree, modules, functions): return mappings +def python_mapping_lookup_alias_violation(tree, mappings): + """Reject aliases that invoke lookup methods on launcher-bearing maps.""" + 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, ast.NamedExpr)) and node.value is not None: + assignments.append((node.target, node.value)) + + aliases = {} + for _ in range(len(assignments) + 1): + changed = False + for target, value 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.Name): + mapping_name = aliases.get(value.id) + if mapping_name is None: + continue + entries, _uncertain = mappings[mapping_name] + launcher_bearing = 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 + ) + if launcher_bearing and aliases.get(target.id) != mapping_name: + aliases[target.id] = mapping_name + changed = True + if not changed: + break + + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id 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 @@ -13107,9 +13201,15 @@ def python_resolved_local_path_expression( "os.path.abspath", "os.path.realpath", } - if dotted not in path_preserving_calls and not ( + 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 {"__str__", "as_posix", "as_uri"} + and node.func.attr in { + "__fspath__", "__str__", "as_posix", "as_uri", "decode", "encode" + } ): return False return any( @@ -13707,8 +13807,10 @@ def python_reviewed_markdown_link_target_path(node, tree, parents): ) for candidate in ast.walk(link_loop) ) - target_path_assignment = any( - isinstance(candidate, ast.Assign) + 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" @@ -13727,28 +13829,94 @@ def python_reviewed_markdown_link_target_path(node, tree, parents): 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" - for candidate in ast.walk(link_loop) + ] + 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 - checks_repository_root = any( - isinstance(call, ast.Call) - and python_dotted_name(call.func) == "path.relative_to" - and len(call.args) == 1 - and isinstance(call.args[0], ast.Name) - and call.args[0].id == "repository_root" + containment_checks = [ + statement.value for statement in candidate.body - for call in ast.walk(statement) - ) + 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) + and any( + isinstance(statement, ast.Continue) for statement in handler.body + ) for handler in candidate.handlers ) - if checks_repository_root and skips_outside_paths: + 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 ( @@ -13924,6 +14092,42 @@ def python_regex_match_receiver(node, tree, seen=None): 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) + ) + 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 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: @@ -14021,6 +14225,8 @@ def python_regex_match_receiver(node, tree, seen=None): 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, ()) ) @@ -14064,6 +14270,11 @@ def python_regex_match_receiver(node, tree, seen=None): 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) @@ -14604,7 +14815,15 @@ def python_path_reader_aliases(tree, parents): return [value.value] if ( isinstance(value, ast.Call) - and python_dotted_name(value.func) == "getattr" + 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 = [] @@ -14652,6 +14871,33 @@ def python_path_reader_aliases(tree, parents): 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(node.args[1], tree): + 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) @@ -14710,6 +14956,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 = { @@ -28011,3 +28260,170 @@ 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 +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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 55359520..e0c58411 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -1,9 +1,10 @@ """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. The only -child processes created below are literal Git commands against temporary local -repositories owned by these tests. +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 @@ -501,6 +502,158 @@ def setUpClass(cls) -> None: 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 = { @@ -644,6 +797,68 @@ def test_resolved_local_paths_are_not_disclosed_to_output_sinks(self) -> None: ) 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' @@ -831,6 +1046,72 @@ def test_sensitive_values_are_tainted_into_method_and_lambda_parameters(self) -> 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' @@ -1387,6 +1668,30 @@ def test_launcher_alias_returned_by_mapping_get_is_rejected(self) -> None: ) 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" From 1f5f89bc80f09393dbc44f45d20f0141e747005a Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 18:18:47 +0900 Subject: [PATCH 013/123] fix(evidence): harden output and launcher provenance --- docs/evidence/g01-recovery-packet.md | 478 ++++++++++++++++-- .../issue79_regression_test.py | 223 +++++++- 2 files changed, 649 insertions(+), 52 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 2d597a21..f7c4b5fc 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8669,6 +8669,7 @@ 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 "getline" in token or output_pipe(token) or output_redirection(token) @@ -8677,6 +8678,19 @@ 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 re.search( + r"(? ast.Module: def _top_level_assignment(module: ast.Module, name: str) -> ast.Assign | ast.AnnAssign: - 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 - ): - return statement - if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name) and statement.target.id == name: - return statement - raise AssertionError(f"verification template assignment {name!r} is missing") + 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: @@ -2271,6 +2283,199 @@ def test_packet_loader_rejects_packet_controlled_definition_time_code(self) -> N with self.assertRaises(AssertionError): _validated_scanner_statements(module) + 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\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', + ) + 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\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_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_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_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 From 1784c1530e64bb6c45b512f293a8caeaaa0ff44a Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 19:01:08 +0900 Subject: [PATCH 014/123] fix(evidence): close output and path alias gaps --- docs/evidence/g01-recovery-packet.md | 196 +++++++++++++++++- .../issue79_regression_test.py | 42 ++++ 2 files changed, 231 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index f7c4b5fc..ce380905 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -7350,6 +7350,10 @@ shell_parameter = re.compile( def shell_sensitive_parameter_violation(tokens): """Reject credential-bearing shell parameter expansions in any argument.""" for token in tokens: + for indirect in re.finditer( + r"\$\{!([A-Za-z_][A-Za-z0-9_]*)\}", token + ): + return "indirect shell parameter expansion is not allowed" for match in shell_parameter.finditer(token): name = match.group(1) or match.group(2) if credential_environment_name(name): @@ -12483,7 +12487,11 @@ def python_path_receiver_expression(node, tree, parents, seen=None): return True if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) - module_aliases, constructor_aliases = python_path_constructor_aliases(tree) + 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 if ( @@ -13278,12 +13286,78 @@ def python_resolved_local_path_expression( return False if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) - if dotted in { - "Path.cwd", - "pathlib.Path.cwd", - "Path.home", - "pathlib.Path.home", - }: + 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 + is_path_home = dotted in {"Path.home", "pathlib.Path.home"} or ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "home" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in constructor_aliases + ) or ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "home" + 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 + ) + is_path_cwd = dotted in {"Path.cwd", "pathlib.Path.cwd"} or ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "cwd" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in constructor_aliases + ) 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 + ) + method_aliases = getattr( + tree, "_issue79_path_method_aliases", None + ) + if method_aliases is None: + method_aliases = {"home": set(), "cwd": set()} + for candidate in ast.walk(tree): + if not isinstance( + candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr) + ): + continue + targets = ( + candidate.targets if isinstance(candidate, ast.Assign) + else [candidate.target] + ) + value = candidate.value + if not isinstance(value, ast.Attribute): + continue + receiver = value.value + is_path_constructor_method = ( + isinstance(receiver, ast.Name) + and receiver.id in constructor_aliases + ) or ( + isinstance(receiver, ast.Attribute) + and receiver.attr == "Path" + and isinstance(receiver.value, ast.Name) + and receiver.value.id in module_aliases + ) + if value.attr in method_aliases and is_path_constructor_method: + method_aliases[value.attr].update( + target.id for target in targets + if isinstance(target, ast.Name) + ) + tree._issue79_path_method_aliases = method_aliases + if isinstance(node.func, ast.Name): + is_path_home = is_path_home or ( + node.func.id in method_aliases["home"] + ) + is_path_cwd = is_path_cwd or ( + node.func.id in method_aliases["cwd"] + ) + if is_path_home or is_path_cwd: return True if dotted == "os.path.expanduser" and node.args: return any( @@ -13417,6 +13491,10 @@ def python_sensitive_output_sink(node, tree=None): "traceback.print_exception", }: return True + if isinstance(node.func, ast.NamedExpr) and python_dotted_name( + node.func.value + ) in {"print", "builtins.print", "sys.exit", "warnings.warn"}: + 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: @@ -28663,6 +28741,7 @@ as an unsafe witness. | 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 @@ -28819,3 +28898,106 @@ 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`. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index e39eb62a..969b8457 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2344,6 +2344,9 @@ def test_assigned_sensitive_constructor_aliases_preserve_taint(self) -> None: 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' @@ -2358,6 +2361,13 @@ def test_home_and_decoded_local_paths_are_not_disclosed(self) -> None: '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', @@ -2368,6 +2378,38 @@ def test_home_and_decoded_local_paths_are_not_disclosed(self) -> None: 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_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_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_environment_dump_readers_are_rejected(self) -> None: for command in ( 'awk \'BEGIN { print ENVIRON["GH_TOKEN"] }\'', From bda0eedb5ba43fba0243c77ef09f50714795490c Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 19:41:20 +0900 Subject: [PATCH 015/123] fix(evidence): track scoped aliases and callable taint --- docs/evidence/g01-recovery-packet.md | 235 ++++++++++++++---- .../issue79_regression_test.py | 104 ++++++++ 2 files changed, 296 insertions(+), 43 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index ce380905..8c0e35da 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9781,6 +9781,16 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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() @@ -9897,7 +9907,11 @@ def python_sensitive_value_names(tree, parents): positional_parameters = list(function.args.posonlyargs) + list( function.args.args ) - if bound_method and positional_parameters: + is_static_method = any( + python_dotted_name(decorator) == "staticmethod" + 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 = [] @@ -9983,7 +9997,9 @@ def python_sensitive_value_names(tree, parents): for value in call_values ): continue - if isinstance(node.func, ast.Name): + 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( @@ -13256,6 +13272,105 @@ python_sensitive_sink_methods = { } +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): + bindings.setdefault((id(scope), alias), []).append(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) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + scope = python_enclosing_scope(parents.get(candidate), parents) + bind(scope, candidate.name, None) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + for argument in ( + list(candidate.args.posonlyargs) + + list(candidate.args.args) + + list(candidate.args.kwonlyargs) + ): + bind(candidate, argument.arg, None) + for argument in (candidate.args.vararg, candidate.args.kwarg): + if argument is not None: + bind(candidate, argument.arg, None) + 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) + elif isinstance(candidate, ast.ImportFrom): + scope = python_enclosing_scope(candidate, parents) + for imported in candidate.names: + bind(scope, imported.asname or imported.name, None) + 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 + module_aliases, constructor_aliases = path_aliases + + def resolves_expression(value, scope, seen): + if isinstance(value, ast.Attribute) and value.attr == method: + receiver = value.value + return ( + isinstance(receiver, ast.Name) + and receiver.id in constructor_aliases + ) or ( + isinstance(receiver, ast.Attribute) + and receiver.attr == "Path" + and isinstance(receiver.value, ast.Name) + and receiver.value.id in module_aliases + ) + if isinstance(value, ast.Name): + for visible_scope in python_lexical_scope_chain(scope, parents): + visible_key = (id(visible_scope), value.id) + if visible_key not in bindings: + continue + if visible_key in seen: + return False + next_seen = seen | {visible_key} + return any( + assigned is not None + and resolves_expression(assigned, visible_scope, next_seen) + for assigned in bindings[visible_key] + ) + return False + + 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 any( + assigned is not None + and resolves_expression(assigned, visible_scope, {key}) + for assigned in bindings[key] + ) + return False + + def python_resolved_local_path_expression( node, tree, @@ -13317,45 +13432,12 @@ def python_resolved_local_path_expression( and isinstance(node.func.value.value, ast.Name) and node.func.value.value.id in module_aliases ) - method_aliases = getattr( - tree, "_issue79_path_method_aliases", None - ) - if method_aliases is None: - method_aliases = {"home": set(), "cwd": set()} - for candidate in ast.walk(tree): - if not isinstance( - candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr) - ): - continue - targets = ( - candidate.targets if isinstance(candidate, ast.Assign) - else [candidate.target] - ) - value = candidate.value - if not isinstance(value, ast.Attribute): - continue - receiver = value.value - is_path_constructor_method = ( - isinstance(receiver, ast.Name) - and receiver.id in constructor_aliases - ) or ( - isinstance(receiver, ast.Attribute) - and receiver.attr == "Path" - and isinstance(receiver.value, ast.Name) - and receiver.value.id in module_aliases - ) - if value.attr in method_aliases and is_path_constructor_method: - method_aliases[value.attr].update( - target.id for target in targets - if isinstance(target, ast.Name) - ) - tree._issue79_path_method_aliases = method_aliases if isinstance(node.func, ast.Name): - is_path_home = is_path_home or ( - node.func.id in method_aliases["home"] + is_path_home = is_path_home or python_path_method_alias_visible( + node.func.id, "home", node, tree, parents ) - is_path_cwd = is_path_cwd or ( - node.func.id in method_aliases["cwd"] + is_path_cwd = is_path_cwd or python_path_method_alias_visible( + node.func.id, "cwd", node, tree, parents ) if is_path_home or is_path_cwd: return True @@ -13491,10 +13573,28 @@ def python_sensitive_output_sink(node, tree=None): "traceback.print_exception", }: return True - if isinstance(node.func, ast.NamedExpr) and python_dotted_name( - node.func.value - ) in {"print", "builtins.print", "sys.exit", "warnings.warn"}: - 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 + ) + ): + 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: @@ -29001,3 +29101,52 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 969b8457..13da226b 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -1058,6 +1058,55 @@ def test_sensitive_values_are_tainted_into_method_and_lambda_parameters(self) -> 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', + ) + 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', + ) + 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_factory_returned_bound_method_receives_sensitive_argument(self) -> None: unsafe = ( 'import os\n' @@ -2402,10 +2451,65 @@ def test_current_directory_path_aliases_are_not_disclosed(self) -> None: 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_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"')) From db8faba6f3a1de384bbde729e3d19a8134f6f7a2 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 21:04:23 +0900 Subject: [PATCH 016/123] Harden issue 79 evidence scanner and worktree root guard --- docs/evidence/g01-recovery-packet.md | 360 ++++++++++++++---- .../issue79_regression_test.py | 223 +++++++++++ 2 files changed, 509 insertions(+), 74 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 8c0e35da..a5728563 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -6146,6 +6146,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"]), @@ -7292,6 +7316,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): @@ -7356,12 +7381,32 @@ def shell_sensitive_parameter_violation(tokens): return "indirect shell parameter expansion 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): + """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) + else: + shell_sensitive_variable_names.discard(name) + def fence_details(line): """Normalize Markdown container prefixes before recognizing a fence.""" candidate = line @@ -7494,6 +7539,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 = "" @@ -7518,6 +7564,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 @@ -7537,6 +7584,8 @@ def shell_commands(markdown): command = " ".join(pending) if shell_quote_pending(command): continue + for segment in shell_token_segments(command): + shell_record_sensitive_assignments(segment) unsafe_heredocs = non_python_heredoc_delimiters(command) if unsafe_heredocs: raise SystemExit( @@ -8031,6 +8080,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"}: @@ -8132,10 +8183,9 @@ git_config_read_only_options = { "--get-all", "--get-regexp", "--get-urlmatch", - "--list", - "-l", "--name-only", } +git_config_bounded_regexp_queries = {r"^filter\."} git_config_mutating_options = { "--add", "--blob", @@ -8356,6 +8406,11 @@ def git_read_only_violation(tokens): return "Git ls-remote is restricted to the literal local repository form" if subcommand == "config": options = tokens[1:] + 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 @@ -8364,6 +8419,11 @@ def git_read_only_violation(tokens): return "Git config mutation is not allowed" if not any(option in git_config_read_only_options for option in options): return "Git config query must use an approved read-only option" + for index, option in enumerate(options): + if option == "--get-regexp": + pattern = options[index + 1] if index + 1 < len(options) else None + if "--local" not in options or pattern not in git_config_bounded_regexp_queries: + return "Git config regex query is not a bounded local query" return None @@ -8802,6 +8862,8 @@ def git_config_environment_include_violation(tokens): 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 @@ -9720,6 +9782,33 @@ def python_assigned_callable_alias(name, target, tree): ) +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 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( + 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_sensitive_value_expression(node, sensitive_names, tree, parents, seen=None): """Track credential values through aliases without trusting variable names.""" if node is None: @@ -9742,6 +9831,17 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) + 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": @@ -9838,9 +9938,20 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen for argument in node.args ) if isinstance(node.func, ast.Attribute): - return python_sensitive_value_expression( + sensitive_receiver = python_sensitive_value_expression( node.func.value, sensitive_names, tree, parents, seen.copy() ) + sensitive_join_values = node.func.attr == "join" and any( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Attribute) + and value.func.attr in {"values", "items"} + and 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 sensitive_receiver or sensitive_join_values if isinstance(node, (ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp)): if any( isinstance(candidate, ast.Call) @@ -9909,6 +10020,12 @@ def python_sensitive_value_names(tree, parents): ) 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: @@ -13278,8 +13395,8 @@ def python_path_method_alias_visible(name, method, node, tree, parents): if bindings is None: bindings = {} - def bind(scope, alias, value): - bindings.setdefault((id(scope), alias), []).append(value) + def bind(scope, alias, value, source): + bindings.setdefault((id(scope), alias), []).append((source, value)) def target_names(target): if isinstance(target, ast.Name): @@ -13301,48 +13418,47 @@ def python_path_method_alias_visible(name, method, node, tree, parents): scope = python_enclosing_scope(candidate, parents) for target in targets: for alias in target_names(target): - bind(scope, alias, value) + 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) + bind(scope, candidate.name, None, candidate) if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): - for argument in ( - list(candidate.args.posonlyargs) - + list(candidate.args.args) - + list(candidate.args.kwonlyargs) + 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, None) + 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) + 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) + 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) + 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 - module_aliases, constructor_aliases = path_aliases - def resolves_expression(value, scope, seen): - if isinstance(value, ast.Attribute) and value.attr == method: - receiver = value.value - return ( - isinstance(receiver, ast.Name) - and receiver.id in constructor_aliases - ) or ( - isinstance(receiver, ast.Attribute) - and receiver.attr == "Path" - and isinstance(receiver.value, ast.Name) - and receiver.value.id in module_aliases - ) if isinstance(value, ast.Name): for visible_scope in python_lexical_scope_chain(scope, parents): visible_key = (id(visible_scope), value.id) @@ -13351,26 +13467,82 @@ def python_path_method_alias_visible(name, method, node, tree, parents): if visible_key in seen: return False next_seen = seen | {visible_key} - return any( - assigned is not None - and resolves_expression(assigned, visible_scope, next_seen) - for assigned in bindings[visible_key] + assigned = max( + bindings[visible_key], + key=lambda entry: ( + getattr(entry[0], "lineno", -1), + getattr(entry[0], "col_offset", -1), + ), + )[1] + return assigned is not None and resolves_expression( + assigned, visible_scope, next_seen ) - return False + return python_path_method_reference(value, method, tree) 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 any( - assigned is not None - and resolves_expression(assigned, visible_scope, {key}) - for assigned in bindings[key] + assigned = max( + bindings[key], + key=lambda entry: ( + getattr(entry[0], "lineno", -1), + getattr(entry[0], "col_offset", -1), + ), + )[1] + return assigned is not None and resolves_expression( + assigned, visible_scope, {key} ) return False +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_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_resolved_local_path_expression( node, tree, @@ -13401,44 +13573,12 @@ def python_resolved_local_path_expression( return False 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 - is_path_home = dotted in {"Path.home", "pathlib.Path.home"} or ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "home" - and isinstance(node.func.value, ast.Name) - and node.func.value.id in constructor_aliases - ) or ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "home" - 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 + is_path_home = python_path_method_expression_visible( + node.func, "home", node, tree, parents ) - is_path_cwd = dotted in {"Path.cwd", "pathlib.Path.cwd"} or ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "cwd" - and isinstance(node.func.value, ast.Name) - and node.func.value.id in constructor_aliases - ) 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 + is_path_cwd = python_path_method_expression_visible( + node.func, "cwd", node, tree, parents ) - if isinstance(node.func, ast.Name): - is_path_home = is_path_home or python_path_method_alias_visible( - node.func.id, "home", node, tree, parents - ) - is_path_cwd = is_path_cwd or python_path_method_alias_visible( - node.func.id, "cwd", node, tree, parents - ) if is_path_home or is_path_cwd: return True if dotted == "os.path.expanduser" and node.args: @@ -29150,3 +29290,75 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 13da226b..0d902702 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -710,6 +710,16 @@ def shell_violation(self, command: str) -> str | None: self.scanner["shell_pending_owned_bindings"].clear() # type: ignore[union-attr] return self.scanner["forbidden_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_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', @@ -1058,6 +1068,27 @@ def test_sensitive_values_are_tainted_into_method_and_lambda_parameters(self) -> 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", + ) + 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", + ) + 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' @@ -1068,6 +1099,13 @@ def test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters(self ' 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): @@ -1081,6 +1119,12 @@ def test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters(self ' 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): @@ -1107,6 +1151,31 @@ def test_sensitive_taint_reaches_string_format_arguments(self) -> None: 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_factory_returned_bound_method_receives_sensitive_argument(self) -> None: unsafe = ( 'import os\n' @@ -2143,6 +2212,94 @@ def test_git_config_include_options_are_rejected_before_read_only_classification 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_nested_raise_does_not_prove_module_root_guard(self) -> None: bodies = [ body @@ -2486,6 +2643,57 @@ def test_path_method_alias_chains_respect_lexical_shadowing(self) -> None: 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_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')) @@ -2514,6 +2722,21 @@ def test_bash_indirect_environment_expansion_rejects_credential_names(self) -> N 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", + ) + for commands in unsafe: + with self.subTest(commands=commands): + self.assertIsNotNone(self.shell_document_violation(commands)) + + self.assertIsNone( + self.shell_document_violation( + "secret=reviewed\nprintf '%s\\n' $secret" + ) + ) + def test_shell_environment_dump_readers_are_rejected(self) -> None: for command in ( 'awk \'BEGIN { print ENVIRON["GH_TOKEN"] }\'', From 7e277abf1c257edd5e07590add9b6b6c18c7928f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Mon, 28 Sep 2026 22:25:15 +0900 Subject: [PATCH 017/123] Close issue 79 review taint and config gaps --- docs/evidence/g01-recovery-packet.md | 392 +++++++++++++++--- .../issue79_regression_test.py | 106 ++++- 2 files changed, 436 insertions(+), 62 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a5728563..6c12a1b7 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -7375,9 +7375,7 @@ shell_parameter = re.compile( def shell_sensitive_parameter_violation(tokens): """Reject credential-bearing shell parameter expansions in any argument.""" for token in tokens: - for indirect in re.finditer( - r"\$\{!([A-Za-z_][A-Za-z0-9_]*)\}", token - ): + if "${!" in token: return "indirect shell parameter expansion is not allowed" for match in shell_parameter.finditer(token): name = match.group(1) or match.group(2) @@ -7390,7 +7388,7 @@ def shell_sensitive_parameter_violation(tokens): ) return None -def shell_record_sensitive_assignments(tokens): +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): @@ -7404,7 +7402,7 @@ def shell_record_sensitive_assignments(tokens): ) if sensitive: shell_sensitive_variable_names.add(name) - else: + elif not preserve_existing: shell_sensitive_variable_names.discard(name) def fence_details(line): @@ -7584,8 +7582,16 @@ def shell_commands(markdown): command = " ".join(pending) if shell_quote_pending(command): continue - for segment in shell_token_segments(command): - shell_record_sensitive_assignments(segment) + 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 == "|" + ) + shell_record_sensitive_assignments( + segment, preserve_existing=preserve_existing + ) unsafe_heredocs = non_python_heredoc_delimiters(command) if unsafe_heredocs: raise SystemExit( @@ -7600,6 +7606,11 @@ def shell_commands(markdown): 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 = [] @@ -7696,19 +7707,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): @@ -8181,11 +8198,15 @@ git_read_only_subcommands = { git_config_read_only_options = { "--get", "--get-all", - "--get-regexp", "--get-urlmatch", - "--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", "--blob", @@ -8202,8 +8223,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] @@ -8224,9 +8245,14 @@ 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.""" @@ -8405,7 +8431,8 @@ 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 @@ -8417,13 +8444,31 @@ def git_read_only_violation(tokens): 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" - for index, option in enumerate(options): - if option == "--get-regexp": - pattern = options[index + 1] if index + 1 < len(options) else None - if "--local" not in options or pattern not in git_config_bounded_regexp_queries: - return "Git config regex query is not a bounded local query" return None @@ -9831,6 +9876,39 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) + if python_reviewed_string_join_callable(node.func, tree) 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) @@ -9941,17 +10019,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen sensitive_receiver = python_sensitive_value_expression( node.func.value, sensitive_names, tree, parents, seen.copy() ) - sensitive_join_values = node.func.attr == "join" and any( - isinstance(value, ast.Call) - and isinstance(value.func, ast.Attribute) - and value.func.attr in {"values", "items"} - and 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 sensitive_receiver or sensitive_join_values + return sensitive_receiver if isinstance(node, (ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp)): if any( isinstance(candidate, ast.Call) @@ -9981,6 +10049,109 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ) return False +def python_unshadowed_builtin_call(node, names, tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + return False + if node.func.id not in names: + return False + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Name) and candidate.id == node.func.id and isinstance(candidate.ctx, ast.Store): + return False + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and candidate.name == node.func.id: + return False + if isinstance(candidate, ast.arg) and candidate.arg == node.func.id: + return False + return True + +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 python_reviewed_string_join_callable(value, tree, 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 + return ( + python_unshadowed_builtin_call(receiver, {"str"}, tree) + and not receiver.args + and not receiver.keywords + ) + if not isinstance(value, ast.Name) or value.id in seen: + return False + return any( + python_reviewed_string_join_callable(candidate, tree, seen | {value.id}) + for candidate in python_join_assignment_index(tree).get(value.id, ()) + ) + +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"} + ): + return python_sensitive_value_expression( + node.func.value, sensitive_names, tree, parents, seen.copy() + ) + if python_unshadowed_builtin_call(node, {"list", "tuple", "iter"}, tree): + 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): + 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_sensitive_value_names(tree, parents): """Resolve credential aliases and local-helper parameter taint.""" @@ -13458,42 +13629,79 @@ def python_path_method_alias_visible(name, method, node, tree, parents): 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, + ( + ast.If, + ast.For, + ast.AsyncFor, + ast.While, + ast.Try, + ast.ExceptHandler, + ast.IfExp, + ast.BoolOp, + ast.comprehension, + ), + ) or type(current).__name__ in {"Match", "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): - visible_key = (id(visible_scope), value.id) - if visible_key not in bindings: + if (id(visible_scope), value.id) not in bindings: continue - if visible_key in seen: - return False - next_seen = seen | {visible_key} - assigned = max( - bindings[visible_key], - key=lambda entry: ( - getattr(entry[0], "lineno", -1), - getattr(entry[0], "col_offset", -1), - ), - )[1] - return assigned is not None and resolves_expression( - assigned, visible_scope, next_seen + return resolves_name( + visible_scope, value.id, seen ) return python_path_method_reference(value, method, tree) - 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 - assigned = max( - bindings[key], + 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), ), - )[1] - return assigned is not None and resolves_expression( - assigned, visible_scope, {key} ) + 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) + ): + 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) + ) + + 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 @@ -15002,10 +15210,14 @@ def python_sensitive_read_violation(tree, parents): 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 output_arguments ): @@ -29362,3 +29574,63 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 0d902702..3b4114df 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -1075,6 +1075,38 @@ def test_join_of_environment_views_keeps_sensitive_taint(self) -> None: "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): @@ -1084,6 +1116,20 @@ def test_join_of_environment_views_keeps_sensitive_taint(self) -> None: '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): @@ -1176,6 +1222,22 @@ def test_format_callable_aliases_preserve_sensitive_taint(self) -> None: 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' @@ -2300,6 +2362,27 @@ def test_unbounded_git_config_dumps_are_rejected(self) -> None: 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 --local --get-all remote.origin.url", + "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_nested_raise_does_not_prove_module_root_guard(self) -> None: bodies = [ body @@ -2694,6 +2777,16 @@ def test_path_home_alias_reassignment_and_helpers_remain_safe(self) -> None: 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')) @@ -2726,14 +2819,23 @@ 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)) - self.assertIsNone( + 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_shell_indirect_environment_expansion_in_assignment_is_rejected(self) -> None: + self.assertIsNotNone( self.shell_document_violation( - "secret=reviewed\nprintf '%s\\n' $secret" + "name=GH_TOKEN\nsecret=${!name}\nprintf '%s\\n' \"$secret\"" ) ) From a55fb9d1c9402bc65c0f40daa6673d6f447700f4 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 00:51:42 +0900 Subject: [PATCH 018/123] Close issue 79 final evidence scanner review gaps --- docs/evidence/g01-recovery-packet.md | 326 ++++++++++++++++-- .../issue79_regression_test.py | 183 +++++++++- 2 files changed, 472 insertions(+), 37 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 6c12a1b7..5a4454ad 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -7369,7 +7369,7 @@ 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): @@ -7589,9 +7589,10 @@ def shell_commands(markdown): preserve_existing = previous_operator in {"&&", "||", "|", "&"} or ( index == 0 and next_operator == "|" ) - shell_record_sensitive_assignments( - segment, preserve_existing=preserve_existing - ) + 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( @@ -8026,7 +8027,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 @@ -8471,6 +8472,19 @@ def git_read_only_violation(tokens): 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", @@ -9013,7 +9027,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" @@ -9061,6 +9075,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", @@ -9853,6 +9875,28 @@ def python_assigned_format_alias(name, tree): if value is not None ) +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 + def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen=None): """Track credential values through aliases without trusting variable names.""" @@ -9864,19 +9908,45 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen return False seen.add(id(node)) if isinstance(node, ast.Name): - return node.id in sensitive_names + 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 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) + if getattr(tree, "_issue79_member_taint_enabled", False) and python_sensitive_member_assignment_value( + node, sensitive_names, tree, parents, seen.copy() + ): + return True return python_sensitive_value_expression( node.value, sensitive_names, tree, parents, seen.copy() ) + 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 True if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) - if python_reviewed_string_join_callable(node.func, tree) and any( + 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() ) @@ -10049,19 +10119,126 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ) return False -def python_unshadowed_builtin_call(node, names, tree): - if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): +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 node.func.id not in names: + marker = ("member", key) + if marker in seen: return False - for candidate in ast.walk(tree): - if isinstance(candidate, ast.Name) and candidate.id == node.func.id and isinstance(candidate.ctx, ast.Store): - return False - if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and candidate.name == node.func.id: - return False - if isinstance(candidate, ast.arg) and candidate.arg == node.func.id: - return False - return True + 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 + return ( + 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_join_assignment_index(tree): """Cache local assignments used by literal joins and their value aliases.""" @@ -10081,7 +10258,7 @@ def python_join_assignment_index(tree): tree._issue79_join_alias_index = assignments return assignments -def python_reviewed_string_join_callable(value, tree, seen=None): +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() @@ -10089,15 +10266,17 @@ def python_reviewed_string_join_callable(value, tree, seen=None): 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) + 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 return any( - python_reviewed_string_join_callable(candidate, tree, seen | {value.id}) + python_reviewed_string_join_callable(candidate, tree, parents, seen | {value.id}) for candidate in python_join_assignment_index(tree).get(value.id, ()) ) @@ -10123,14 +10302,14 @@ def python_sensitive_join_argument(node, sensitive_names, tree, parents, seen=No return python_sensitive_value_expression( node.func.value, sensitive_names, tree, parents, seen.copy() ) - if python_unshadowed_builtin_call(node, {"list", "tuple", "iter"}, tree): + 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): + if python_unshadowed_builtin_call(node, {"map"}, tree, parents): return any( python_sensitive_join_argument( argument, sensitive_names, tree, parents, seen.copy() @@ -10155,6 +10334,7 @@ def python_sensitive_join_argument(node, sensitive_names, tree, parents, seen=No def python_sensitive_value_names(tree, parents): """Resolve credential aliases and local-helper parameter taint.""" + tree._issue79_member_taint_enabled = False sensitive_names = set() assignments = [] for node in ast.walk(tree): @@ -10321,6 +10501,7 @@ def python_sensitive_value_names(tree, parents): changed = True if not changed: break + tree._issue79_member_taint_enabled = True return sensitive_names @@ -13758,7 +13939,11 @@ def python_resolved_local_path_expression( assignments_by_name, seen=None, ): - """Track resolved local paths into output sinks without rejecting checks.""" + """Track resolved local paths into output sinks without rejecting checks. + + 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: @@ -13775,7 +13960,7 @@ def python_resolved_local_path_expression( tree, parents, assignments_by_name, - seen.copy(), + seen, ): return True return False @@ -13800,14 +13985,14 @@ def python_resolved_local_path_expression( tree, parents, assignments_by_name, - seen.copy(), + seen, ) for value in python_local_call_return_values(node, tree, parents) ): return True if dotted == "dict" and any( python_resolved_local_path_expression( - value, tree, parents, assignments_by_name, seen.copy() + value, tree, parents, assignments_by_name, seen ) for value in list(node.args) + [keyword.value for keyword in node.keywords] @@ -13818,7 +14003,7 @@ def python_resolved_local_path_expression( and node.func.attr == "format" and any( python_resolved_local_path_expression( - value, tree, parents, assignments_by_name, seen.copy() + value, tree, parents, assignments_by_name, seen ) for value in list(node.args) + [keyword.value for keyword in node.keywords] @@ -13834,7 +14019,7 @@ def python_resolved_local_path_expression( tree, parents, assignments_by_name, - seen.copy(), + seen, ) for argument in node.args ) @@ -13897,7 +14082,7 @@ def python_resolved_local_path_expression( tree, parents, assignments_by_name, - seen.copy(), + seen, ) for child in ast.iter_child_nodes(node) if not isinstance( @@ -15858,7 +16043,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: @@ -15883,7 +16068,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): @@ -29634,3 +29819,78 @@ Rollback for this correction is input HEAD 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 3b4114df..cae6b14a 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -708,14 +708,14 @@ def replace_source_fuzz_guard_fragment( 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_command"](shlex.split(command)) # type: ignore[operator] + 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_command"](segment) # type: ignore[operator] + violation = self.scanner["forbidden_shell_command"](segment) # type: ignore[operator] if violation: return violation return None @@ -1135,6 +1135,113 @@ def test_join_of_environment_views_keeps_sensitive_taint(self) -> None: 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' @@ -2375,7 +2482,6 @@ def test_git_config_queries_allow_only_reviewed_keys(self) -> None: self.assertIsNotNone(self.shell_violation(command)) for command in ( - "git config --local --get-all remote.origin.url", "git config --get core.repositoryformatversion", "git config --local --get-regexp '^filter\\.'", "git config --get-urlmatch http.sslverify https://github.com/1XP-AI/gh-runnerd", @@ -2383,6 +2489,40 @@ def test_git_config_queries_allow_only_reviewed_keys(self) -> None: 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_nested_raise_does_not_prove_module_root_guard(self) -> None: bodies = [ body @@ -2832,6 +2972,41 @@ def test_shell_credential_assignment_aliases_are_rejected(self) -> None: 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( @@ -2964,7 +3139,7 @@ def test_current_packet_has_no_static_scanner_violations(self) -> None: "an isolated AST-inspected heredoc" ) continue - violation = self.scanner["forbidden_command"](segment) # type: ignore[operator] + violation = self.scanner["forbidden_shell_command"](segment) # type: ignore[operator] if violation: matches.append(f"line {number}: {violation}") From b3c335bd40def3ff75ef926387d82ac68d955103 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 01:27:47 +0900 Subject: [PATCH 019/123] Harden issue 79 scanner and evidence parity gates --- docs/evidence/g01-recovery-packet.md | 117 +++++++++++++++++- .../issue79_regression_test.py | 82 +++++++++++- 2 files changed, 192 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 5a4454ad..61b6f477 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -5830,6 +5830,7 @@ 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", ) @@ -6032,6 +6033,7 @@ def run_bounded_git_packet_blob_query(blob_spec, *, cwd, env): 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") @@ -6058,6 +6060,7 @@ def require_packet_head_parity(intent_output, head_blob, worktree_bytes): 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") @@ -6275,6 +6278,10 @@ for reviewed_path in issue79_reviewed_evidence_paths: 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: @@ -7405,6 +7412,16 @@ def shell_record_sensitive_assignments(tokens, preserve_existing=False): 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 @@ -7603,6 +7620,11 @@ 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) @@ -8397,6 +8419,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 @@ -8842,11 +8869,37 @@ def shell_reader_path_violation(tokens): """Reject reader operands outside reviewed repository or owned-temp paths.""" if not tokens or executable_basename(tokens[0]) not in reviewed_reader_executables: return None - for token in tokens[1:]: - if token in {"--", "<<<"} or token.startswith("-"): + executable = executable_basename(tokens[0]) + file_options = { + "diff": {"--from-file", "--to-file"}, + "grep": {"-f", "--file"}, + "rg": {"-f", "--file"}, + "awk": {"-f", "--file"}, + "jq": {"-f"}, + }.get(executable, set()) + position = 1 + while position < len(tokens): + token = tokens[position] + option = token.split("=", 1)[0] + if 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" + elif 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" + elif token in {"--", "<<<"} or token.startswith("-"): + position += 1 continue - if not shell_reviewed_reader_path(token): + elif not shell_reviewed_reader_path(token): return "reader path is not reviewed or packet-owned" + position += 1 return None @@ -8926,11 +8979,22 @@ def git_config_environment_include_violation(tokens): 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 @@ -8962,6 +9026,8 @@ def forbidden_command(tokens, depth=0): 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 @@ -9924,7 +9990,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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 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() ): @@ -9994,13 +10060,13 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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) + 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 credential_environment_name(key) + 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 ) @@ -29894,3 +29960,42 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index cae6b14a..39ac68b1 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -242,7 +242,19 @@ def _scanner_namespace() -> dict[str, object]: else: names = _target_names(statement.target) value = statement.value - if "source" in names or value is None: + 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 @@ -253,6 +265,10 @@ def _scanner_namespace() -> dict[str, object]: ) 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" + ) namespace["source"] = PACKET_TEXT return namespace @@ -2136,6 +2152,7 @@ def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) - ( "docs/evidence/g01-recovery-packet.md", "scripts/evidence_packet/issue79_regression_test.py", + "docs/decisions/0004-offline-python-ast-regression-tooling.md", ), ) required_order = ( @@ -2145,6 +2162,7 @@ def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) - '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"])', ) @@ -2523,6 +2541,36 @@ def test_shell_origin_url_query_is_rejected_but_verifier_capture_remains(self) - ) 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_nested_raise_does_not_prove_module_root_guard(self) -> None: bodies = [ body @@ -2712,6 +2760,37 @@ def test_packet_loader_rejects_packet_controlled_definition_time_code(self) -> N 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_constructor_and_output_sink_aliases_preserve_sensitive_taint(self) -> None: unsafe = ( 'import os\n' @@ -2780,6 +2859,7 @@ def test_home_and_decoded_local_paths_are_not_disclosed(self) -> None: '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): From 6f3f8c8b5427222232104304d7c7ba0c41e2187f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 02:15:10 +0900 Subject: [PATCH 020/123] Close reviewed evidence scanner bypasses --- docs/evidence/g01-recovery-packet.md | 291 +++++++++++++++++- .../issue79_regression_test.py | 95 +++++- 2 files changed, 375 insertions(+), 11 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 61b6f477..f8c71b79 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8353,7 +8353,7 @@ def git_filter_attribute_violation(tokens): 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 @@ -8379,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 @@ -8391,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: @@ -8405,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 @@ -12975,6 +12969,89 @@ def python_unknown_os_call_violation(tree): return None +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" + } + 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 + for node in ast.walk(tree): + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Name): + continue + if node.value.id not in subprocess_names: + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id not in subprocess_names: + subprocess_names.add(target.id) + changed = True + for node in ast.walk(tree): + 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 isinstance(node.args[0], ast.Name) + and node.args[0].id in subprocess_names + ): + 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.""" for node in ast.walk(tree): @@ -15930,6 +16007,117 @@ def python_open_read_violation(tree, parents): return None +def python_unreviewed_decorator_violation(tree, parents): + """Keep decorators limited to inert reviewed builtin forms.""" + for candidate in ast.walk(tree): + 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="") @@ -15959,6 +16147,12 @@ def inspect_python_heredoc(body, safe_marker): for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } + 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 ) @@ -29999,3 +30193,80 @@ 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`. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 39ac68b1..943e0601 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -129,7 +129,12 @@ def _scanner_module_source(packet: str) -> str: source = packet[code_start:terminator] if "def forbidden_command(tokens, depth=0):" not in source: raise AssertionError("packet scanner functions were not extracted") - return source + 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: @@ -209,6 +214,11 @@ def _validated_scanner_statements(module: ast.Module) -> tuple[ast.stmt, ...]: 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) @@ -269,6 +279,8 @@ def _scanner_namespace() -> dict[str, object]: 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 @@ -2571,6 +2583,17 @@ def test_git_show_output_and_reader_option_paths_are_reviewed(self) -> None: ) ) + 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 @@ -2791,6 +2814,76 @@ def test_packet_loader_rejects_unsupported_top_level_assignment(self) -> None: 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', + ) + 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( + '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', + ): + 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)) + self.assertIsNone(self.inspect('def reviewed():\n return "safe"\nprint(reviewed())\n')) + def test_constructor_and_output_sink_aliases_preserve_sensitive_taint(self) -> None: unsafe = ( 'import os\n' From 31c2e6018b39b9ae8b6ae57fb7b54c17e41bf7b5 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 04:00:37 +0900 Subject: [PATCH 021/123] Harden G01 evidence scanner against indirect aliases and mutators --- docs/evidence/g01-recovery-packet.md | 509 +++++++++++++++++- .../issue79_regression_test.py | 191 +++++++ 2 files changed, 685 insertions(+), 15 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index f8c71b79..0c7f0341 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9704,7 +9704,7 @@ def python_local_class_alias_names(name, method_name, scopes, methods_by_scope, index = expression.slice if isinstance(index, ast.Index): index = index.value - if isinstance(index, ast.Constant) and type(index.value) is int: + 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)) @@ -9887,7 +9887,8 @@ def python_assigned_callable_alias(name, target, tree): isinstance(value, ast.Subscript) and isinstance(value.value, (ast.List, ast.Tuple)) and isinstance(value.slice, ast.Constant) - and type(value.slice.value) is int + and isinstance(value.slice.value, int) + and not isinstance(value.slice.value, bool) ): elements = value.value.elts index = value.slice.value @@ -12978,6 +12979,17 @@ def python_subprocess_os_reexport_violation(tree): 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 @@ -12986,16 +12998,149 @@ def python_subprocess_os_reexport_violation(tree): changed = True while changed: changed = False - for node in ast.walk(tree): - if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Name): - continue - if node.value.id not in subprocess_names: - continue - for target in node.targets: - if isinstance(target, ast.Name) and target.id not in subprocess_names: + 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 + + 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): + 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 ( @@ -13045,8 +13190,7 @@ def python_subprocess_os_reexport_violation(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 in subprocess_names + and may_refer_to_subprocess(node.args[0]) ): return "Python heredoc dynamically accesses a subprocess module re-export" return None @@ -13054,6 +13198,30 @@ def python_subprocess_os_reexport_violation(tree): def python_filesystem_mutator_alias_violation(tree, parents): """Reject extracted mutator methods unless their receiver is temp-owned.""" + 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 "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): @@ -13884,6 +14052,31 @@ python_sensitive_sink_methods = { } +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.Attribute) + and node.attr.casefold() in python_sensitive_sink_methods + ): + continue + 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 + ): + continue + 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) @@ -13970,7 +14163,7 @@ def python_path_method_alias_visible(name, method, node, tree, parents): ast.BoolOp, ast.comprehension, ), - ) or type(current).__name__ in {"Match", "match_case"}: + ) or isinstance(current, (ast.Match, ast.match_case)): return True return False @@ -14235,6 +14428,45 @@ def python_resolved_local_path_expression( ) +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) + ) + ) + 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_sensitive_output_sink(node, tree=None): """Recognize output/error sinks without tainting ordinary containers/helpers.""" if not isinstance(node, ast.Call): @@ -14265,9 +14497,12 @@ def python_sensitive_output_sink(node, tree=None): 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 + and ( + any( + python_assigned_callable_alias(named_value.id, target, tree) + for target in named_targets + ) + or python_assigned_sink_method_alias(named_value.id, tree) ) ): return True @@ -14292,6 +14527,7 @@ def python_sensitive_output_sink(node, tree=None): python_assigned_callable_alias(name, target, tree) for target in targets ) + or python_assigned_sink_method_alias(name, tree) } imported_functions = { "builtins": {"print"}, @@ -16009,7 +16245,40 @@ def python_open_read_violation(tree, parents): def python_unreviewed_decorator_violation(tree, parents): """Keep decorators limited to inert reviewed builtin forms.""" + sys_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 == "sys" + } for candidate in ast.walk(tree): + if isinstance(candidate, ast.ImportFrom) and candidate.module == "sys" and any( + alias.name in {"modules", "*"} for alias in candidate.names + ): + return "Python heredoc imports the mutable sys.modules registry" + 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 == "modules" + and isinstance(candidate.value, ast.Name) + and candidate.value.id in sys_names + ): + return "Python heredoc accesses the mutable sys.modules registry" if isinstance(candidate, ast.Name) and candidate.id == "__builtins__": return "Python heredoc accesses the mutable builtins namespace" if isinstance(candidate, ast.Import) and any( @@ -16147,6 +16416,9 @@ 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 @@ -30270,3 +30542,210 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 943e0601..55e492e5 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2832,11 +2832,151 @@ def test_reexported_os_module_does_not_bypass_heredoc_checks(self) -> None: '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.assertIsNone(self.inspect( 'class Settings:\n os = "darwin"\nprint(Settings.os)\n' )) @@ -2869,6 +3009,9 @@ def test_command_capable_decorator_cannot_replace_safe_function(self) -> None: '__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' @@ -2882,8 +3025,56 @@ def test_command_capable_decorator_cannot_replace_safe_function(self) -> None: ) 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' From b902f7df1d910a3fa836e60f983d65f15e7f613d Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 04:25:47 +0900 Subject: [PATCH 022/123] Close exact-head G01 scanner review findings --- docs/evidence/g01-recovery-packet.md | 209 ++++++++++++++++-- .../issue79_regression_test.py | 86 +++++++ 2 files changed, 282 insertions(+), 13 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 0c7f0341..ea6bd6b4 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -7384,6 +7384,8 @@ def shell_sensitive_parameter_violation(tokens): 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 ( @@ -7906,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): @@ -8872,9 +8872,19 @@ def shell_reader_path_violation(tokens): "jq": {"-f"}, }.get(executable, set()) position = 1 + expression_consumed = executable not in {"awk", "grep", "jq", "rg"} while position < len(tokens): token = tokens[position] option = token.split("=", 1)[0] + if token in {"<<<", ">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>", "<", "0<"}: + position += 2 + continue + if token.startswith((">", "1>", "2>", "&>", "<", "0<")): + position += 1 + continue + if executable == "rg" and token in {"--glob", "--iglob", "-g"}: + position += 2 + continue if option in file_options: if "=" in token: path = token.split("=", 1)[1] @@ -8885,12 +8895,16 @@ def shell_reader_path_violation(tokens): path = tokens[position] if not shell_reviewed_reader_path(path): return "reader option path is not reviewed or packet-owned" + expression_consumed = True elif 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 token in {"--", "<<<"} or token.startswith("-"): position += 1 continue + elif executable == "tr" or not expression_consumed: + expression_consumed = True elif not shell_reviewed_reader_path(token): return "reader path is not reviewed or packet-owned" position += 1 @@ -10396,7 +10410,13 @@ def python_sensitive_join_argument(node, sensitive_names, tree, parents, seen=No def python_sensitive_value_names(tree, parents): """Resolve credential aliases and local-helper parameter taint.""" tree._issue79_member_taint_enabled = False - sensitive_names = set() + 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): @@ -12950,17 +12970,54 @@ python_reviewed_os_calls = { def python_unknown_os_call_violation(tree): """Fail closed for OS calls whose path/effect surface is not reviewed.""" + os_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 == "os" + } + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Name): + continue + if node.value.id not in os_names: + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id not in os_names: + os_names.add(target.id) + changed = True + 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 dotted = python_dotted_name(node.func) - if not dotted or not dotted.startswith("os."): + 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 ( - dotted in python_filesystem_mutating_functions - or dotted in python_reviewed_os_calls - or dotted.startswith("os.path.") - or dotted.startswith("os.environ.") + canonical in python_filesystem_mutating_functions + or canonical in python_reviewed_os_calls + or canonical.startswith("os.path.") + or canonical.startswith("os.environ.") ): continue return ( @@ -13070,6 +13127,46 @@ def python_subprocess_os_reexport_violation(tree): 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 ( + (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 "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) @@ -15705,6 +15802,16 @@ def python_sensitive_read_violation(tree, parents): list(node.exc.args) + [keyword.value for keyword in node.exc.keywords] if isinstance(node.exc, ast.Call) else [node.exc] ) + 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 @@ -16254,9 +16361,9 @@ def python_unreviewed_decorator_violation(tree, parents): } for candidate in ast.walk(tree): if isinstance(candidate, ast.ImportFrom) and candidate.module == "sys" and any( - alias.name in {"modules", "*"} for alias in candidate.names + alias.name in {"modules", "_getframe", "_current_frames", "*"} for alias in candidate.names ): - return "Python heredoc imports the mutable sys.modules registry" + return "Python heredoc imports a mutable process namespace handle" if ( isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Name) @@ -16274,11 +16381,11 @@ def python_unreviewed_decorator_violation(tree, parents): return "Python heredoc accesses the mutable global namespace" if ( isinstance(candidate, ast.Attribute) - and candidate.attr == "modules" + 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 the mutable sys.modules registry" + 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( @@ -30749,3 +30856,79 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 55e492e5..5f3bc99c 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2977,6 +2977,60 @@ def test_reexported_os_module_does_not_bypass_heredoc_checks(self) -> None: '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' )) @@ -3395,6 +3449,38 @@ def test_shell_environment_dump_readers_are_rejected(self) -> None: 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\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.assertIsNone(self.inspect('raise RuntimeError("reviewed")\n')) + def test_jq_environment_object_references_are_rejected(self) -> None: for command in ( "jq -n '$ENV'", From 9a31f9942dcfa29b3072c29599b31fb1618b2549 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 04:41:27 +0900 Subject: [PATCH 023/123] Close module dictionary, shutil alias, and AWK review gaps --- docs/evidence/g01-recovery-packet.md | 87 ++++++++++++++++++- .../issue79_regression_test.py | 24 +++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index ea6bd6b4..62ba79dc 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8814,6 +8814,7 @@ def awk_command_violation(tokens): 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) @@ -12989,6 +12990,21 @@ def python_unknown_os_call_violation(tree): if isinstance(target, ast.Name) and target.id not in os_names: os_names.add(target.id) changed = True + 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 == "vars" 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 in os_names + ): + return "Python heredoc accesses an OS module dictionary" for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) @@ -13295,6 +13311,34 @@ def python_subprocess_os_reexport_violation(tree): def python_filesystem_mutator_alias_violation(tree, parents): """Reject extracted mutator methods unless their receiver is temp-owned.""" + shutil_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 == "shutil" + } + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Name): + continue + if node.value.id not in shutil_names: + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id not in shutil_names: + shutil_names.add(target.id) + changed = True + 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.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 @@ -15798,10 +15842,15 @@ def python_sensitive_read_violation(tree, parents): "Python credential/environment value is sent to an exception " f"on line {node.lineno}" ) - exception_values = ( - list(node.exc.args) + [keyword.value for keyword in node.exc.keywords] - if isinstance(node.exc, ast.Call) else [node.exc] - ) + exception_values = [] + for exception in (node.exc, node.cause): + if isinstance(exception, ast.Call): + exception_values.extend(exception.args) + exception_values.extend( + keyword.value for keyword in exception.keywords + ) + elif exception is not None: + exception_values.append(exception) if any( python_sensitive_value_expression( value, sensitive_names, tree, parents @@ -30932,3 +30981,33 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 5f3bc99c..50af1ce4 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3479,8 +3479,32 @@ def test_shell_reader_rejects_unreviewed_relative_credential_file(self) -> None: 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.assertIsNone(self.inspect('raise RuntimeError("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_shutil_module_assignment_alias_cannot_hide_mutation(self) -> None: + 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_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_jq_environment_object_references_are_rejected(self) -> None: for command in ( "jq -n '$ENV'", From 86d90df10da6dde430a1ace71a50300dece61beb Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 05:01:14 +0900 Subject: [PATCH 024/123] Close reader, signal, assertion, and jq review gaps --- docs/evidence/g01-recovery-packet.md | 240 ++++++++++++++---- .../issue79_regression_test.py | 63 +++++ 2 files changed, 250 insertions(+), 53 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 62ba79dc..f5d46ce7 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8828,6 +8828,10 @@ def jq_command_violation(tokens): 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 executable == "rg" and token in {"--glob", "--iglob", "-g"}: + if not operand_mode and executable == "rg" and token in {"--glob", "--iglob", "-g"}: position += 2 continue - if option in file_options: + 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: @@ -8897,18 +8914,22 @@ def shell_reader_path_violation(tokens): if not shell_reviewed_reader_path(path): return "reader option path is not reviewed or packet-owned" expression_consumed = True - elif token.startswith("-f") and "-f" in file_options and len(token) > 2: + 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 token in {"--", "<<<"} or token.startswith("-"): + elif not operand_mode and (token == "<<<" or token.startswith("-")): position += 1 continue elif executable == "tr" or not expression_consumed: expression_consumed = True 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 @@ -12417,13 +12438,53 @@ def reviewed_python_signal_target(node, tree, parents, dotted): 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 + + 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 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) + dotted = signal_name(node.func) or python_dotted_name(node.func) if dotted not in python_process_signal_functions: continue if reviewed_python_signal_target(node, tree, parents, dotted): @@ -12969,27 +13030,40 @@ python_reviewed_os_calls = { } -def python_unknown_os_call_violation(tree): - """Fail closed for OS calls whose path/effect surface is not reviewed.""" - os_names = { +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 == "os" + 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 not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Name): - continue - if node.value.id not in os_names: - continue - for target in node.targets: - if isinstance(target, ast.Name) and target.id not in os_names: - os_names.add(target.id) - changed = True + 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 + 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) @@ -12999,7 +13073,11 @@ def python_unknown_os_call_violation(tree): ) or ( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) - and (node.func.id == "vars" or python_assigned_callable_alias(node.func.id, "vars", tree)) + 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 isinstance(node.args[0], ast.Name) and node.args[0].id in os_names @@ -13311,26 +13389,16 @@ def python_subprocess_os_reexport_violation(tree): def python_filesystem_mutator_alias_violation(tree, parents): """Reject extracted mutator methods unless their receiver is temp-owned.""" - shutil_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 == "shutil" - } - changed = True - while changed: - changed = False - for node in ast.walk(tree): - if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Name): - continue - if node.value.id not in shutil_names: - continue - for target in node.targets: - if isinstance(target, ast.Name) and target.id not in shutil_names: - shutil_names.add(target.id) - changed = True + 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) @@ -15823,6 +15891,24 @@ def python_sensitive_read_violation(tree, parents): assignments_by_name.setdefault(function.args.kwarg.arg, []).append( (function, keyword.value) ) + def exception_arguments(value, seen_names=None): + if value is None: + return [] + if seen_names is None: + seen_names = set() + if isinstance(value, ast.Name): + if value.id in seen_names: + return [value] + sources = assignments_by_name.get(value.id, ()) + return [value] + [ + argument + for _scope, source in sources + for argument in exception_arguments(source, seen_names | {value.id}) + ] + if isinstance(value, ast.Call): + return list(value.args) + [keyword.value for keyword in value.keywords] + return [value] + 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" @@ -15834,6 +15920,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 @@ -15842,15 +15931,7 @@ def python_sensitive_read_violation(tree, parents): "Python credential/environment value is sent to an exception " f"on line {node.lineno}" ) - exception_values = [] - for exception in (node.exc, node.cause): - if isinstance(exception, ast.Call): - exception_values.extend(exception.args) - exception_values.extend( - keyword.value for keyword in exception.keywords - ) - elif exception is not None: - exception_values.append(exception) + exception_values = exception_arguments(node.exc) + exception_arguments(node.cause) if any( python_sensitive_value_expression( value, sensitive_names, tree, parents @@ -16401,13 +16482,7 @@ def python_open_read_violation(tree, parents): def python_unreviewed_decorator_violation(tree, parents): """Keep decorators limited to inert reviewed builtin forms.""" - sys_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 == "sys" - } + 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 @@ -31011,3 +31086,62 @@ commands and 95 Python heredoc bodies with zero violations. `git diff 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 50af1ce4..467f5375 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3453,6 +3453,9 @@ def test_os_module_assignment_alias_cannot_hide_filesystem_mutation(self) -> Non 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\nalias = os\nprint(alias.environ)\n')) self.assertIsNotNone(self.inspect( 'import os\nalias = os\nprint(alias.getenv("GH_TOKEN"))\n' @@ -3482,8 +3485,16 @@ def test_exception_arguments_keep_environment_taint(self) -> None: 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.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', @@ -3493,18 +3504,65 @@ def test_module_dictionary_environment_access_is_rejected(self) -> None: 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.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.assertIsNone(self.shell_violation('rg . docs/EXECUTION.md')) + self.assertIsNone(self.shell_violation('rg -n . <<< reviewed')) + def test_jq_environment_object_references_are_rejected(self) -> None: for command in ( "jq -n '$ENV'", @@ -3515,6 +3573,11 @@ def test_jq_environment_object_references_are_rejected(self) -> None: 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' From f24c73ad55ac6cfb5164a04f9aae2de54ff23e18 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 05:12:26 +0900 Subject: [PATCH 025/123] Close independent G01 scanner bypasses --- docs/evidence/g01-recovery-packet.md | 59 +++++++++++++++++-- .../issue79_regression_test.py | 11 ++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index f5d46ce7..1a53f580 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8923,6 +8923,8 @@ def shell_reader_path_violation(tokens): continue elif executable == "tr" or not expression_consumed: expression_consumed = True + elif executable == "rg" and token in {".", "./"}: + return "rg may not use the repository root as a reader operand" elif not shell_reviewed_reader_path(token): return "reader path is not reviewed or packet-owned" else: @@ -13058,6 +13060,9 @@ def python_assigned_module_names(tree, module): 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)): + if isinstance(node.iter, (ast.Tuple, ast.List)): + changed = any([bind(node.target, item) for item in node.iter.elts]) or changed return names @@ -13079,8 +13084,10 @@ def python_unknown_os_call_violation(tree): or python_assigned_callable_alias(node.func.id, "getattr", tree) ) and node.args - and isinstance(node.args[0], ast.Name) - and node.args[0].id in os_names + 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): @@ -15891,12 +15898,14 @@ def python_sensitive_read_violation(tree, parents): assignments_by_name.setdefault(function.args.kwarg.arg, []).append( (function, keyword.value) ) - def exception_arguments(value, seen_names=None): + 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] sources = assignments_by_name.get(value.id, ()) @@ -15906,7 +15915,12 @@ def python_sensitive_read_violation(tree, parents): for argument in exception_arguments(source, seen_names | {value.id}) ] if isinstance(value, ast.Call): - return list(value.args) + [keyword.value for keyword in value.keywords] + 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, False) + ] return [value] for alias, receiver in path_reader_aliases.items(): @@ -31145,3 +31159,40 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 467f5375..54d2d3ec 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3456,6 +3456,9 @@ def test_os_module_assignment_alias_cannot_hide_filesystem_mutation(self) -> Non 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\nalias = os\nprint(alias.environ)\n')) self.assertIsNotNone(self.inspect( 'import os\nalias = os\nprint(alias.getenv("GH_TOKEN"))\n' @@ -3489,6 +3492,10 @@ def test_exception_arguments_keep_environment_taint(self) -> None: '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.assertIsNone(self.inspect('raise RuntimeError("reviewed")\n')) def test_assertion_message_cannot_disclose_environment(self) -> None: @@ -3508,6 +3515,9 @@ 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' )) @@ -3560,6 +3570,7 @@ def test_awk_program_cannot_rewrite_reader_argv(self) -> None: 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.assertIsNone(self.shell_violation('rg . docs/EXECUTION.md')) self.assertIsNone(self.shell_violation('rg -n . <<< reviewed')) From cf4ae9e8ab799b55644dd6814c9a6b6cccecd924 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 05:23:45 +0900 Subject: [PATCH 026/123] Close recursive reader and nested alias review gaps --- docs/evidence/g01-recovery-packet.md | 69 +++++++++++++++++-- .../issue79_regression_test.py | 9 +++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 1a53f580..b99e8cfc 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8869,6 +8869,14 @@ def shell_reader_path_violation(tokens): if not tokens or executable_basename(tokens[0]) not in reviewed_reader_executables: return None executable = executable_basename(tokens[0]) + recursive_grep = executable == "grep" and any( + token in {"-r", "-R", "--recursive"} + or ( + token.startswith("-") and not token.startswith("--") + and any(flag in token[1:] for flag in "rR") + ) + for token in tokens[1:] + ) file_options = { "diff": {"--from-file", "--to-file"}, "grep": {"-f", "--file"}, @@ -8923,8 +8931,8 @@ def shell_reader_path_violation(tokens): continue elif executable == "tr" or not expression_consumed: expression_consumed = True - elif executable == "rg" and token in {".", "./"}: - return "rg may not use the repository root as a reader operand" + 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: @@ -13061,8 +13069,17 @@ def python_assigned_module_names(tree, module): elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): changed = bind(node.target, node.value) or changed elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)): - if isinstance(node.iter, (ast.Tuple, ast.List)): - changed = any([bind(node.target, item) for item in node.iter.elts]) or changed + iterable = node.iter + if ( + isinstance(iterable, ast.Call) + and isinstance(iterable.func, ast.Name) + and iterable.func.id in {"tuple", "list", "set"} + 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 @@ -15908,7 +15925,21 @@ def python_sensitive_read_violation(tree, parents): return [value] if value.id in seen_names: return [value] - sources = assignments_by_name.get(value.id, ()) + 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") + ) + ) + ) + ) return [value] + [ argument for _scope, source in sources @@ -15919,7 +15950,7 @@ def python_sensitive_read_violation(tree, parents): return [value] + [ nested for argument in arguments - for nested in exception_arguments(argument, seen_names, False) + for nested in exception_arguments(argument, seen_names) ] return [value] @@ -31196,3 +31227,29 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 54d2d3ec..53aaa752 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3459,6 +3459,9 @@ def test_os_module_assignment_alias_cannot_hide_filesystem_mutation(self) -> Non 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\nalias = os\nprint(alias.environ)\n')) self.assertIsNotNone(self.inspect( 'import os\nalias = os\nprint(alias.getenv("GH_TOKEN"))\n' @@ -3496,6 +3499,10 @@ def test_exception_arguments_keep_environment_taint(self) -> None: '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.assertIsNone(self.inspect('raise RuntimeError("reviewed")\n')) def test_assertion_message_cannot_disclose_environment(self) -> None: @@ -3571,7 +3578,9 @@ 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.assertIsNone(self.shell_violation('rg . docs/EXECUTION.md')) + self.assertIsNone(self.shell_violation('grep -R . docs/')) self.assertIsNone(self.shell_violation('rg -n . <<< reviewed')) def test_jq_environment_object_references_are_rejected(self) -> None: From 415e551742e9c3099da88caafff36796156368d9 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 05:34:18 +0900 Subject: [PATCH 027/123] Close remaining grep and exception alias gaps --- docs/evidence/g01-recovery-packet.md | 49 +++++++++++++++++-- .../issue79_regression_test.py | 10 ++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index b99e8cfc..50676cb9 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8871,11 +8871,17 @@ def shell_reader_path_violation(tokens): executable = executable_basename(tokens[0]) recursive_grep = executable == "grep" and any( token in {"-r", "-R", "--recursive"} + or token in {"-drecurse", "--directories=recurse"} + or ( + token in {"-d", "--directories"} + and index + 1 < len(tokens) + and tokens[index + 1] == "recurse" + ) or ( token.startswith("-") and not token.startswith("--") and any(flag in token[1:] for flag in "rR") ) - for token in tokens[1:] + for index, token in enumerate(tokens[1:], 1) ) file_options = { "diff": {"--from-file", "--to-file"}, @@ -8906,6 +8912,9 @@ def shell_reader_path_violation(tokens): 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 in {"-d", "--directories"}: + position += 2 + continue if not operand_mode and executable in {"grep", "rg"} and token in {"-e", "--regexp"}: position += 1 if position >= len(tokens): @@ -13073,7 +13082,7 @@ def python_assigned_module_names(tree, module): if ( isinstance(iterable, ast.Call) and isinstance(iterable.func, ast.Name) - and iterable.func.id in {"tuple", "list", "set"} + and iterable.func.id in {"tuple", "list", "set", "iter", "reversed", "sorted"} and len(iterable.args) == 1 and not iterable.keywords ): @@ -15934,8 +15943,14 @@ def python_sensitive_read_violation(tree, parents): isinstance(source, ast.Name) or ( isinstance(source, ast.Call) - and (python_dotted_name(source.func) or "").rsplit(".", 1)[-1].endswith( - ("Error", "Exception", "Exit") + and ( + (python_dotted_name(source.func) or "").rsplit(".", 1)[-1].endswith( + ("Error", "Exception", "Exit") + ) + or (python_dotted_name(source.func) or "").rsplit(".", 1)[-1] in { + "StopIteration", "StopAsyncIteration", "KeyboardInterrupt", + "ExceptionGroup", "BaseExceptionGroup", + } ) ) ) @@ -31253,3 +31268,29 @@ 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. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 53aaa752..4911cc35 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3462,6 +3462,9 @@ def test_os_module_assignment_alias_cannot_hide_filesystem_mutation(self) -> Non 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\nalias = os\nprint(alias.environ)\n')) self.assertIsNotNone(self.inspect( 'import os\nalias = os\nprint(alias.getenv("GH_TOKEN"))\n' @@ -3503,6 +3506,10 @@ def test_exception_arguments_keep_environment_taint(self) -> None: '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.assertIsNone(self.inspect('raise RuntimeError("reviewed")\n')) def test_assertion_message_cannot_disclose_environment(self) -> None: @@ -3579,8 +3586,11 @@ def test_shell_reader_requires_explicit_reviewed_operand(self) -> None: 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.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: From 2390a54e25065fb9856dc464fef902b3832f2b34 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 29 Sep 2026 05:47:45 +0900 Subject: [PATCH 028/123] Close abbreviated grep and warning alias gaps --- docs/evidence/g01-recovery-packet.md | 60 +++++++++++++++++-- .../issue79_regression_test.py | 15 +++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 50676cb9..9ef833ff 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -8872,8 +8872,9 @@ def shell_reader_path_violation(tokens): recursive_grep = executable == "grep" and any( token in {"-r", "-R", "--recursive"} or token in {"-drecurse", "--directories=recurse"} + or (token.startswith("--dir") and token.endswith("=recurse")) or ( - token in {"-d", "--directories"} + (token == "-d" or token.startswith("--dir")) and index + 1 < len(tokens) and tokens[index + 1] == "recurse" ) @@ -8912,7 +8913,9 @@ def shell_reader_path_violation(tokens): 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 in {"-d", "--directories"}: + 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"}: @@ -13079,6 +13082,16 @@ def python_assigned_module_names(tree, module): 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) @@ -15924,6 +15937,16 @@ def python_sensitive_read_violation(tree, parents): 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 [] @@ -15945,12 +15968,14 @@ def python_sensitive_read_violation(tree, parents): isinstance(source, ast.Call) and ( (python_dotted_name(source.func) or "").rsplit(".", 1)[-1].endswith( - ("Error", "Exception", "Exit") + ("Error", "Exception", "Exit", "Warning") ) or (python_dotted_name(source.func) or "").rsplit(".", 1)[-1] in { "StopIteration", "StopAsyncIteration", "KeyboardInterrupt", - "ExceptionGroup", "BaseExceptionGroup", + "ExceptionGroup", "BaseExceptionGroup", "TimeoutExpired", } + or (python_dotted_name(source.func) or "").rsplit(".", 1)[-1] + in local_exception_names ) ) ) @@ -31294,3 +31319,30 @@ 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 index 4911cc35..e33d580d 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3465,6 +3465,10 @@ def test_os_module_assignment_alias_cannot_hide_filesystem_mutation(self) -> Non 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' @@ -3510,6 +3514,15 @@ def test_exception_arguments_keep_environment_taint(self) -> None: '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: @@ -3588,6 +3601,8 @@ def test_shell_reader_requires_explicit_reviewed_operand(self) -> None: 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/')) From a6343f3440e343f625c465cdc330f58b90831511 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 12:10:47 +0900 Subject: [PATCH 029/123] test(g01): reject byte environment and local-path evidence leaks --- docs/evidence/g01-recovery-packet.md | 24 +++++++++++++------ .../issue79_regression_test.py | 17 +++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 9ef833ff..56c9805d 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9373,12 +9373,20 @@ def python_credential_reader_aliases(tree): """Resolve only aliases of credential-bearing environment readers.""" reader_names = { "os.getenv", + "os.getenvb", "os.environ.get", "os.environ.setdefault", "os.environ.pop", "os.environ.__getitem__", } aliases = set() + aliases.update( + 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 == "getenvb" + ) assignments = [] for node in ast.walk(tree): if isinstance(node, ast.Assign): @@ -10038,10 +10046,10 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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": + if isinstance(node, ast.Attribute) and python_dotted_name(node) in {"os.environ", "os.environb"}: return True if isinstance(node, ast.Subscript): - if python_dotted_name(node.value) == "os.environ": + if python_dotted_name(node.value) in {"os.environ", "os.environb"}: 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( @@ -10109,7 +10117,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen for value in list(node.args) + [keyword.value for keyword in node.keywords] ) - if dotted == "os.getenv": + if dotted in {"os.getenv", "os.getenvb"}: return True if dotted == "os.environ.get": key = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None @@ -10459,7 +10467,7 @@ def python_sensitive_value_names(tree, parents): for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module == "os" for alias in node.names - if alias.name == "environ" + if alias.name in {"environ", "environb"} } assignments = [] for node in ast.walk(tree): @@ -14270,7 +14278,7 @@ def python_reviewed_read_path(node, tree, parents, seen=None): return python_reviewed_read_path(node.args[0], tree, parents, seen) if ( isinstance(node.func, ast.Attribute) - and node.func.attr == "resolve" + and node.func.attr in {"resolve", "absolute"} and not node.args and not node.keywords ): @@ -14641,7 +14649,7 @@ def python_resolved_local_path_expression( return True if ( isinstance(node.func, ast.Attribute) - and node.func.attr == "resolve" + and node.func.attr in {"resolve", "absolute"} ): return True path_preserving_calls = { @@ -14732,6 +14740,7 @@ def python_sensitive_output_sink(node, tree=None): "sys.exit", "warnings.warn", "warnings.warn_explicit", + "warnings.showwarning", "traceback.print_exc", "traceback.print_exception", }: @@ -14744,6 +14753,7 @@ def python_sensitive_output_sink(node, tree=None): "sys.exit", "warnings.warn", "warnings.warn_explicit", + "warnings.showwarning", "traceback.print_exc", "traceback.print_exception", ) @@ -16039,7 +16049,7 @@ def python_sensitive_read_violation(tree, parents): ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) - if dotted == "os.getenv": + if dotted in {"os.getenv", "os.getenvb"}: key = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None if key is None or not isinstance(key, str) or credential_environment_name(key): return f"Python credential/environment read os.getenv is not allowed on line {node.lineno}" diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index e33d580d..b481a51d 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3483,6 +3483,23 @@ def test_from_import_environment_alias_remains_sensitive(self) -> None: 'from os import environ as inherited\nprint("reviewed")\n' )) + def test_byte_environment_mappings_are_sensitive(self) -> None: + for body in ( + 'import os\nprint(os.environb)\n', + 'from os import environb\nprint(environb)\n', + 'from os import getenvb as read_bytes\nprint(read_bytes(b"GH_TOKEN"))\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + def test_warning_sink_and_absolute_path_do_not_disclose_local_data(self) -> None: + self.assertIsNotNone(self.inspect( + 'import os, warnings\nwarnings.showwarning(os.environ, UserWarning, "x", 1)\n' + )) + self.assertIsNotNone(self.inspect( + 'from pathlib import Path\nprint(Path(".").absolute())\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}\"" From 98da2a8567d0dedbc7289ed14582a89138268cb0 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 12:16:19 +0900 Subject: [PATCH 030/123] fix(g01): refuse escaped module values in evidence scanner --- docs/evidence/g01-recovery-packet.md | 89 +++++++++++++++++-- .../issue79_regression_test.py | 27 ++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 56c9805d..3dbbd36c 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13143,7 +13143,7 @@ def python_unknown_os_call_violation(tree): and isinstance(node.value, ast.Name) and node.value.id in os_names and node.value.id != "os" - and node.attr in {"environ", "getenv"} + and node.attr in {"environ", "environb", "getenv", "getenvb"} ): return "Python heredoc accesses environment values through an OS module alias" for node in ast.walk(tree): @@ -13175,6 +13175,47 @@ def python_unknown_os_call_violation(tree): return None +def python_module_value_escape_violation(tree, parents): + """Keep security-sensitive modules on direct, inspectable attribute paths.""" + protected = {"os", "subprocess", "shutil", "signal", "sys", "pathlib", "warnings", "builtins", "importlib"} + names = { + alias.asname or alias.name.split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + if alias.name.split(".")[0] in protected + } + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + targets, value = node.targets, node.value + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): + targets, value = [node.target], node.value + else: + continue + if isinstance(value, ast.Name) and value.id in names: + for target in targets: + if isinstance(target, ast.Name) and target.id not in names: + names.add(target.id) + changed = True + for node in ast.walk(tree): + if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load) or node.id not in names: + continue + parent = parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue + if isinstance(parent, ast.Assign) and parent.value is node and all( + isinstance(target, ast.Name) for target in parent.targets + ): + continue + if isinstance(parent, (ast.AnnAssign, ast.NamedExpr)) and parent.value is node and isinstance(parent.target, ast.Name): + continue + return f"Python security module value escapes its reviewed attribute path on line {node.lineno}" + return None + + def python_subprocess_os_reexport_violation(tree): """Do not let subprocess's imported os module bypass direct os guards.""" subprocess_names = { @@ -14505,7 +14546,7 @@ def python_path_method_reference(value, method, tree): ) if isinstance(value, ast.Attribute) and value.attr == method: - return is_path_constructor(value.value) + return method in {"resolve", "absolute"} or is_path_constructor(value.value) if isinstance(value, ast.NamedExpr): return python_path_method_reference(value.value, method, tree) if isinstance(value, ast.Call): @@ -14516,7 +14557,7 @@ def python_path_method_reference(value, method, tree): return ( is_getattr and len(value.args) in {2, 3} - and is_path_constructor(value.args[0]) + and (method in {"resolve", "absolute"} or is_path_constructor(value.args[0])) and method in python_static_string_values(value.args[1], tree) ) return False @@ -14571,7 +14612,10 @@ def python_resolved_local_path_expression( is_path_cwd = python_path_method_expression_visible( node.func, "cwd", node, tree, parents ) - if is_path_home or is_path_cwd: + if is_path_home or is_path_cwd or any( + python_path_method_expression_visible(node.func, method, node, tree, parents) + for method in ("resolve", "absolute") + ): return True if dotted == "os.path.expanduser" and node.args: return any( @@ -14780,6 +14824,7 @@ def python_sensitive_output_sink(node, tree=None): "sys.exit", "warnings.warn", "warnings.warn_explicit", + "warnings.showwarning", "traceback.print_exc", "traceback.print_exception", ) @@ -14797,7 +14842,7 @@ def python_sensitive_output_sink(node, tree=None): imported_functions = { "builtins": {"print"}, "sys": {"exit"}, - "warnings": {"warn", "warn_explicit"}, + "warnings": {"warn", "warn_explicit", "showwarning"}, "traceback": {"print_exc", "print_exception"}, } for candidate in ast.walk(tree): @@ -16742,6 +16787,9 @@ def inspect_python_heredoc(body, safe_marker): for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } + module_escape_violation = python_module_value_escape_violation(tree, parents) + if module_escape_violation: + return module_escape_violation sink_storage_violation = python_sensitive_sink_storage_violation(tree, parents) if sink_storage_violation: return sink_storage_violation @@ -31356,3 +31404,34 @@ 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. + +### Issue #79 correction of the completed `2390a54` Codex review + +Codex completed review of `2390a54e25065fb9856dc464fef902b3832f2b34` +on 2026-09-28. Its four P1 findings are blocking until the corrected +candidate receives independent review, a passing hosted quick check and a +completed GitHub Codex review. The following specimens are inert AST inputs; +none was executed with inherited environment values or real resources. + +| Finding at the reviewed input | Reproduction and correction | +|---|---| +| [Module values recovered from containers](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4127016661) | `test_module_values_cannot_escape_through_containers_or_helpers` reproduced six accepted bypasses: list/dict storage, a destructive-module container, helper returns, helper arguments and iterator recovery. Security-sensitive module values now remain on direct attribute paths or direct name aliases; storing/passing/returning them through unsupported dynamic paths is rejected before downstream certification. Direct unused imports and direct unused aliases remain accepted. | +| [Warning output sink](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4127016664) | Direct `warnings.showwarning` was accepted before local correction `a6343f3`. Further RED cases reproduced assigned and from-import sink aliases. The direct, named-expression, assigned and imported sink tables now include `showwarning`. | +| [Absolute local path output](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4127016674) | The direct `Path(".").absolute()` output was corrected in `a6343f3`; subsequent RED reproduced assigned and `getattr` method aliases. The existing lexical method resolver now recognizes `absolute` and `resolve` when tracking local-path output. Relative reviewed paths retain their existing positive controls. | +| [Byte environment access](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4127016685) | Direct/from-import `environb` and imported `getenvb` each failed the new negative test before `a6343f3`. A subsequent RED reproduced `alias = os; print(alias.environb)`. Byte environment mappings/accessors now enter the same sensitivity boundary, including OS module aliases. | + +The initial local command `python3 -m unittest` with the two new test names +failed with four assertions before `a6343f3`; its GREEN rerun passed both +methods. The full local module invocation then passed 118 tests in 258.150s, +including 331 shell commands and 95 Python heredocs with zero static scanner +violations. That invocation was not the isolated ADR 0004 entry point. + +The later command `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest` with +`test_module_values_cannot_escape_through_containers_or_helpers` and +`test_warning_and_absolute_path_aliases_preserve_sensitive_values` failed +with 11 assertions before their corrections. Its GREEN rerun, including the +two earlier direct tests and the lexical-shadowing control, passed five +methods in 0.123s. Results for the canonical isolated full invocation and +independent review are recorded in the PR against the combined candidate +when those gates settle. No live qualification result is claimed. Rollback is a reviewed reversal of this correction's +packet/harness delta; the parent G01 and G02 live gates remain open. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index b481a51d..7b4426c8 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3500,6 +3500,33 @@ def test_warning_sink_and_absolute_path_do_not_disclose_local_data(self) -> None 'from pathlib import Path\nprint(Path(".").absolute())\n' )) + def test_module_values_cannot_escape_through_containers_or_helpers(self) -> None: + unsafe = ( + 'import os\nmods = [os]\nprint(mods[0].environ)\n', + 'import os\nmods = {"module": os}\nmods["module"].system("synthetic-command")\n', + 'import shutil\nmods = [shutil]\nmods[0].rmtree("synthetic-owned")\n', + 'import os\ndef module():\n return os\nprint(module().environ)\n', + 'import os\ndef identity(value):\n return value\nprint(identity(os).environ)\n', + 'import os\nmods = iter([os])\nprint(next(mods).environ)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('import os\nprint("reviewed")\n')) + self.assertIsNone(self.inspect('import os\nalias = os\nprint("reviewed")\n')) + + def test_warning_and_absolute_path_aliases_preserve_sensitive_values(self) -> None: + unsafe = ( + 'import os, warnings\nemit = warnings.showwarning\nemit(os.environ, UserWarning, "x", 1)\n', + 'import os\nfrom warnings import showwarning as emit\nemit(os.environ, UserWarning, "x", 1)\n', + 'from pathlib import Path\nabsolute = Path(".").absolute\nprint(absolute())\n', + 'from pathlib import Path\nabsolute = getattr(Path("."), "absolute")\nprint(absolute())\n', + 'import os\nalias = os\nprint(alias.environb)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + 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}\"" From 55c5f6f5be97508abdb2264fa7afc5dd7ee20393 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 12:40:11 +0900 Subject: [PATCH 031/123] fix(g01): bound module namespaces and path output provenance --- docs/evidence/g01-recovery-packet.md | 187 +++++++++++++++++- .../issue79_regression_test.py | 31 +++ 2 files changed, 210 insertions(+), 8 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 3dbbd36c..74a55f99 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13175,7 +13175,7 @@ def python_unknown_os_call_violation(tree): return None -def python_module_value_escape_violation(tree, parents): +def python_module_value_escape_violation(tree, parents, safe_marker): """Keep security-sensitive modules on direct, inspectable attribute paths.""" protected = {"os", "subprocess", "shutil", "signal", "sys", "pathlib", "warnings", "builtins", "importlib"} names = { @@ -13185,6 +13185,14 @@ def python_module_value_escape_violation(tree, parents): for alias in node.names if alias.name.split(".")[0] in protected } + path_modules = { + 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 == "path" + } + names.update(path_modules) changed = True while changed: changed = False @@ -13200,10 +13208,111 @@ def python_module_value_escape_violation(tree, parents): if isinstance(target, ast.Name) and target.id not in names: names.add(target.id) changed = True + + rebound_names = { + node.id for node in ast.walk(tree) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) + } | { + node.arg for node in ast.walk(tree) if isinstance(node, ast.arg) + } | { + node.name for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } | { + alias.asname or alias.name.split(".")[0] + for node in ast.walk(tree) + if isinstance(node, (ast.Import, ast.ImportFrom)) + for alias in node.names + } + + def reviewed_namespace_argument(node, parent): + return ( + isinstance(parent, ast.Call) + and len(parent.args) in {2, 3} + and not parent.keywords + and node in parent.args[1:] + and not {"exec", "compile"}.intersection(rebound_names) + and reviewed_python_exec_call(parent, safe_marker, tree) + ) + + def reviewed_dictionary_storage(dictionary): + if not dictionary.keys or not all( + isinstance(key, ast.Constant) and isinstance(key.value, str) + for key in dictionary.keys + ): + return False + keys = [key.value for key in dictionary.keys] + if len(keys) != len(set(keys)): + return False + declaration = parents.get(dictionary) + if reviewed_namespace_argument(dictionary, declaration): + return True + if not ( + isinstance(declaration, ast.Assign) + and declaration.value is dictionary + and len(declaration.targets) == 1 + and isinstance(declaration.targets[0], ast.Name) + and not isinstance(python_enclosing_scope(declaration, parents), ast.ClassDef) + ): + return False + name = declaration.targets[0].id + if sum( + isinstance(candidate, ast.Name) + and isinstance(candidate.ctx, ast.Store) + and candidate.id == name + for candidate in ast.walk(tree) + ) != 1 or any( + isinstance(candidate, ast.arg) and candidate.arg == name + or isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and candidate.name == name + for candidate in ast.walk(tree) + ): + return False + module_keys = { + key for key, value in zip(keys, dictionary.values) + if isinstance(value, ast.Name) and value.id in names + } + for reference in ast.walk(tree): + if not isinstance(reference, ast.Name) or not isinstance(reference.ctx, ast.Load) or reference.id != name: + continue + parent = parents.get(reference) + if ( + isinstance(parent, ast.Subscript) and parent.value is reference + and isinstance(parent.slice, ast.Constant) + and isinstance(parent.slice.value, str) + and parent.slice.value not in module_keys + ): + continue + if reviewed_namespace_argument(reference, parent): + continue + if isinstance(parent, ast.Compare) and reference in parent.comparators and all( + isinstance(operator, (ast.In, ast.NotIn)) for operator in parent.ops + ): + continue + return False + return True + + safe_storage = { + id(value) + for dictionary in ast.walk(tree) + if isinstance(dictionary, ast.Dict) and reviewed_dictionary_storage(dictionary) + for value in dictionary.values + if isinstance(value, ast.Name) and value.id in names + } + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in names: + if node.attr.startswith("__"): + return f"Python security module reflection is not reviewed on line {node.lineno}" + if node.value.id in path_modules: + return f"Python OS path module alias is not reviewed on line {node.lineno}" + if python_dotted_name(node) == "os.path": + parent = parents.get(node) + if not isinstance(parent, ast.Attribute) or parent.value is not node: + return f"Python OS path module value escapes its reviewed attribute path on line {node.lineno}" for node in ast.walk(tree): if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load) or node.id not in names: continue parent = parents.get(node) + if id(node) in safe_storage: + continue if isinstance(parent, ast.Attribute) and parent.value is node: continue if isinstance(parent, ast.Assign) and parent.value is node and all( @@ -14606,6 +14715,8 @@ def python_resolved_local_path_expression( return False if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) + if dotted in {"os.path.abspath", "os.path.realpath"}: + return True is_path_home = python_path_method_expression_visible( node.func, "home", node, tree, parents ) @@ -14774,11 +14885,26 @@ def python_assigned_sink_method_alias(name, tree): ) +def python_warning_sink_targets(tree): + targets = getattr(tree, "_issue79_warning_sink_targets", None) + if targets is None: + targets = { + name + "." + method + for name in python_assigned_module_names(tree, "warnings") + for method in ("warn", "warn_explicit", "showwarning") + } + tree._issue79_warning_sink_targets = targets + return targets + + 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) + warning_targets = python_warning_sink_targets(tree) if tree is not None else set() + if dotted in warning_targets: + return True if dotted in { "print", "sys.exit", @@ -14801,6 +14927,7 @@ def python_sensitive_output_sink(node, tree=None): "traceback.print_exc", "traceback.print_exception", ) + named_targets = named_targets + tuple(warning_targets) if python_dotted_name(named_value) in named_targets: return True if ( @@ -14827,7 +14954,7 @@ def python_sensitive_output_sink(node, tree=None): "warnings.showwarning", "traceback.print_exc", "traceback.print_exception", - ) + ) + tuple(warning_targets) python_assigned_callable_alias("", targets[0], tree) assignment_index = getattr(tree, "_issue79_callable_alias_index", {}) sink_aliases = { @@ -16787,7 +16914,7 @@ def inspect_python_heredoc(body, safe_marker): for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } - module_escape_violation = python_module_value_escape_violation(tree, parents) + module_escape_violation = python_module_value_escape_violation(tree, parents, safe_marker) if module_escape_violation: return module_escape_violation sink_storage_violation = python_sensitive_sink_storage_violation(tree, parents) @@ -29978,10 +30105,10 @@ 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. | +| 1 | [4118778407](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118778407): 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 | [4118778413](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118778413): `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 | [4118778418](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118778418): 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 | [4118778422](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4118778422): 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: @@ -31433,5 +31560,49 @@ with 11 assertions before their corrections. Its GREEN rerun, including the two earlier direct tests and the lexical-shadowing control, passed five methods in 0.123s. Results for the canonical isolated full invocation and independent review are recorded in the PR against the combined candidate -when those gates settle. No live qualification result is claimed. Rollback is a reviewed reversal of this correction's +when those gates settle. No live qualification result is claimed. Rollback +is a reviewed reversal of this correction's packet/harness delta; the parent G01 and G02 live gates remain open. + +#### Independent HOLD at `98da2a8` and compatibility correction + +Two read-only GPT-6-Luna/max sessions reviewed immutable +`98da2a8567d0dedbc7289ed14582a89138268cb0`. Contract review confirmed the +four requested direct/alias negative groups and literal positive controls, +but reproduced both compatibility failures. Security review reproduced +additional `warnings.__dict__["showwarning"]` and `os.path.abspath` output +bypasses. Both verdicts were HOLD. Their final reports were read from the +Orca transcripts because the deliberately read-only sandbox blocked Orca +completion delivery with EPERM; these were not accepted `worker_done` +settlements. Both finished attempts were abandoned and only their locally +created reviewer terminals closed. Source and worktrees were preserved. + +The canonical command `python3 -I -B +scripts/evidence_packet/issue79_regression_test.py` ran 120 tests in +262.017s and failed two controls: 15 existing reviewed compile/exec helper +namespace bodies were rejected, and inert mixed-dictionary storage followed +by an unrelated literal-key read was rejected. This run preceded the final +prose-only ledger sentence edit; it is failure evidence for the unchanged +scanner/test source, not a passing validation of immutable `98da2a8`. + +`test_module_namespace_storage_requires_reviewed_uses` and +`test_module_reflection_and_os_path_outputs_are_rejected` subsequently +failed seven assertions in 0.111s before correction: one rejected safe +dictionary and six accepted reflection/path witnesses. Dictionary module +storage is now permitted only with unique literal keys and one declaration, +where every use is a static non-module-key access, key membership check, or +namespace argument of an already reviewed compile/exec call with unshadowed +builtins. Module-key recovery, mapping aliases, copy/iteration, dynamic keys, +class namespace storage and module insertion into an unrelated slot remain +unsupported. Protected-module reflection and first-class OS path-module +recovery are rejected, and direct `abspath`/`realpath` results are local-path +sources. The five focused methods, including the old mixed-subprocess +dictionary control, then passed in 0.154s. + +Two additional warning-module alias specimens failed RED in 0.121s under +`test_warning_and_absolute_path_aliases_preserve_sensitive_values`. +Warning sink classification now follows imported/assigned module names +alongside function aliases. That selector and the direct output selector +passed GREEN in 0.116s. All unsafe specimens remained inert AST inputs. +Combined isolated verification and independent review of this refinement +must still be recorded against its candidate before merge. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 7b4426c8..09fe861c 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3518,6 +3518,8 @@ def test_module_values_cannot_escape_through_containers_or_helpers(self) -> None def test_warning_and_absolute_path_aliases_preserve_sensitive_values(self) -> None: unsafe = ( 'import os, warnings\nemit = warnings.showwarning\nemit(os.environ, UserWarning, "x", 1)\n', + 'import os\nimport warnings as w\nw.showwarning(os.environ, UserWarning, "x", 1)\n', + 'import os, warnings\nalias = warnings\nemit = alias.showwarning\nemit(os.environ, UserWarning, "x", 1)\n', 'import os\nfrom warnings import showwarning as emit\nemit(os.environ, UserWarning, "x", 1)\n', 'from pathlib import Path\nabsolute = Path(".").absolute\nprint(absolute())\n', 'from pathlib import Path\nabsolute = getattr(Path("."), "absolute")\nprint(absolute())\n', @@ -3527,6 +3529,35 @@ def test_warning_and_absolute_path_aliases_preserve_sensitive_values(self) -> No with self.subTest(body=body): self.assertIsNotNone(self.inspect(body)) + def test_module_namespace_storage_requires_reviewed_uses(self) -> None: + self.assertIsNone(self.inspect( + 'import os\nnamespace = {"module": os, "safe": "reviewed"}\n' + 'value = namespace["safe"]\nnamespace["safe"] = value\n' + )) + for suffix in ( + 'print(namespace["module"].environ)\n', + 'key = "module"\nprint(namespace[key].environ)\n', + 'alias = namespace\nprint(alias["module"].environ)\n', + 'print(namespace.get("module").environ)\n', + 'print(list(namespace.values())[0].environ)\n', + 'namespace["safe"] = os\nprint(namespace["safe"].environ)\n', + ): + body = 'import os\nnamespace = {"module": os, "safe": "reviewed"}\n' + suffix + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + def test_module_reflection_and_os_path_outputs_are_rejected(self) -> None: + for body in ( + 'import os, warnings\nemit = warnings.__dict__["showwarning"]\nemit(os.environ, UserWarning, "x", 1)\n', + 'import os\nprint(os.path.abspath("."))\n', + 'import os\nprint(os.path.realpath("."))\n', + 'import os\np = os.path\nprint(p.abspath("."))\n', + 'from os import path as p\nprint(p.abspath("."))\n', + 'from os import path as p\nmodules = [p]\nprint(modules[0].abspath("."))\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + 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}\"" From 1b10507709bcc5eae34e606e036bd077441d4ecb Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 12:49:27 +0900 Subject: [PATCH 032/123] docs: give reviewed helper namespaces unique identities --- docs/evidence/g01-recovery-packet.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 74a55f99..38251415 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22374,7 +22374,7 @@ with tempfile.TemporaryDirectory() as directory: reviewed_tree = subprocess.check_output( ["git", "rev-parse", "HEAD:experiments/g01-scaleset"], cwd=root, env=git_env, text=True, ).strip() - namespace = { + post_test_namespace = { "os": os, "re": __import__("re"), "stat": __import__("stat"), @@ -22391,11 +22391,11 @@ with tempfile.TemporaryDirectory() as directory: functions["git_source_control_entries"], functions["git_worktree_matches_pinned_blobs"], functions["recheck_reviewed_source_checkout"], - ], type_ignores=[]), "", "exec"), namespace) - namespace["recheck_reviewed_source_checkout"]("before synthetic child") + ], type_ignores=[]), "", "exec"), post_test_namespace) + post_test_namespace["recheck_reviewed_source_checkout"]("before synthetic child") source_file.write_bytes(b"package p\n// mutation after child\n") try: - namespace["recheck_reviewed_source_checkout"]("after synthetic child") + post_test_namespace["recheck_reviewed_source_checkout"]("after synthetic child") except SystemExit as error: if "raw worktree bytes differ" not in str(error): raise SystemExit("post-test assertion: wrong mutation refusal") @@ -22503,7 +22503,7 @@ signals = [] saved_killpg = os.killpg os.killpg = lambda pid, signal_number: signals.append((pid, signal_number)) try: - namespace = { + parent_group_namespace = { "os": os, "signal": __import__("signal"), "subprocess": subprocess, @@ -22513,9 +22513,9 @@ try: exec(compile(ast.Module( body=[functions["close_go_child_streams"], functions["terminate_go_child_group"]], type_ignores=[], - ), "", "exec"), namespace) + ), "", "exec"), parent_group_namespace) process = DirectExitWithDescendant() - namespace["terminate_go_child_group"](process) + parent_group_namespace["terminate_go_child_group"](process) finally: os.killpg = saved_killpg if signals != [(51503, __import__("signal").SIGTERM)]: @@ -31606,3 +31606,17 @@ alongside function aliases. That selector and the direct output selector passed GREEN in 0.116s. All unsafe specimens remained inert AST inputs. Combined isolated verification and independent review of this refinement must still be recorded against its candidate before merge. + +The canonical isolated full invocation on +`55c5f6f5be97508abdb2264fa7afc5dd7ee20393` ran 122 tests in 271.948s and +failed only `test_current_packet_has_no_static_scanner_violations`; the +other 121 methods passed. The remaining two diagnostics were historical +compile/exec helpers that reused the same namespace variable in one Python +body. Their dictionaries now have distinct names (`post_test_namespace` +and `parent_group_namespace`), preserving the one-declaration proof rather +than weakening it. No scanner or harness source changed in that correction. +The canonical isolated packet selector then passed one test in 119.911s: +331 shell commands, 95 Python heredoc bodies, zero violations. This is +incremental correction evidence, not a claim that the earlier full invocation +passed. The final combined candidate still requires independent delta +sign-off, hosted PR quick checks and completed exact-head GitHub Codex review. From ddfdcfd2fa71e30ced62cef93244649bf84a8e7a Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 12:59:02 +0900 Subject: [PATCH 033/123] fix: reject path-callable escapes and namespace builtin shadows --- docs/evidence/g01-recovery-packet.md | 73 ++++++++++++++++--- .../issue79_regression_test.py | 33 +++++++++ 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 38251415..46ebaadd 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13193,6 +13193,7 @@ def python_module_value_escape_violation(tree, parents, safe_marker): if alias.name == "path" } names.update(path_modules) + os_names = python_assigned_module_names(tree, "os") changed = True while changed: changed = False @@ -13209,10 +13210,7 @@ def python_module_value_escape_violation(tree, parents, safe_marker): names.add(target.id) changed = True - rebound_names = { - node.id for node in ast.walk(tree) - if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) - } | { + non_store_bound_names = { node.arg for node in ast.walk(tree) if isinstance(node, ast.arg) } | { node.name for node in ast.walk(tree) @@ -13222,6 +13220,17 @@ def python_module_value_escape_violation(tree, parents, safe_marker): for node in ast.walk(tree) if isinstance(node, (ast.Import, ast.ImportFrom)) for alias in node.names + } | { + node.name for node in ast.walk(tree) + if isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) + and node.name is not None + } | { + node.rest for node in ast.walk(tree) + if isinstance(node, ast.MatchMapping) and node.rest is not None + } + rebound_names = non_store_bound_names | { + node.id for node in ast.walk(tree) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) } def reviewed_namespace_argument(node, parent): @@ -13260,11 +13269,7 @@ def python_module_value_escape_violation(tree, parents, safe_marker): and isinstance(candidate.ctx, ast.Store) and candidate.id == name for candidate in ast.walk(tree) - ) != 1 or any( - isinstance(candidate, ast.arg) and candidate.arg == name - or isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and candidate.name == name - for candidate in ast.walk(tree) - ): + ) != 1 or name in non_store_bound_names: return False module_keys = { key for key, value in zip(keys, dictionary.values) @@ -13298,11 +13303,20 @@ def python_module_value_escape_violation(tree, parents, safe_marker): if isinstance(value, ast.Name) and value.id in names } for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and python_dotted_name(node) in {"os.path.abspath", "os.path.realpath"} + ): + parent = parents.get(node) + if not isinstance(parent, ast.Call) or parent.func is not node: + return f"Python OS path callable escapes its reviewed direct call on line {node.lineno}" if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in names: if node.attr.startswith("__"): return f"Python security module reflection is not reviewed on line {node.lineno}" if node.value.id in path_modules: return f"Python OS path module alias is not reviewed on line {node.lineno}" + if node.attr == "path" and node.value.id in os_names and node.value.id != "os": + return f"Python OS path module through an OS alias is not reviewed on line {node.lineno}" if python_dotted_name(node) == "os.path": parent = parents.get(node) if not isinstance(parent, ast.Attribute) or parent.value is not node: @@ -31620,3 +31634,44 @@ The canonical isolated packet selector then passed one test in 119.911s: incremental correction evidence, not a claim that the earlier full invocation passed. The final combined candidate still requires independent delta sign-off, hosted PR quick checks and completed exact-head GitHub Codex review. + +#### Completed contract review of `55c5f6f` and path-callable correction + +The GPT-6-Luna/max contract reviewer completed the immutable `55c5f6f` +delta review and successfully delivered its result through Orca. Its five +focused methods passed in 0.150s, including the mixed-subprocess dictionary +positive. The code verdict remained HOLD: assigned `os.path.abspath` and +`os.path.realpath` callables bypassed local-path output classification. +The review attempt was settled and retained without process action; a new +attempt is required for final candidate sign-off. + +`test_os_path_callable_recovery_cannot_hide_local_output` failed three +assertions in 0.110s for the assigned callables and a list-held callable. +After a direct-call-only guard closed those forms, neutral OS alias names +reproduced two additional accepted forms in 0.115s. First-class +`os.path.abspath`/`os.path.realpath` recovery and OS path-module access +through assigned/imported OS aliases are now rejected as unsupported +indirection. Canonical direct calls still allow normalization comparisons +without output. The new selector and the four neighboring module/alias +selectors passed in 0.121s after both corrections. All specimens remained +inert AST inputs; no inherited value or local path was printed. + +The packet-only `1b10507709bcc5eae34e606e036bd077441d4ecb` correction +also received a post-ledger canonical packet scan: one test passed in +118.865s, 331 shell commands and 95 Python heredoc bodies with zero +violations. This evidence does not certify the subsequent source change. +Security review and final combined candidate verification/reviews remain +pending. Neither the parent live gates nor the merge gates are waived. + +The security review's inert namespace witness used a `match` capture named +`exec` to call a local helper with a module-bearing dictionary, bypassing +the proof's builtin-shadow check. The coordinator's +`test_pattern_bindings_cannot_shadow_reviewed_namespace_builtins` was RED: +three assertions failed in 0.118s. Binding collection now includes exception +handler names, match captures/star captures and mapping-rest names, alongside +ordinary stores, imports, parameters and definitions. These names also +invalidate the helper dictionary's one-declaration proof. The new pattern +and path selectors, the namespace/reflection selectors and the pre-existing +mixed-subprocess dictionary selector passed together in 0.150s. No specimen +was executed. The completed immutable security report, final delta verdicts +and candidate checks must be recorded in the PR before merge. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 09fe861c..21cbc35a 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3558,6 +3558,39 @@ def test_module_reflection_and_os_path_outputs_are_rejected(self) -> None: with self.subTest(body=body): self.assertIsNotNone(self.inspect(body)) + def test_os_path_callable_recovery_cannot_hide_local_output(self) -> None: + for body in ( + 'import os\nabsolute = os.path.abspath\nprint(absolute("."))\n', + 'import os\nrealpath = os.path.realpath\nprint(realpath("."))\n', + 'import os as platform\nprint(platform.path.abspath("."))\n', + 'import os\nplatform = os\nprint(platform.path.realpath("."))\n', + 'import os\nplatform = os\nabsolute = platform.path.abspath\nprint(absolute("."))\n', + 'import os\nfunctions = [os.path.abspath]\nprint(functions[0]("."))\n', + 'import os\nabsolute = getattr(os.path, "abspath")\nprint(absolute("."))\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import os\nnormalized = os.path.abspath(".")\n' + 'assert normalized == os.path.realpath(".")\nprint("reviewed")\n' + )) + + def test_pattern_bindings_cannot_shadow_reviewed_namespace_builtins(self) -> None: + prefix = ( + 'import ast, os\nnamespace = {"module": os}\n' + 'def fake(code, globals_dict):\n' + ' print(globals_dict["module"].environ)\n' + ) + call = ( + 'exec(compile(ast.Module(body=[functions["safe"]], type_ignores=[]), ' + '"", "exec"), namespace)\n' + ) + for pattern in ('case exec:', 'case [*exec]:', 'case {**exec}:'): + with self.subTest(pattern=pattern): + self.assertIsNotNone(self.inspect( + prefix + 'match fake:\n ' + pattern + '\n ' + call + )) + 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}\"" From 55412a05eba67366c074638509385427d2662dac Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 13:10:59 +0900 Subject: [PATCH 034/123] fix: close indirect OS path members and environment expansion --- docs/evidence/g01-recovery-packet.md | 44 ++++++++++++++++--- .../issue79_regression_test.py | 16 +++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 46ebaadd..5523bd0a 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13209,6 +13209,12 @@ def python_module_value_escape_violation(tree, parents, safe_marker): if isinstance(target, ast.Name) and target.id not in names: names.add(target.id) changed = True + if ( + isinstance(target, ast.Name) and value.id in path_modules + and target.id not in path_modules + ): + path_modules.add(target.id) + changed = True non_store_bound_names = { node.arg for node in ast.walk(tree) if isinstance(node, ast.arg) @@ -13303,13 +13309,6 @@ def python_module_value_escape_violation(tree, parents, safe_marker): if isinstance(value, ast.Name) and value.id in names } for node in ast.walk(tree): - if ( - isinstance(node, ast.Attribute) - and python_dotted_name(node) in {"os.path.abspath", "os.path.realpath"} - ): - parent = parents.get(node) - if not isinstance(parent, ast.Call) or parent.func is not node: - return f"Python OS path callable escapes its reviewed direct call on line {node.lineno}" if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in names: if node.attr.startswith("__"): return f"Python security module reflection is not reviewed on line {node.lineno}" @@ -13321,6 +13320,11 @@ def python_module_value_escape_violation(tree, parents, safe_marker): parent = parents.get(node) if not isinstance(parent, ast.Attribute) or parent.value is not node: return f"Python OS path module value escapes its reviewed attribute path on line {node.lineno}" + if parent.attr.startswith("__") or parent.attr == "expandvars": + return f"Python OS path reflection/environment expansion is not reviewed on line {node.lineno}" + call = parents.get(parent) + if not isinstance(call, ast.Call) or call.func is not parent: + return f"Python OS path member escapes its reviewed direct call on line {node.lineno}" for node in ast.walk(tree): if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load) or node.id not in names: continue @@ -31675,3 +31679,29 @@ and path selectors, the namespace/reflection selectors and the pre-existing mixed-subprocess dictionary selector passed together in 0.150s. No specimen was executed. The completed immutable security report, final delta verdicts and candidate checks must be recorded in the PR before merge. + +#### Path-member delta HOLD at `ddfdcfd` + +The completed GPT-6-Luna/max contract delta review of immutable +`ddfdcfd2fa71e30ced62cef93244649bf84a8e7a` passed four exact-blob focused +methods in 0.148s, but retained HOLD. It reproduced two assigned aliases of +a `from os import path` module reaching `abspath`/`realpath` output. The +coordinator also reproduced nested path-module dictionary recovery and +environment expansion as inert inputs. No candidate was pushed or merged. + +`test_os_path_member_recovery_and_expansion_are_not_certified` was RED with +six failed assertions in 0.112s: the two module aliases, dictionary callable +recovery, environment expansion, an `expanduser` callable alias and an OS +module re-export. Path-module provenance now follows direct name aliases. +Canonical `os.path` members must stay on direct calls; first-class member +capture, reflection and `expandvars` are unsupported. This closes the +member-recovery family rather than admitting new indirect callables. +Canonical normalization/comparison positive controls remain accepted. Eight +neighboring isolated methods passed in 0.156s after the correction. All +specimens remained AST input, not executable tests against inherited data. + +The previous `ddfdcfd` exact-head packet selector passed in 122.930s +(331 shell commands, 95 Python bodies, zero violations); it does not certify +this subsequent delta. Final combined candidate packet verification, +independent contract/security delta verdicts and fresh exact-head hosted/ +GitHub Codex review are still required. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 21cbc35a..19fd059a 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3591,6 +3591,22 @@ def test_pattern_bindings_cannot_shadow_reviewed_namespace_builtins(self) -> Non prefix + 'match fake:\n ' + pattern + '\n ' + call )) + def test_os_path_member_recovery_and_expansion_are_not_certified(self) -> None: + for body in ( + 'from os import path as p\nalias = p\nprint(alias.abspath("."))\n', + 'from os import path as p\nalias = p\nprint(alias.realpath("."))\n', + 'import os\nresolve = os.path.__dict__["abspath"]\nprint(resolve("."))\n', + 'import os\nprint(os.path.expandvars("$GH_TOKEN"))\n', + 'import os\nexpand = os.path.expanduser\nprint(expand("~"))\n', + 'import os\nprint(os.path.os.environ)\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import os\nvalue = os.path.normpath("docs/example")\n' + 'assert value == "docs/example"\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}\"" From a628322c48e8c2895bdeb3c5e9161618189f782a Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 13:15:21 +0900 Subject: [PATCH 035/123] fix: disambiguate path member AST node from callback names --- docs/evidence/g01-recovery-packet.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 5523bd0a..d64cfcde 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13322,8 +13322,8 @@ def python_module_value_escape_violation(tree, parents, safe_marker): return f"Python OS path module value escapes its reviewed attribute path on line {node.lineno}" if parent.attr.startswith("__") or parent.attr == "expandvars": return f"Python OS path reflection/environment expansion is not reviewed on line {node.lineno}" - call = parents.get(parent) - if not isinstance(call, ast.Call) or call.func is not parent: + path_member_call_node = parents.get(parent) + if not isinstance(path_member_call_node, ast.Call) or path_member_call_node.func is not parent: return f"Python OS path member escapes its reviewed direct call on line {node.lineno}" for node in ast.walk(tree): if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load) or node.id not in names: @@ -31705,3 +31705,17 @@ The previous `ddfdcfd` exact-head packet selector passed in 122.930s this subsequent delta. Final combined candidate packet verification, independent contract/security delta verdicts and fresh exact-head hosted/ GitHub Codex review are still required. + +The completed GPT-6-Luna/max security delta report also retained HOLD on +`ddfdcfd` for the same assigned path-module aliases, saved dictionary callable +and environment expansion witnesses. Those are included in the six-witness +correction above. Its complete prior namespace-pattern witness was refused; +the broad coordinator checks were not duplicated by the reviewer. + +On `55412a05eba67366c074638509385427d2662dac`, the eight focused methods +passed in 0.157s. The exact-head packet selector failed one assertion in +107.128s: the scanner's new local AST variable named `call` collided with +existing callback-name analysis of the scanner body itself. It is renamed +`path_member_call_node` without changing the guard's semantics. This failed +packet scan is not reported as GREEN; the subsequent exact-candidate scan +and final delta verdicts are recorded in the PR when complete. From de28dea58d0086783f340d50dfe8c79228eb04cb Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 13:25:12 +0900 Subject: [PATCH 036/123] test: record open AST compile provenance blocker --- docs/evidence/g01-recovery-packet.md | 26 +++++++++++++++++++ .../issue79_regression_test.py | 14 ++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index d64cfcde..d74a0509 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -31719,3 +31719,29 @@ existing callback-name analysis of the scanner body itself. It is renamed `path_member_call_node` without changing the guard's semantics. This failed packet scan is not reported as GREEN; the subsequent exact-candidate scan and final delta verdicts are recorded in the PR when complete. + +#### Open P1: AST compile-source proof (not corrected) + +The path-member contract reviewer approved immutable `55412a0` after four +exact-blob focused methods passed in 0.142s. That sign-off does not cover the +subsequent AST-variable rename. The renamed `a628322c48e8c2895bdeb3c5e9161618189f782a` +packet selector then passed in 110.796s (331 shell commands, 95 Python bodies, +zero violations). These are scoped passing results, not approval of the +entire combined candidate. + +Coordinator self-review reproduced a separate P1 compile-source gap: +an arbitrary parsed AST stored in a dictionary named `functions` or a +variable named `module` was accepted at a compiled execution call with an +OS-module namespace. `test_unreviewed_ast_code_cannot_use_reserved_compile_names` +failed two assertions in 0.115s under the canonical isolated harness command. +The specimens are inert AST input; neither the compiled specimen nor its +environment output was executed. This RED remains intentionally unresolved +in the local work-in-progress branch, not in a pushed merge candidate. + +The next correction must tie AST nodes, containers and selected helper +members to reviewed source provenance rather than their reserved names or +node kinds, while preserving the actual packet-derived helper controls. +Compiler source-proof changes require their focused RED/GREEN evidence and +an independent security delta review. No GREEN, final combined approval, +fresh GitHub Codex review or merge is claimed for this open finding. The +parent live gates and issue #79 remain incomplete. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 19fd059a..e28c94bd 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3607,6 +3607,20 @@ def test_os_path_member_recovery_and_expansion_are_not_certified(self) -> None: 'assert value == "docs/example"\nprint("reviewed")\n' )) + def test_unreviewed_ast_code_cannot_use_reserved_compile_names(self) -> None: + for body in ( + 'import ast, os\n' + 'functions = {"safe": ast.parse("print(os.environ)").body[0]}\n' + 'namespace = {"os": os}\n' + 'exec(compile(ast.Module(body=[functions["safe"]], type_ignores=[]), ' + '"", "exec"), namespace)\n', + 'import ast, os\nmodule = ast.parse("print(os.environ)")\n' + 'namespace = {"os": os}\n' + 'exec(compile(module, "", "exec"), namespace)\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + 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}\"" From e378ba418fe70fba01b6b8b68bf7405fba610aad Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 14:12:31 +0900 Subject: [PATCH 037/123] fix: require packet-derived AST compile provenance --- docs/evidence/g01-recovery-packet.md | 393 ++++++++++++++++-- .../issue79_regression_test.py | 143 +++++++ 2 files changed, 494 insertions(+), 42 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index d74a0509..a793d4b0 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10847,8 +10847,51 @@ reviewed_python_compile_ast_names = { reviewed_python_compile_slice_bases = reviewed_python_compile_source_names +def python_compile_primitive_is_shadowed(name, tree, provider_call=None): + """A matching call spelling does not establish a builtin/stdlib binding.""" + module_aliases = python_assigned_module_names(tree, name) if name in {"ast", "subprocess", "pathlib"} else {name} + for candidate in ast.walk(tree): + if ( + isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store) and candidate.id == name + or isinstance(candidate, ast.arg) and candidate.arg == name + or isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) and candidate.name == name + or isinstance(candidate, ast.MatchMapping) and candidate.rest == name + ): + return True + if isinstance(candidate, (ast.Import, ast.ImportFrom)): + for imported in candidate.names: + binding = imported.asname or imported.name.split(".")[0] + if binding != name: + continue + genuine = ( + isinstance(candidate, ast.Import) and imported.name == name + and name in {"ast", "subprocess", "pathlib"} + ) or ( + isinstance(candidate, ast.ImportFrom) and candidate.module == "pathlib" + and imported.name == name == "Path" + ) + if not genuine: + return True + if isinstance(candidate, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + if provider_call is not None and candidate.lineno > provider_call.lineno: + continue + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + for target in targets: + if name == "subprocess" and isinstance(target, ast.Attribute) and target.attr != "check_output": + continue + if isinstance(target, (ast.Attribute, ast.Subscript)) and any( + isinstance(part, ast.Name) and part.id in module_aliases + for part in ast.walk(target.value) + ): + return True + return False + + def python_compile_provenance(tree): """Resolve source/AST provenance before permitting static compile/exec.""" + cached = getattr(tree, "_issue79_compile_provenance", None) + if cached is not None: + return cached source_names = set() ast_names = set() packet_path_names = set() @@ -10867,7 +10910,7 @@ def python_compile_provenance(tree): return any( isinstance(candidate, ast.Constant) and isinstance(candidate.value, str) - and "docs/evidence/g01-recovery-packet.md" in candidate.value + and candidate.value == "docs/evidence/g01-recovery-packet.md" for candidate in ast.walk(node) ) or any( isinstance(candidate, ast.Name) and candidate.id in packet_path_names @@ -10878,7 +10921,7 @@ def python_compile_provenance(tree): literal_path = ( isinstance(value, ast.Constant) and isinstance(value.value, str) - and "docs/evidence/g01-recovery-packet.md" in value.value + and value.value == "docs/evidence/g01-recovery-packet.md" ) path_constructor = ( isinstance(value, ast.Call) @@ -10895,21 +10938,6 @@ def python_compile_provenance(tree): 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 @@ -10923,9 +10951,17 @@ def python_compile_provenance(tree): isinstance(node.func, ast.Attribute) and node.func.attr == "replace" ): - return source_value(node.func.value) + return ( + source_value(node.func.value) and not node.keywords and len(node.args) == 3 + and all(isinstance(argument, ast.Constant) for argument in node.args) + and node.args[0].value == 'source = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")' + and node.args[1].value in {'source = ""', 'source = packet'} + and type(node.args[2].value) is int and node.args[2].value == 1 + ) if isinstance(node.func, ast.Attribute) and node.func.attr == "read_text": receiver = node.func.value + if python_compile_primitive_is_shadowed("Path", tree) or python_compile_primitive_is_shadowed("pathlib", tree): + return False return ( isinstance(receiver, ast.Call) and python_dotted_name(receiver.func) in {"Path", "pathlib.Path"} @@ -10936,16 +10972,99 @@ def python_compile_provenance(tree): and receiver.id in packet_path_names ) if dotted == "subprocess.check_output": - return contains_packet_path(node) + if python_compile_primitive_is_shadowed("subprocess", tree, node) or not node.args: + return False + command = node.args[0] + if not isinstance(command, (ast.List, ast.Tuple)) or len(command.elts) not in {3, 4}: + return False + prefix = [part.value if isinstance(part, ast.Constant) else None for part in command.elts[:-1]] + if prefix not in (["git", "show"], ["git", "-P", "show"]): + return False + def literal_reference(value, seen=None): + seen = seen or set() + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return value.value + if isinstance(value, ast.Name) and value.id not in seen: + bindings = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store) + and candidate.id == value.id + ] + definitions = [ + candidate.value for candidate in ast.walk(tree) + if isinstance(candidate, ast.Assign) and len(candidate.targets) == 1 + and isinstance(candidate.targets[0], ast.Name) and candidate.targets[0].id == value.id + ] + if len(bindings) == len(definitions) == 1: + return literal_reference(definitions[0], seen | {value.id}) + if isinstance(value, ast.JoinedStr): + parts = [] + for part in value.values: + if isinstance(part, ast.FormattedValue): + if part.conversion != -1 or part.format_spec is not None: + return None + part = part.value + resolved = literal_reference(part, seen.copy()) + if resolved is None: + return None + parts.append(resolved) + return "".join(parts) + return None + reference = literal_reference(command.elts[-1]) + references = {reference} if reference is not None else set() + return bool(references) and all( + re.fullmatch(r"[0-9a-f]{40}:docs/evidence/g01-recovery-packet\.md", reference) + for reference in references + ) return False - def ast_value(node): + ast_is_shadowed = python_compile_primitive_is_shadowed("ast", tree) + + def ast_value(node, local_names=None): + local_names = local_names or set() if isinstance(node, ast.Name): - return node.id in ast_names + return node.id in ast_names or node.id in local_names + if isinstance(node, ast.Attribute): + return node.attr == "body" and ast_value(node.value, local_names) + if isinstance(node, ast.Subscript): + return ast_value(node.value, local_names) if isinstance(node, ast.Call): - return python_dotted_name(node.func) in {"ast.parse", "ast.Module"} + dotted = python_dotted_name(node.func) + if ast_is_shadowed: + return False + if dotted == "ast.parse": + return bool(node.args) and source_value(node.args[0]) + if dotted == "ast.walk": + return len(node.args) == 1 and ast_value(node.args[0], local_names) + if dotted == "ast.Module" and not node.args: + keywords = {keyword.arg: keyword.value for keyword in node.keywords} + return ( + set(keywords) == {"body", "type_ignores"} + and isinstance(keywords["type_ignores"], ast.List) + and not keywords["type_ignores"].elts + and ast_value(keywords["body"], local_names) + ) + return False if isinstance(node, (ast.List, ast.Tuple)): - return bool(node.elts) and all(ast_value(element) for element in node.elts) + return all(ast_value(element, local_names) for element in node.elts) + if isinstance(node, ast.Dict): + return all(key is not None for key in node.keys) and all( + ast_value(element, local_names) for element in node.values + ) + if isinstance(node, (ast.ListComp, ast.DictComp)): + bound_names = set(local_names) + for generator in node.generators: + ast_iterable = ast_value(generator.iter, bound_names) + literal_keys = isinstance(generator.iter, (ast.List, ast.Tuple, ast.Set)) and all( + isinstance(element, ast.Constant) and isinstance(element.value, str) + for element in generator.iter.elts + ) + if generator.is_async or not (ast_iterable or literal_keys): + return False + if ast_iterable: + bound_names.update(target_names(generator.target)) + element = node.value if isinstance(node, ast.DictComp) else node.elt + return ast_value(element, bound_names) return False assignments = [] @@ -10963,6 +11082,38 @@ def python_compile_provenance(tree): if name in packet_path_names and not packet_path_value(value) } packet_path_names.difference_update(tainted_packet_path_names) + parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + source_parameter_rules = [] + for function in ast.walk(tree): + if not isinstance(function, ast.FunctionDef): + continue + uses = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Load) + and candidate.id == function.name + ] + calls = [parents.get(use) for use in uses] + direct_only = bool(calls) and not function.decorator_list and all( + isinstance(call, ast.Call) and call.func is use + for use, call in zip(uses, calls) + ) and sum( + isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and candidate.name == function.name for candidate in ast.walk(tree) + ) == 1 and not any( + isinstance(candidate, (ast.Name, ast.arg)) + and (candidate.id if isinstance(candidate, ast.Name) else candidate.arg) == function.name + and (not isinstance(candidate, ast.Name) or isinstance(candidate.ctx, ast.Store)) + for candidate in ast.walk(tree) + ) + for index, parameter in enumerate(list(function.args.posonlyargs) + list(function.args.args)): + source_parameter_rules.append((parameter.arg, index, calls if direct_only else [])) + + def source_parameter_is_proven(index, calls): + return bool(calls) and all( + not call.keywords and index < len(call.args) and source_value(call.args[index]) + for call in calls + ) + for _ in range(len(assignments) + 1): changed = False for target, value in assignments: @@ -10972,28 +11123,134 @@ def python_compile_provenance(tree): if name not in source_names: source_names.add(name) changed = True + for parameter, index, calls in source_parameter_rules: + if parameter not in source_names and source_parameter_is_proven(index, calls): + source_names.add(parameter) + changed = True + if not changed: + break + while True: + 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_source_names.update( + parameter for parameter, index, calls in source_parameter_rules + if parameter in source_names and not source_parameter_is_proven(index, calls) + ) + if not invalidated_source_names: + break + source_names.difference_update(invalidated_source_names) + + ast_bindings = list(assignments) + ast_bindings.extend( + (candidate.target, candidate.iter) + for candidate in ast.walk(tree) + if isinstance(candidate, ast.For) + ) + for _ in range(len(ast_bindings) + 1): + changed = False + for target, value in ast_bindings: if ast_value(value): - for name in names: + for name in target_names(target): 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 + ast_aliases = {name: set() for name in ast_names} + for target, value in ast_bindings: + comprehension_bound_names = { + bound for part in ast.walk(value) if isinstance(part, ast.comprehension) + for bound in target_names(part.target) + } + dependencies = { + part.id for part in ast.walk(value) + if isinstance(part, ast.Name) and isinstance(part.ctx, ast.Load) and part.id in ast_names + and part.id not in comprehension_bound_names + } + for alias in target_names(target): + if alias not in ast_aliases: + continue + for dependency in dependencies: + ast_aliases[alias].add(dependency) + ast_aliases[dependency].add(alias) + + def ast_receiver_names(value): + if isinstance(value, ast.Name): + return {value.id}.intersection(ast_aliases) + if isinstance(value, (ast.Attribute, ast.Subscript)): + return ast_receiver_names(value.value) + return set() + + while True: + invalidated_ast_names = { + name for target, value in ast_bindings for name in target_names(target) + if name in ast_names and not ast_value(value) + } + mutated_ast_names = set() + for target, value in assignments: + if isinstance(target, (ast.Subscript, ast.Attribute)): + if not isinstance(target, ast.Subscript) or not ast_value(value): + mutated_ast_names.update(ast_receiver_names(target.value)) + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Call) and python_dotted_name(candidate.func) not in { + "ast.parse", "ast.walk", "ast.Module", "ast.dump", "ast.literal_eval", "ast.unparse", + "isinstance", "len", "compile", "exec", + } and not ( + isinstance(candidate.func, ast.Attribute) + and candidate.func.attr in {"append", "extend", "insert", "update", "setdefault", "__setitem__", "__setattr__"} + and ast_receiver_names(candidate.func.value) + and not candidate.keywords + and all(ast_value(argument) for argument in candidate.args) + ) and not ( + isinstance(candidate.func, ast.Attribute) and candidate.func.attr == "issubset" + and isinstance(candidate.func.value, ast.Name) + and sum( + isinstance(part, ast.Name) and isinstance(part.ctx, ast.Store) + and part.id == candidate.func.value.id for part in ast.walk(tree) + ) == 1 + and any( + isinstance(target, ast.Name) and target.id == candidate.func.value.id + and isinstance(value, ast.Set) + and all(isinstance(element, ast.Constant) and isinstance(element.value, str) for element in value.elts) + for target, value in assignments + ) + ): + for argument in candidate.args: + mutated_ast_names.update(ast_receiver_names(argument)) + for keyword in candidate.keywords: + mutated_ast_names.update(ast_receiver_names(keyword.value)) + if isinstance(candidate, ast.Attribute) and candidate.attr in {"append", "extend", "insert", "update", "setdefault", "__setitem__", "__setattr__"}: + parent = parents.get(candidate) + if not isinstance(parent, ast.Call) or parent.func is not candidate: + mutated_ast_names.update(ast_receiver_names(candidate.value)) + if ( + isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Name) + and candidate.func.id in {"setattr", "delattr"} and candidate.args + ): + mutated_ast_names.update(ast_receiver_names(candidate.args[0])) + if not ( + isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Attribute) + and candidate.func.attr in {"append", "extend", "insert", "update", "setdefault", "__setitem__", "__setattr__"} + ): + continue + values = candidate.args[1:] if candidate.func.attr in {"insert", "setdefault", "__setitem__"} else candidate.args + if not values or candidate.keywords or not all(ast_value(value) for value in values): + mutated_ast_names.update(ast_receiver_names(candidate.func.value)) + pending = list(mutated_ast_names) + while pending: + current = pending.pop() + for alias in ast_aliases.get(current, ()): + if alias not in mutated_ast_names: + mutated_ast_names.add(alias) + pending.append(alias) + invalidated_ast_names.update(mutated_ast_names.intersection(ast_names)) + if not invalidated_ast_names: + break + ast_names.difference_update(invalidated_ast_names) + tree._issue79_compile_provenance = (source_names, ast_names) + return tree._issue79_compile_provenance def reviewed_python_compile_source(node, provenance=None): @@ -11037,17 +11294,18 @@ def reviewed_python_compile_source(node, provenance=None): return False body = keywords["body"] if isinstance(body, ast.Name): - return body.id in reviewed_python_compile_ast_names + return body.id in reviewed_python_compile_ast_names and body.id in ast_provenance 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: + if element.id not in reviewed_python_compile_ast_names or element.id not in ast_provenance: return False elif not ( isinstance(element, ast.Subscript) and isinstance(element.value, ast.Name) and element.value.id == "functions" + and element.value.id in ast_provenance and isinstance(element.slice, ast.Constant) and isinstance(element.slice.value, str) ): @@ -11059,6 +11317,10 @@ 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 tree is not None and any( + python_compile_primitive_is_shadowed(name, tree) for name in ("exec", "compile") + ): + return False if not call.args or not isinstance(call.args[0], ast.Call): return False compiler = call.args[0] @@ -13177,7 +13439,7 @@ def python_unknown_os_call_violation(tree): def python_module_value_escape_violation(tree, parents, safe_marker): """Keep security-sensitive modules on direct, inspectable attribute paths.""" - protected = {"os", "subprocess", "shutil", "signal", "sys", "pathlib", "warnings", "builtins", "importlib"} + protected = {"os", "subprocess", "shutil", "signal", "sys", "pathlib", "warnings", "builtins", "importlib", "ast"} names = { alias.asname or alias.name.split(".")[0] for node in ast.walk(tree) @@ -31745,3 +32007,50 @@ Compiler source-proof changes require their focused RED/GREEN evidence and an independent security delta review. No GREEN, final combined approval, fresh GitHub Codex review or merge is claimed for this open finding. The parent live gates and issue #79 remain incomplete. + +#### AST compile-source correction candidate (offline; review pending) + +The preceding open-P1 entry records the immutable RED at `de28dea`; it is +not a claim about the correction below. A GPT-6-Luna/max read-only design +audit of that commit retained HOLD and confirmed dictionary/list selection, +forged providers, primitive rebinding and marker-only parameter gaps. Its +first two-assertion reproduction used the non-isolated module invocation +(0.113s); subsequent pinned-blob probes used isolated in-memory loading. +That audit is design evidence, not approval of this implementation. + +The correction follows packet-derived text into genuine `ast.parse`, AST +selection comprehensions and bounded lists, rather than granting authority +to reserved names. Source providers require a literal packet path or a +literal 40-hex commit plus that exact path through `git show` (optionally +`-P`). Source/AST rebinding and mutable-object aliases invalidate proof. +Nested AST changes, saved mutators and opaque AST consumers are refused; +ordinary packet AST selection and read-only inspection remain supported. +Function parameters require every use to be a direct call with proven +source arguments. Marker text, an unrelated global binding and an escaped +function value do not establish that proof. Source replacement is limited +to the existing scanner self-read substitution, not arbitrary code editing. +AST-module values also stay within reviewed direct uses or bounded helper +namespaces. This is a deliberately conservative static subset, not a +hostile-code execution sandbox or a complete Python effect system. + +Canonical isolated RED evidence before each relevant correction: +the original two AST-name cases failed again in 0.116s; three direct +source/container mutations failed in 0.143s; five alias/nested-mutation +and two forged-provider assertions failed in 0.163s; two primitive/parameter +assertions failed in 0.115s; three parameter/opaque-call assertions failed +in 0.138s; arbitrary source replacement failed in 0.119s. These specimens +were inspected as AST input only, never compiled or run against inherited +environment data. The helper-preservation probe checks all 128 actual +packet `exec(compile(...))` calls without executing those packet bodies. + +Intermediate provider/scope corrections rejected 56, then 11 actual helper +calls; the parameter/opaque-call correction initially rejected nine controls, +then six. These were failed runs, not GREEN. After resolving those overly +broad checks, the focused 16-method boundary group passed in 1.232s, +including all 128 real compile helpers and neighboring namespace/path guards, +using `python3 -I -B scripts/evidence_packet/issue79_regression_test.py` +with explicit method selectors. This is not a full harness pass. The +combined packet scan and independent exact-commit contract/security delta +reviews remain pending, as do the fresh pushed-head hosted PR quick check +and GitHub Codex review. No push, merge, live dispatch or runner operation +is claimed by this entry; G01 and #79 remain incomplete. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index e28c94bd..8656b72c 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3621,6 +3621,149 @@ def test_unreviewed_ast_code_cannot_use_reserved_compile_names(self) -> None: with self.subTest(body=body): self.assertIsNotNone(self.inspect(body)) + def packet_ast_source_prefix(self) -> str: + return ( + 'import ast, os\nfrom pathlib import Path\n' + 'packet = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'wrapper_start = packet.index("\\nimport hashlib\\n") + 1\n' + 'wrapper_end = packet.index("\\nPY\\n}", wrapper_start)\n' + 'wrapper = packet[wrapper_start:wrapper_end]\n' + 'module = ast.parse(wrapper)\n' + ) + + def test_packet_derived_ast_helper_selection_remains_supported(self) -> None: + suffixes = ( + 'functions = {node.name: node for node in module.body ' + 'if isinstance(node, ast.FunctionDef)}\n' + 'namespace = {"os": os}\n' + 'exec(compile(ast.Module(body=[functions["run_go_child"]], type_ignores=[]), ' + '"", "exec"), namespace)\n', + 'selected = []\nfor node in module.body:\n' + ' if isinstance(node, ast.FunctionDef) and node.name == "run_go_child":\n' + ' selected.append(node)\n' + 'namespace = {"os": os}\n' + 'exec(compile(ast.Module(body=selected, type_ignores=[]), ' + '"", "exec"), namespace)\n', + ) + for suffix in suffixes: + with self.subTest(suffix=suffix): + self.assertIsNone(self.inspect(self.packet_ast_source_prefix() + suffix)) + + def test_ast_compile_provenance_is_invalidated_by_unreviewed_mutation(self) -> None: + suffixes = ( + 'functions = {node.name: node for node in module.body ' + 'if isinstance(node, ast.FunctionDef)}\n' + 'functions["run_go_child"] = ast.parse("print(os.environ)").body[0]\n' + 'namespace = {"os": os}\n' + 'exec(compile(ast.Module(body=[functions["run_go_child"]], type_ignores=[]), ' + '"", "exec"), namespace)\n', + 'selected = []\nfor node in module.body:\n selected.append(node)\n' + 'selected.append(ast.parse("print(os.environ)").body[0])\n' + 'namespace = {"os": os}\n' + 'exec(compile(ast.Module(body=selected, type_ignores=[]), ' + '"", "exec"), namespace)\n', + 'changed = wrapper\nchanged = "print(os.environ)"\n' + 'module = ast.parse(changed)\nnamespace = {"os": os}\n' + 'exec(compile(module, "", "exec"), namespace)\n', + ) + for suffix in suffixes: + with self.subTest(suffix=suffix): + self.assertIsNotNone(self.inspect(self.packet_ast_source_prefix() + suffix)) + + def test_compile_provenance_does_not_leak_across_parameters_or_opaque_calls(self) -> None: + specimens = ( + self.packet_ast_source_prefix() + + 'def execute(wrapper):\n exec(compile(wrapper, "", "exec"), {"os": os})\n' + 'execute("print(os.environ)")\n', + self.packet_ast_source_prefix() + + 'def execute(helper_source):\n exec(compile(helper_source, "", "exec"), {"os": os})\n' + 'callback = execute\nexecute(wrapper)\ncallback("print(os.environ)")\n', + self.packet_ast_source_prefix() + + 'def alter(value):\n value.body = ast.parse("print(os.environ)").body\n' + 'alter(module)\nexec(compile(module, "", "exec"), {"os": os})\n', + ) + for specimen in specimens: + with self.subTest(specimen=specimen): + self.assertIsNotNone(self.inspect(specimen)) + + def test_source_replacement_cannot_inject_an_unreviewed_program(self) -> None: + specimen = self.packet_ast_source_prefix() + ( + 'wrapper = wrapper.replace("", "print(os.environ)\\n", 1)\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n' + ) + self.assertIsNotNone(self.inspect(specimen)) + + def test_actual_packet_compile_helpers_retain_provenance(self) -> None: + checked = 0 + for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): + tree = ast.parse(body, filename="") + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == "exec" and node.args + and isinstance(node.args[0], ast.Call) + and isinstance(node.args[0].func, ast.Name) + and node.args[0].func.id == "compile" + ): + continue + checked += 1 + with self.subTest(packet_line=number, body_line=node.lineno): + self.assertTrue(self.scanner["reviewed_python_exec_call"](node, safe_marker, tree)) + self.assertGreater(checked, 0) + print(f"packet compile-source boundary: {checked} actual helper calls checked") + + def test_ast_alias_and_nested_mutations_invalidate_helper_origins(self) -> None: + mutations = ( + 'alias = functions\nalias["run_go_child"] = ast.parse("print(os.environ)").body[0]\n', + 'helper = functions["run_go_child"]\nhelper.body = ast.parse("print(os.environ)").body\n', + 'functions["run_go_child"].body = ast.parse("print(os.environ)").body\n', + 'put = functions.update\nput({"run_go_child": ast.parse("print(os.environ)").body[0]})\n', + 'helper = functions["run_go_child"]\nsetattr(helper, "body", ast.parse("print(os.environ)").body)\n', + ) + setup = self.packet_ast_source_prefix() + ( + 'functions = {node.name: node for node in module.body ' + 'if isinstance(node, ast.FunctionDef)}\n' + ) + suffix = ( + 'namespace = {"os": os}\n' + 'exec(compile(ast.Module(body=[functions["run_go_child"]], type_ignores=[]), ' + '"", "exec"), namespace)\n' + ) + for mutation in mutations: + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect(setup + mutation + suffix)) + + def test_ast_providers_cannot_be_forged_by_markers_or_monkeypatch(self) -> None: + bodies = ( + 'import ast, os, subprocess\n' + 'packet = subprocess.check_output(["printf", ' + '"print(os.environ) # docs/evidence/g01-recovery-packet.md"], text=True)\n' + 'module = ast.parse(packet)\nnamespace = {"os": os}\n' + 'exec(compile(module, "", "exec"), namespace)\n', + self.packet_ast_source_prefix() + + 'def fake(value):\n return None\nast.parse = fake\n' + 'module = ast.parse(wrapper)\nnamespace = {"os": os}\n' + 'exec(compile(module, "", "exec"), namespace)\n', + ) + for body in bodies: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + def test_compile_primitives_and_source_parameters_need_proven_bindings(self) -> None: + bodies = ( + self.packet_ast_source_prefix() + + 'exec = eval\nnamespace = {}\n' + 'exec(compile(module, "", "exec"), namespace)\n', + 'def check(helper_source):\n' + ' marker = \'source = Path("docs/evidence/g01-recovery-packet.md")\'\n' + ' tail = "matches = []"\n' + ' exec(compile(helper_source, "", "exec"), {})\n' + 'check("print(__import__(\\\"os\\\").environ)")\n', + ) + for body in bodies: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + 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}\"" From 6a7a17eb759733c54fd9d0e4d7b6218f57f56c57 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 14:17:31 +0900 Subject: [PATCH 038/123] fix: keep scanner loader modules within local namespaces --- docs/evidence/g01-recovery-packet.md | 133 ++++++++++++++---- .../issue79_regression_test.py | 30 ++++ 2 files changed, 132 insertions(+), 31 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a793d4b0..2683b322 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10956,7 +10956,8 @@ def python_compile_provenance(tree): and all(isinstance(argument, ast.Constant) for argument in node.args) and node.args[0].value == 'source = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")' and node.args[1].value in {'source = ""', 'source = packet'} - and type(node.args[2].value) is int and node.args[2].value == 1 + and isinstance(node.args[2].value, int) and not isinstance(node.args[2].value, bool) + and node.args[2].value == 1 ) if isinstance(node.func, ast.Attribute) and node.func.attr == "read_text": receiver = node.func.value @@ -25879,9 +25880,15 @@ def load_scanner(text): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "python_heredoc_bodies": loader_namespace["python_heredoc_bodies"], + "shell_commands": loader_namespace["shell_commands"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } parent = load_scanner(parent_packet) current = load_scanner(packet) @@ -27082,9 +27089,13 @@ def load_scanner(text, label): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } current = load_scanner(packet, "current-scanner-401034") parent_packet = subprocess.check_output( @@ -27386,9 +27397,13 @@ def load_scanner(text, label): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } def wrapper_source(text): start = text.index("\nimport hashlib\n", text.index("go_test_checked()")) + 1 @@ -27617,9 +27632,14 @@ def load_scanner(text): "source = \"\"", 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "executable_tokens": loader_namespace["executable_tokens"], + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } parent = load_scanner(parent_packet) current = load_scanner(packet) @@ -27861,9 +27881,13 @@ def load_scanner(text, label): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } def wrapper_source(text): start = text.index("\nimport hashlib\n", text.index("go_test_checked()")) + 1 @@ -28168,9 +28192,13 @@ def load_scanner(text): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } parent = load_scanner(parent_packet) current = load_scanner(packet) @@ -28386,9 +28414,13 @@ def load_scanner(text): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } parent = load_scanner(parent_packet) current = load_scanner(packet) @@ -29279,9 +29311,15 @@ def load_scanner(packet, label): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_owned_directory_proof": loader_namespace["shell_owned_directory_proof"], + "shell_packet_owned_path": loader_namespace["shell_packet_owned_path"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } parent = load_scanner(parent_packet, "exact-parent-2d6a1f7-scanner") @@ -29640,9 +29678,15 @@ def load_scanner(packet, label): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "executable_tokens": loader_namespace["executable_tokens"], + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_commands": loader_namespace["shell_commands"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } parent = load_scanner(parent_packet, "exact-parent-f44") @@ -29808,9 +29852,15 @@ def load_scanner(packet, label): 'source = ""', 1, ) - namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} - exec(compile(scanner, "", "exec"), namespace) - return namespace + loader_namespace = {"Path": Path, "ast": ast, "re": re, "shlex": shlex} + exec(compile(scanner, "", "exec"), loader_namespace) + return { + "executable_tokens": loader_namespace["executable_tokens"], + "forbidden_command": loader_namespace["forbidden_command"], + "inspect_python_heredoc": loader_namespace["inspect_python_heredoc"], + "shell_commands": loader_namespace["shell_commands"], + "shell_token_segments": loader_namespace["shell_token_segments"], + } current = load_scanner(packet, "current-5676911476") @@ -32054,3 +32104,24 @@ combined packet scan and independent exact-commit contract/security delta reviews remain pending, as do the fresh pushed-head hosted PR quick check and GitHub Codex review. No push, merge, live dispatch or runner operation is claimed by this entry; G01 and #79 remain incomplete. + +The first combined selector on immutable +`e378ba418fe70fba01b6b8b68bf7405fba610aad` failed one test in 39.465s, +with 11 scanner violations: one new `type()` spelling in the scanner itself +and ten historic scanner loaders returning their full AST-bearing namespace. +No passing combined scan is claimed for that head. The compiler-source +helper probe still accepted all 128 legitimate calls. + +The follow-up keeps the AST-module escape guard closed: historic loaders +now return only the literal helper members their callers actually use, +leaving the full module-bearing execution dictionary local. It changes +no immutable source SHA or historic RED assertion. An AST-only structural +test verifies all ten loaders expose exactly their required helper keys +and remain scanner-compatible, without running historical packet code. +The scanner's integer-count check uses `isinstance` with explicit boolean +exclusion instead of its own unsupported runtime-type construction form. +Four focused methods passed in 2.141s, covering those ten loaders, all 128 +compile calls, opaque/parameter provenance and source-replacement denial. +Independent reviews target `e378ba4`; they do not automatically approve +this later compatibility delta. The subsequent combined selector and +exact-candidate delta approval remain required before pushing. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 8656b72c..c93aeb3a 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3712,6 +3712,36 @@ def test_actual_packet_compile_helpers_retain_provenance(self) -> None: self.assertGreater(checked, 0) print(f"packet compile-source boundary: {checked} actual helper calls checked") + def test_historic_scanner_loaders_export_only_their_required_helpers(self) -> None: + checked = 0 + for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): + tree = ast.parse(body, filename="") + for function in ast.walk(tree): + if not isinstance(function, ast.FunctionDef) or function.name != "load_scanner": + continue + checked += 1 + with self.subTest(packet_line=number): + exported = [node.value for node in function.body if isinstance(node, ast.Return)] + self.assertEqual(1, len(exported)) + self.assertIsInstance(exported[0], ast.Dict) + exports = exported[0] + keys = {key.value for key in exports.keys if isinstance(key, ast.Constant)} + required = { + node.slice.value for node in ast.walk(tree) + if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant) + and isinstance(node.slice.value, str) and node.slice.value in self.scanner + and not (isinstance(node.value, ast.Name) and node.value.id == "loader_namespace") + } + self.assertEqual(required, keys) + self.assertTrue(all( + isinstance(value, ast.Subscript) and isinstance(value.value, ast.Name) + and value.value.id == "loader_namespace" and isinstance(value.slice, ast.Constant) + and value.slice.value == key.value + for key, value in zip(exports.keys, exports.values) + )) + self.assertIsNone(self.scanner["inspect_python_heredoc"](body, safe_marker)) + self.assertEqual(10, checked) + def test_ast_alias_and_nested_mutations_invalidate_helper_origins(self) -> None: mutations = ( 'alias = functions\nalias["run_go_child"] = ast.parse("print(os.environ)").body[0]\n', From ef81a3eb2535c09e6ccffbc0b94166fb960e47c7 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 14:24:12 +0900 Subject: [PATCH 039/123] fix: isolate AST parameter and comprehension provenance --- docs/evidence/g01-recovery-packet.md | 50 +++++++++++++++++-- .../issue79_regression_test.py | 15 ++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 2683b322..c31c0b69 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -11179,26 +11179,51 @@ def python_compile_provenance(tree): def ast_receiver_names(value): if isinstance(value, ast.Name): + current = parents.get(value) + while current is not None: + if isinstance(current, (ast.ListComp, ast.DictComp, ast.SetComp, ast.GeneratorExp)): + for generator in current.generators: + if value.id in target_names(generator.target): + return ast_receiver_names(generator.iter) + current = parents.get(current) return {value.id}.intersection(ast_aliases) if isinstance(value, (ast.Attribute, ast.Subscript)): return ast_receiver_names(value.value) return set() + shadowed_ast_names = { + candidate.arg for candidate in ast.walk(tree) if isinstance(candidate, ast.arg) + } | { + candidate.name for candidate in ast.walk(tree) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.ExceptHandler, ast.MatchAs, ast.MatchStar)) + and candidate.name is not None + } | { + candidate.rest for candidate in ast.walk(tree) + if isinstance(candidate, ast.MatchMapping) and candidate.rest is not None + } | { + alias.asname or alias.name.split(".")[0] + for candidate in ast.walk(tree) if isinstance(candidate, (ast.Import, ast.ImportFrom)) + for alias in candidate.names + } + readonly_primitive_names = { + name for name in {"isinstance", "len", "compile", "exec"} + if not python_compile_primitive_is_shadowed(name, tree) + } while True: invalidated_ast_names = { name for target, value in ast_bindings for name in target_names(target) if name in ast_names and not ast_value(value) } + invalidated_ast_names.update(shadowed_ast_names.intersection(ast_names)) mutated_ast_names = set() for target, value in assignments: if isinstance(target, (ast.Subscript, ast.Attribute)): if not isinstance(target, ast.Subscript) or not ast_value(value): mutated_ast_names.update(ast_receiver_names(target.value)) for candidate in ast.walk(tree): - if isinstance(candidate, ast.Call) and python_dotted_name(candidate.func) not in { + if isinstance(candidate, ast.Call) and python_dotted_name(candidate.func) not in ({ "ast.parse", "ast.walk", "ast.Module", "ast.dump", "ast.literal_eval", "ast.unparse", - "isinstance", "len", "compile", "exec", - } and not ( + } | readonly_primitive_names) and not ( isinstance(candidate.func, ast.Attribute) and candidate.func.attr in {"append", "extend", "insert", "update", "setdefault", "__setitem__", "__setattr__"} and ast_receiver_names(candidate.func.value) @@ -32125,3 +32150,22 @@ compile calls, opaque/parameter provenance and source-replacement denial. Independent reviews target `e378ba4`; they do not automatically approve this later compatibility delta. The subsequent combined selector and exact-candidate delta approval remain required before pushing. + +The completed GPT-6-Luna/max contract delta review of immutable `e378ba4` +retained HOLD for an AST-parameter shadow: a parameter named `module` +inherited proof from the unrelated packet-derived global. Four pinned-blob +focused methods passed in 0.139s, but the inert shadow witness was accepted. +The coordinator reproduced that witness plus comprehension-local mutation +and a forged `len` consumer in one selector (three failed assertions, +0.135s), without compiling or executing any specimen. + +The follow-up conservatively removes AST proof for parameter/definition/ +import/pattern collisions, resolves comprehension-local receiver origins +through their generator instead of unrelated global names, and requires +unshadowed bindings for the readonly builtin allowlist. Six focused methods +passed in 2.412s, including all 128 actual compiler-helper calls, all ten +literal loader exports and neighboring safe AST-selection controls. This +changes a blocking boundary and needs fresh exact-candidate independent +delta review; the prior HOLD is not approval of this correction. The +already-running `6a7a17e` packet scan describes its captured older bytes, +not this subsequent source change. No push or merge has occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index c93aeb3a..f9883a46 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3693,6 +3693,21 @@ def test_source_replacement_cannot_inject_an_unreviewed_program(self) -> None: ) self.assertIsNotNone(self.inspect(specimen)) + def test_ast_parameters_and_readonly_call_spellings_cannot_inherit_provenance(self) -> None: + specimens = ( + 'def execute(module):\n' + ' exec(compile(ast.Module(body=[module], type_ignores=[]), "", "exec"), {"os": os})\n' + 'execute(ast.parse("print(os.environ)").body[0])\n', + 'def alter(value):\n value.body = ast.parse("print(os.environ)").body\n return True\n' + 'functions = {node.name: node for node in module.body if alter(node)}\n' + 'exec(compile(ast.Module(body=[functions["run_go_child"]], type_ignores=[]), "", "exec"), {"os": os})\n', + 'def len(value):\n value.body = ast.parse("print(os.environ)").body\n return 1\n' + 'len(module)\nexec(compile(module, "", "exec"), {"os": os})\n', + ) + for specimen in specimens: + with self.subTest(specimen=specimen): + self.assertIsNotNone(self.inspect(self.packet_ast_source_prefix() + specimen)) + def test_actual_packet_compile_helpers_retain_provenance(self) -> None: checked = 0 for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): From 85dcf4bd38a0233bfc7f64e6f2ace6f30975fe1b Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 14:40:19 +0900 Subject: [PATCH 040/123] fix: invalidate source mutations and constrain compiler providers --- docs/evidence/g01-recovery-packet.md | 154 ++++++++++++++++-- .../issue79_regression_test.py | 51 ++++++ 2 files changed, 195 insertions(+), 10 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index c31c0b69..67928c09 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9371,6 +9371,9 @@ def python_import_path_mutation_violation(tree): def python_credential_reader_aliases(tree): """Resolve only aliases of credential-bearing environment readers.""" + cached = getattr(tree, "_issue79_credential_reader_aliases", None) + if cached is not None: + return cached reader_names = { "os.getenv", "os.getenvb", @@ -9409,7 +9412,8 @@ def python_credential_reader_aliases(tree): changed = True if not changed: break - return aliases + tree._issue79_credential_reader_aliases = frozenset(aliases) + return tree._issue79_credential_reader_aliases def python_lexical_scope_chain(scope, parents): @@ -10846,10 +10850,53 @@ reviewed_python_compile_ast_names = { } reviewed_python_compile_slice_bases = reviewed_python_compile_source_names +# Closed historical fixture inputs already named by the packet's reproductions. +# Adding a revision changes this reviewed boundary; arbitrary Git objects do not. +reviewed_python_compile_packet_revisions = { + "01764bbed0a387129d2a2abbc9e27a87e073f87e", + "055a05bd9d5a9bb101e4400dd7a9236e3afd9f48", + "13b463086a0e7aa710067cafb44dcd9aed118654", + "14f998f32710f122f5861edfac8fdbc89ef95bfb", + "1fc2cede68f2692de0fad7a18d6faf7810544845", + "21f258a4215c667c245abb44ea419eb7901de2ad", + "22a2923033c875ddd4f755774f79f60b94649449", + "2abe394ee32f09888892c4adea5fc08121845d6b", + "2d6a1f700e2fb3aa918f2166e4dd4611601b10be", + "390c89b5e431a165dfbf8fa986cbf2594e3444ce", + "393d029975139b9d28900d477f62e8de392ace96", + "3bc8445567fe68cc355cf3f88f0c962a41e9cad5", + "3c4eb91ba0dabce5ab29785e5da273d1734ae612", + "3f6de0b227e4b44aa3d5e259e937e7dc1f0856bb", + "423d4fc501120a014e63f77d3ef6652606d0326a", + "4bd66186ea8d980a06ed8a4adf7f51e76b5028ef", + "4ee7c855b1e79f9478e5cf73c270731dfbf58cf7", + "518f23c3c875bfda8c8c65239e5171d23c444fc6", + "5297b3c3b05afedf97723b7b58806cdd5519a2b6", + "5f42598b94ce5339c35f55be42eb108973850d24", + "6af854fb660bc7d9c31c9920a6cec5c2fbb1966d", + "6c55f5b67035fb1c7ac334984499cfe80d6bb86b", + "71a7a599de567914158b05e3c480f7e0d48c709f", + "755968f9b4343c860cd8ddeca12a97b277c6e1b5", + "7d91bed688dbea21bea7dff62f41d48d1d57ce4b", + "81787b2e90df496a9c5a51fddc7607d3019834b7", + "8958ec9a5c1e8de6c43d29e389a706f1c9ba75dd", + "8dfa9a031bc321f2ccc208104268f7c5ead9281b", + "943ebece04882a0faf055d73e5988bd8954088f8", + "b16a349509535d6dcb179c9c0bd7a6a313c48bcd", + "b85839cc801395f4ec9560b056a2a6706c7aa306", + "c9f986d0256e47aba7fd273c1ae03993193a39c8", + "ce417347aa100562722477ea1126a5cf6372ec3a", + "d85f99fa70a6f563079b1ed4f29a1bc97740a3c5", + "da1af0d041e37e5df9f3ed8028b51a69ec58ed8c", + "ec5eb8087420bbbbb2a8ccf5c5df190b3c644895", + "f44a4871f87c1a8165593549d58ece6ffee61bf2", + "f84113bf38dd77dacb5dd3ea9b6018c2f2d06471", +} + def python_compile_primitive_is_shadowed(name, tree, provider_call=None): """A matching call spelling does not establish a builtin/stdlib binding.""" - module_aliases = python_assigned_module_names(tree, name) if name in {"ast", "subprocess", "pathlib"} else {name} + module_aliases = ({name} | python_assigned_module_names(tree, name)) if name in {"ast", "subprocess", "pathlib", "Path"} else {name} for candidate in ast.walk(tree): if ( isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store) and candidate.id == name @@ -10884,6 +10931,13 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): for part in ast.walk(target.value) ): return True + if ( + isinstance(candidate, ast.Call) and candidate.args + and python_dotted_name(candidate.func) in {"setattr", "delattr", "builtins.setattr", "builtins.delattr"} + and any(isinstance(part, ast.Name) and part.id in module_aliases for part in ast.walk(candidate.args[0])) + and (provider_call is None or candidate.lineno <= provider_call.lineno) + ): + return True return False @@ -10895,6 +10949,7 @@ def python_compile_provenance(tree): source_names = set() ast_names = set() packet_path_names = set() + parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} def target_names(target): if isinstance(target, ast.Name): @@ -11015,6 +11070,7 @@ def python_compile_provenance(tree): references = {reference} if reference is not None else set() return bool(references) and all( re.fullmatch(r"[0-9a-f]{40}:docs/evidence/g01-recovery-packet\.md", reference) + and reference.split(":", 1)[0] in reviewed_python_compile_packet_revisions for reference in references ) return False @@ -11076,6 +11132,16 @@ def python_compile_provenance(tree): assignments.append((node.target, node.value)) elif isinstance(node, ast.NamedExpr): assignments.append((node.target, node.value)) + elif isinstance(node, ast.AugAssign): + assignments.append((node.target, None)) + unsupported_source_binders = { + node.id for node in ast.walk(tree) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) + and not ( + isinstance(parents.get(node), ast.Assign) and node in parents[node].targets + or isinstance(parents.get(node), (ast.AnnAssign, ast.NamedExpr)) and parents[node].target is node + ) + } tainted_packet_path_names = { name for target, value in assignments @@ -11083,7 +11149,7 @@ def python_compile_provenance(tree): if name in packet_path_names and not packet_path_value(value) } packet_path_names.difference_update(tainted_packet_path_names) - parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + packet_path_names.difference_update(unsupported_source_binders) source_parameter_rules = [] for function in ast.walk(tree): if not isinstance(function, ast.FunctionDef): @@ -11139,6 +11205,7 @@ def python_compile_provenance(tree): parameter for parameter, index, calls in source_parameter_rules if parameter in source_names and not source_parameter_is_proven(index, calls) ) + invalidated_source_names.update(unsupported_source_binders.intersection(source_names)) if not invalidated_source_names: break source_names.difference_update(invalidated_source_names) @@ -11161,6 +11228,8 @@ def python_compile_provenance(tree): break ast_aliases = {name: set() for name in ast_names} for target, value in ast_bindings: + if value is None: + continue comprehension_bound_names = { bound for part in ast.walk(value) if isinstance(part, ast.comprehension) for bound in target_names(part.target) @@ -11177,19 +11246,36 @@ def python_compile_provenance(tree): ast_aliases[alias].add(dependency) ast_aliases[dependency].add(alias) - def ast_receiver_names(value): + def ast_receiver_names(value, seen=None): + seen = seen or set() + if value is None or id(value) in seen: + return set() + seen = seen | {id(value)} if isinstance(value, ast.Name): current = parents.get(value) while current is not None: if isinstance(current, (ast.ListComp, ast.DictComp, ast.SetComp, ast.GeneratorExp)): - for generator in current.generators: - if value.id in target_names(generator.target): - return ast_receiver_names(generator.iter) + matching = [ + generator for generator in current.generators + if value.id in target_names(generator.target) and value not in ast.walk(generator.iter) + ] + if matching: + return set().union(*(ast_receiver_names(generator.iter, seen) for generator in matching)) current = parents.get(current) return {value.id}.intersection(ast_aliases) if isinstance(value, (ast.Attribute, ast.Subscript)): - return ast_receiver_names(value.value) - return set() + if isinstance(value, ast.Attribute) and value.attr in {"name", "id", "arg", "attr", "lineno", "end_lineno", "col_offset", "end_col_offset"}: + return set() + return ast_receiver_names(value.value, seen) + if isinstance(value, ast.Call) and ( + python_dotted_name(value.func) in {"ast.unparse", "ast.dump", "ast.literal_eval"} + or isinstance(value.func, ast.Name) and value.func.id in {"len", "isinstance", "any", "all"} + and not python_compile_primitive_is_shadowed(value.func.id, tree) + or isinstance(value.func, ast.Attribute) and value.func.attr == "keys" + and ast_value(value.func.value) and not value.args and not value.keywords + ): + return set() + return set().union(*(ast_receiver_names(child, seen) for child in ast.iter_child_nodes(value))) shadowed_ast_names = { candidate.arg for candidate in ast.walk(tree) if isinstance(candidate, ast.arg) @@ -11206,7 +11292,7 @@ def python_compile_provenance(tree): for alias in candidate.names } readonly_primitive_names = { - name for name in {"isinstance", "len", "compile", "exec"} + name for name in {"isinstance", "len", "any", "all", "next", "compile", "exec"} if not python_compile_primitive_is_shadowed(name, tree) } while True: @@ -32169,3 +32255,51 @@ changes a blocking boundary and needs fresh exact-candidate independent delta review; the prior HOLD is not approval of this correction. The already-running `6a7a17e` packet scan describes its captured older bytes, not this subsequent source change. No push or merge has occurred. + +The completed GPT-6-Luna/max security review of immutable `e378ba4` +retained HOLD after isolated pinned-blob AST-only probes accepted six +source/provider mutations: source, AST-body and parameter `+=`, a +for-target packet-path rebind, an unrelated full-SHA Git provider and +`setattr(Path, "read_text", fake)`. A multi-component Path read was +already refused. The coordinator reproduced the six accepted forms in +one canonical selector (six failed assertions, 0.136s). + +The completed contract follow-up on immutable `ef81a3e` passed six pinned +methods in 2.358s, but retained HOLD for a duplicate-target comprehension +whose inner target referred to the AST while receiver analysis stopped at +the harmless outer target. The coordinator reproduced that case plus a +wrapped AST argument (two failed assertions, 0.131s). Receiver analysis +now unions the relevant comprehension origins and follows wrapped AST +arguments with cycle protection. Proven scalar fields and unshadowed +readonly builtin consumers do not falsely invalidate the real helpers. + +Augmented assignments invalidate source/AST proof; unsupported source/path +binders cannot reuse an old literal read origin. Git compile providers now +use a closed set of 38 immutable historical fixture inputs already named +by the packet, not arbitrary full hashes. Adding one changes a reviewed +boundary; membership is not approval to run all code in that snapshot. +Primitive `setattr`/`delattr` changes are included in provider binding checks. +An intermediate implementation failed with 134 errors from a parent-index +initialization ordering mistake, then one assertion and ten errors from a +missing-value mutation sentinel; those runs are not GREEN. Subsequent +overly broad AST-consumer invalidation rejected ten helpers, then one, +before the readonly controls were corrected. The final focused eight +methods passed in 2.491s, including all 128 compiler calls, all ten local +loader exports, mutation/provider denials and neighboring credential checks. + +The `6a7a17e` packet selector was explicitly interrupted after more than +eight minutes of CPU work because its captured source was superseded and +the scan substantially exceeded earlier 110–123s runs; exit was 130, not +PASS. Only the coordinator-owned offline test process was interrupted. +Its stack showed repeated credential-reader alias indexing in recursive +taint analysis. A deterministic AST-walk-count regression failed in +0.118s before caching and confirmed that an unchanged tree repeated two +full walks. The alias result is now an immutable per-tree cache; seven +focused methods passed in 1.352s with alias/import/credential-helper and +AST controls unchanged. No wall-clock speedup or completed combined scan +is claimed yet. The current source requires its own combined selector, +fresh independent contract/security delta sign-off and exact pushed-head +GitHub Codex/hosted gates. All preceding HOLD reports were delivered and +processed; neither later implementation nor earlier focused GREEN is an +independent approval. G01 and #79 remain incomplete; no live operation, +new push or merge has occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index f9883a46..dafb40f9 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3492,6 +3492,24 @@ def test_byte_environment_mappings_are_sensitive(self) -> None: with self.subTest(body=body): self.assertIsNotNone(self.inspect(body)) + def test_environment_reader_alias_index_is_reused_for_an_immutable_tree(self) -> None: + tree = ast.parse('import os\nreader = os.getenvb\nsecond = reader\n') + original_walk = ast.walk + walks = [] + def counted_walk(node): + walks.append(id(node)) + return original_walk(node) + try: + ast.walk = counted_walk + first = self.scanner["python_credential_reader_aliases"](tree) + self.assertEqual({"reader", "second"}, set(first)) + count = len(walks) + second = self.scanner["python_credential_reader_aliases"](tree) + self.assertEqual(first, second) + self.assertEqual(count, len(walks), "unchanged AST must not rebuild the reader alias index") + finally: + ast.walk = original_walk + def test_warning_sink_and_absolute_path_do_not_disclose_local_data(self) -> None: self.assertIsNotNone(self.inspect( 'import os, warnings\nwarnings.showwarning(os.environ, UserWarning, "x", 1)\n' @@ -3708,6 +3726,39 @@ def test_ast_parameters_and_readonly_call_spellings_cannot_inherit_provenance(se with self.subTest(specimen=specimen): self.assertIsNotNone(self.inspect(self.packet_ast_source_prefix() + specimen)) + def test_augmented_assignment_and_forged_source_providers_invalidate_compile_proof(self) -> None: + prefix = self.packet_ast_source_prefix() + specimens = ( + prefix + 'wrapper += "\\nprint(os.environ)"\nexec(compile(wrapper, "", "exec"), {"os": os})\n', + prefix + 'module.body += ast.parse("print(os.environ)").body\nexec(compile(module, "", "exec"), {"os": os})\n', + prefix + 'def execute(helper_source):\n helper_source += "\\nprint(os.environ)"\n exec(compile(helper_source, "", "exec"), {"os": os})\nexecute(wrapper)\n', + 'import os\nfrom pathlib import Path\npacket_path = "docs/evidence/g01-recovery-packet.md"\n' + 'for packet_path in ("outside",):\n pass\nwrapper = Path(packet_path).read_text()\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n', + 'import os, subprocess\nwrapper = subprocess.check_output(["git", "show", "' + + 'f' * 40 + ':docs/evidence/g01-recovery-packet.md"], text=True)\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n', + 'import os\nfrom pathlib import Path\ndef fake(self):\n return "print(os.environ)"\n' + 'setattr(Path, "read_text", fake)\nwrapper = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n', + ) + for specimen in specimens: + with self.subTest(specimen=specimen): + self.assertIsNotNone(self.inspect(specimen)) + + def test_reused_comprehension_targets_and_wrapped_ast_arguments_keep_mutation_taint(self) -> None: + setup = self.packet_ast_source_prefix() + ( + 'def alter(value):\n value.body = ast.parse("print(os.environ)").body\n' + 'def alter_wrapped(values):\n alter(values[0])\n' + ) + for mutation in ( + '[alter(node) for node in ["unused"] for node in [module]]\n', + 'alter_wrapped([module])\n', + ): + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect(setup + mutation + + 'exec(compile(module, "", "exec"), {"os": os})\n')) + def test_actual_packet_compile_helpers_retain_provenance(self) -> None: checked = 0 for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): From 3d76310ffb913791e3c18415a986f6ce6b56484e Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 14:43:37 +0900 Subject: [PATCH 041/123] fix: disambiguate compiler source call nodes --- docs/evidence/g01-recovery-packet.md | 37 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 67928c09..b6d4a753 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -11159,10 +11159,10 @@ def python_compile_provenance(tree): if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Load) and candidate.id == function.name ] - calls = [parents.get(use) for use in uses] - direct_only = bool(calls) and not function.decorator_list and all( - isinstance(call, ast.Call) and call.func is use - for use, call in zip(uses, calls) + compile_source_call_nodes = [parents.get(use) for use in uses] + direct_only = bool(compile_source_call_nodes) and not function.decorator_list and all( + isinstance(compile_source_call_node, ast.Call) and compile_source_call_node.func is use + for use, compile_source_call_node in zip(uses, compile_source_call_nodes) ) and sum( isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and candidate.name == function.name for candidate in ast.walk(tree) @@ -11173,12 +11173,13 @@ def python_compile_provenance(tree): for candidate in ast.walk(tree) ) for index, parameter in enumerate(list(function.args.posonlyargs) + list(function.args.args)): - source_parameter_rules.append((parameter.arg, index, calls if direct_only else [])) + source_parameter_rules.append((parameter.arg, index, compile_source_call_nodes if direct_only else [])) - def source_parameter_is_proven(index, calls): - return bool(calls) and all( - not call.keywords and index < len(call.args) and source_value(call.args[index]) - for call in calls + def source_parameter_is_proven(index, compile_source_call_nodes): + return bool(compile_source_call_nodes) and all( + not compile_source_call_node.keywords and index < len(compile_source_call_node.args) + and source_value(compile_source_call_node.args[index]) + for compile_source_call_node in compile_source_call_nodes ) for _ in range(len(assignments) + 1): @@ -11190,8 +11191,8 @@ def python_compile_provenance(tree): if name not in source_names: source_names.add(name) changed = True - for parameter, index, calls in source_parameter_rules: - if parameter not in source_names and source_parameter_is_proven(index, calls): + for parameter, index, compile_source_call_nodes in source_parameter_rules: + if parameter not in source_names and source_parameter_is_proven(index, compile_source_call_nodes): source_names.add(parameter) changed = True if not changed: @@ -11202,8 +11203,8 @@ def python_compile_provenance(tree): if name in source_names and not source_value(value) } invalidated_source_names.update( - parameter for parameter, index, calls in source_parameter_rules - if parameter in source_names and not source_parameter_is_proven(index, calls) + parameter for parameter, index, compile_source_call_nodes in source_parameter_rules + if parameter in source_names and not source_parameter_is_proven(index, compile_source_call_nodes) ) invalidated_source_names.update(unsupported_source_binders.intersection(source_names)) if not invalidated_source_names: @@ -32303,3 +32304,13 @@ GitHub Codex/hosted gates. All preceding HOLD reports were delivered and processed; neither later implementation nor earlier focused GREEN is an independent approval. G01 and #79 remain incomplete; no live operation, new push or merge has occurred. + +At immutable `85dcf4bd38a0233bfc7f64e6f2ace6f30975fe1b`, the combined +24-method focused group passed in 2.604s (including 128 helper calls and +ten loaders). The packet selector completed in 46.499s but failed one +assertion: the new comprehension variable named `call` collided with +existing callback-target analysis of the scanner body itself. It is renamed +to `compile_source_call_node` (and the corresponding call list) without +changing the source-binding rule. This is not a passing combined scan; +the renamed source still needs its own selector and exact-head delta +review. The 38-SHA fixture allowlist and mutable-AST guards are unchanged. From 45f8dfdc5c8e95e2eb9d64b4e78e1e45dc1ca0da Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 14:53:57 +0900 Subject: [PATCH 042/123] test: preserve open AST reader and Path provider alias blockers --- docs/evidence/g01-recovery-packet.md | 43 +++++++++++++++++++ .../issue79_regression_test.py | 30 +++++++++++++ 2 files changed, 73 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index b6d4a753..b9a0e9bc 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -32314,3 +32314,46 @@ to `compile_source_call_node` (and the corresponding call list) without changing the source-binding rule. This is not a passing combined scan; the renamed source still needs its own selector and exact-head delta review. The 38-SHA fixture allowlist and mutable-AST guards are unchanged. + +#### Current checkpoint: passing packet scan, open origin-alias P1s + +Immutable `3d76310ffb913791e3c18415a986f6ce6b56484e` passed its packet +selector in 49.663s: 331 shell commands, 95 Python bodies, zero static +violations. Four focused exact-head methods passed in 1.670s, including all +128 real compiler-helper calls and source/parameter/provider controls. These +passing results do not establish absence of unsupported source effects. + +Both completed GPT-6-Luna/max independent delta reviews explicitly covered +the `85dcf4b..3d76310` rename and reported HOLD at immutable `3d76310`. +Their isolated pinned-blob AST-only probes reproduced a setter alias +(`patch_reader = setattr`) forging the Path reader; the security review also +reproduced a class alias (`PathAlias = Path`) replacing `read_text` without +invalidating the canonical provider. Direct setter spelling was refused; +the alias forms were accepted. Neither reviewer edited files or reran the +whole packet scan/harness. Both reports settled successfully as completed +reviews, were processed before acknowledgment, and their existing terminals +were released back to external ownership without a process action. The +review verdicts remain HOLD, not APPROVE. + +Coordinator boundary checks additionally accepted three mutable-reader +escapes: `functions.get(...)` returning a helper node, `next(ast.walk(...))` +returning the module, and a function's `.args` child carrying a replaced +default expression. Their mutations did not reach the compiled origin's +alias graph. The canonical regression failed three assertions in 0.140s; +the two independently reported Path-provider aliases failed two assertions +in 0.121s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_ast_child_reader_results_cannot_hide_mutation_from_the_compiled_origin +python3 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_path_provider_patch_aliases_do_not_retain_source_authority +``` + +These five specimens remained AST data, never compiled/executed and never +read inherited values. Their RED tests are deliberately preserved before +the next correction; no GREEN or final independent approval is claimed. +They block pushing this branch as a stable candidate or merging PR #103. +The next bounded correction must close class/setter provider aliases and +AST child/reader-result mutation escape without relaxing the existing +128-helper and ten-loader controls. No fresh GitHub Codex review was +requested mid-edit, and no new push, merge or live operation occurred. +Issue #79 and parent G01 #1 remain In progress with required work outstanding. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index dafb40f9..02d245a9 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3759,6 +3759,36 @@ def test_reused_comprehension_targets_and_wrapped_ast_arguments_keep_mutation_ta self.assertIsNotNone(self.inspect(setup + mutation + 'exec(compile(module, "", "exec"), {"os": os})\n')) + def test_ast_child_reader_results_cannot_hide_mutation_from_the_compiled_origin(self) -> None: + setup = self.packet_ast_source_prefix() + ( + 'functions = {node.name: node for node in module.body if isinstance(node, ast.FunctionDef)}\n' + ) + specimens = ( + 'helper = functions.get("run_go_child")\nhelper.body = ast.parse("print(os.environ)").body\n' + 'exec(compile(ast.Module(body=[functions["run_go_child"]], type_ignores=[]), "", "exec"), {"os": os})\n', + 'selected = next(ast.walk(module))\nselected.body = ast.parse("print(os.environ)").body\n' + 'exec(compile(module, "", "exec"), {"os": os})\n', + 'arguments = functions["run_go_child"].args\n' + 'arguments.defaults = [ast.parse("print(os.environ)").body[0].value]\n' + 'exec(compile(ast.Module(body=[functions["run_go_child"]], type_ignores=[]), "", "exec"), {"os": os})\n', + ) + for specimen in specimens: + with self.subTest(specimen=specimen): + self.assertIsNotNone(self.inspect(setup + specimen)) + + def test_path_provider_patch_aliases_do_not_retain_source_authority(self) -> None: + setup = 'import os\nfrom pathlib import Path\ndef fake(self):\n return "print(os.environ)"\n' + suffix = ( + 'wrapper = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n' + ) + for mutation in ( + 'patch_reader = setattr\npatch_reader(Path, "read_text", fake)\n', + 'PathAlias = Path\nPathAlias.read_text = fake\n', + ): + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect(setup + mutation + suffix)) + def test_actual_packet_compile_helpers_retain_provenance(self) -> None: checked = 0 for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): From ee513f82720d09f2ddc69a872837ab8b1fce86a6 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 15:03:26 +0900 Subject: [PATCH 043/123] fix: track borrowed AST mutations and provider setter aliases --- docs/evidence/g01-recovery-packet.md | 80 +++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index b9a0e9bc..01bf1de4 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10897,6 +10897,29 @@ reviewed_python_compile_packet_revisions = { def python_compile_primitive_is_shadowed(name, tree, provider_call=None): """A matching call spelling does not establish a builtin/stdlib binding.""" module_aliases = ({name} | python_assigned_module_names(tree, name)) if name in {"ast", "subprocess", "pathlib", "Path"} else {name} + if name == "Path": + module_aliases.update( + imported.asname or imported.name + for statement in ast.walk(tree) + if isinstance(statement, ast.ImportFrom) and statement.module == "pathlib" + for imported in statement.names if imported.name == "Path" + ) + for _ in range(sum(isinstance(statement, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) for statement in ast.walk(tree)) + 1 if name in {"ast", "subprocess", "pathlib", "Path"} else 0): + before = set(module_aliases) + for statement in ast.walk(tree): + if isinstance(statement, ast.Assign): + bindings, provider_value = statement.targets, statement.value + elif isinstance(statement, (ast.AnnAssign, ast.NamedExpr)): + bindings, provider_value = [statement.target], statement.value + else: + continue + if ( + isinstance(provider_value, ast.Name) and provider_value.id in module_aliases + or name == "Path" and python_dotted_name(provider_value) == "pathlib.Path" + ): + module_aliases.update(binding.id for binding in bindings if isinstance(binding, ast.Name)) + if module_aliases == before: + break for candidate in ast.walk(tree): if ( isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store) and candidate.id == name @@ -10933,8 +10956,15 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): return True if ( isinstance(candidate, ast.Call) and candidate.args - and python_dotted_name(candidate.func) in {"setattr", "delattr", "builtins.setattr", "builtins.delattr"} and any(isinstance(part, ast.Name) and part.id in module_aliases for part in ast.walk(candidate.args[0])) + and ( + python_dotted_name(candidate.func) in {"setattr", "delattr", "builtins.setattr", "builtins.delattr"} + or isinstance(candidate.func, ast.Name) and any( + python_assigned_callable_alias(candidate.func.id, setter, tree) + or python_imported_function_alias_is_stable(candidate.func.id, "builtins", setter, tree) + for setter in ("setattr", "delattr") + ) + ) and (provider_call is None or candidate.lineno <= provider_call.lineno) ): return True @@ -11278,6 +11308,27 @@ def python_compile_provenance(tree): return set() return set().union(*(ast_receiver_names(child, seen) for child in ast.iter_child_nodes(value))) + # Origin aliases are broader than certified compiler values: a reader may + # expose mutable children without itself being a supported compiler input. + # Keep those borrowed objects linked to the certified roots until taint + # propagates, rather than granting the reader new compile authority. + for _ in range(len(ast_bindings) + 1): + origin_links_changed = False + for target, value in ast_bindings: + origins = ast_receiver_names(value) + for alias in target_names(target): + if not origins: + continue + ast_aliases.setdefault(alias, set()) + for origin in origins: + if origin == alias or origin in ast_aliases[alias]: + continue + ast_aliases[alias].add(origin) + ast_aliases[origin].add(alias) + origin_links_changed = True + if not origin_links_changed: + break + shadowed_ast_names = { candidate.arg for candidate in ast.walk(tree) if isinstance(candidate, ast.arg) } | { @@ -32357,3 +32408,30 @@ AST child/reader-result mutation escape without relaxing the existing 128-helper and ten-loader controls. No fresh GitHub Codex review was requested mid-edit, and no new push, merge or live operation occurred. Issue #79 and parent G01 #1 remain In progress with required work outstanding. + +#### Origin-alias correction: focused GREEN, independent approval pending + +The five open cases at `45f8dfdc5c8e95e2eb9d64b4e78e1e45dc1ca0da` +were revalidated together before implementation: five failed assertions in +0.147s. Mutable results borrowed through dictionary readers, AST iterators +or AST child attributes now stay connected to their compiled roots in the +mutation-origin graph. This deliberately does not certify those readers as +new compiler inputs. Provider mutation checks follow direct class aliases, +annotated/named bindings and local/imported setter aliases rather than +trusting only the canonical spelling. + +The two blocker methods plus actual-helper and historical-loader controls +passed in 39.825s (four methods, all 128 compiler calls and ten loaders). +Alias discovery was then limited to module/class providers, and receiver +matching precedes setter resolution to avoid needless callable-index work. +The combined focused group passed 21 methods in 4.277s: all five new denials, +previous AST/provider/parameter/comprehension/mutation controls, all 128 +actual helper calls, ten loader exports and adjacent module/credential +boundaries. The unsafe specimens remained AST data throughout. + +Canonical invocation remains `python3 -I -B +scripts/evidence_packet/issue79_regression_test.py` with the recorded focused +method selectors. These results replace neither a full harness result nor +the required packet selector and exact-candidate independent delta reviews. +The preceding HOLD verdicts remain historical; no final approval, new push, +GitHub Codex result, merge or live authorization is inferred from GREEN. From 97353eaa4ca1a26927de0979b166193869546a0e Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 15:20:57 +0900 Subject: [PATCH 044/123] fix: close opaque receiver and deferred provider mutations --- docs/evidence/g01-recovery-packet.md | 149 +++++++++++++++++- .../issue79_regression_test.py | 26 +++ 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 01bf1de4..b9b051aa 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10896,6 +10896,15 @@ reviewed_python_compile_packet_revisions = { def python_compile_primitive_is_shadowed(name, tree, provider_call=None): """A matching call spelling does not establish a builtin/stdlib binding.""" + if provider_call is not None: + provider_parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + enclosing_provider = provider_parents.get(provider_call) + while enclosing_provider is not None: + if isinstance(enclosing_provider, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + # Definition line order cannot prove when a deferred read runs. + provider_call = None + break + enclosing_provider = provider_parents.get(enclosing_provider) module_aliases = ({name} | python_assigned_module_names(tree, name)) if name in {"ast", "subprocess", "pathlib", "Path"} else {name} if name == "Path": module_aliases.update( @@ -10904,6 +10913,47 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): if isinstance(statement, ast.ImportFrom) and statement.module == "pathlib" for imported in statement.names if imported.name == "Path" ) + provider_bindings = {} + for statement in ast.walk(tree): + if isinstance(statement, ast.Assign): + bindings, provider_value = statement.targets, statement.value + elif isinstance(statement, (ast.AnnAssign, ast.NamedExpr)): + bindings, provider_value = [statement.target], statement.value + else: + continue + for binding in bindings: + if isinstance(binding, ast.Name): + provider_bindings.setdefault(binding.id, []).append(provider_value) + + def provider_value_borrows_class(value, seen=None): + seen = seen or set() + if value is None or id(value) in seen: + return False + seen = seen | {id(value)} + if isinstance(value, ast.Name): + return value.id in module_aliases + if isinstance(value, (ast.List, ast.Tuple)): + return any(provider_value_borrows_class(element, seen) for element in value.elts) + if isinstance(value, ast.Dict): + return any(provider_value_borrows_class(element, seen) for element in value.values) + if isinstance(value, ast.IfExp): + return any(provider_value_borrows_class(element, seen) for element in (value.body, value.orelse)) + if isinstance(value, ast.Subscript) and isinstance(value.slice, ast.Constant): + containers = provider_bindings.get(value.value.id, ()) if isinstance(value.value, ast.Name) else [value.value] + for container in containers: + if isinstance(container, ast.Dict): + selected = [element for key, element in zip(container.keys, container.values) + if key is None or isinstance(key, ast.Constant) and key.value == value.slice.value] + elif isinstance(container, (ast.List, ast.Tuple)) and isinstance(value.slice.value, int) and not isinstance(value.slice.value, bool): + index = value.slice.value + selected = [container.elts[index]] if -len(container.elts) <= index < len(container.elts) else [] + else: + selected = [container] + if any(provider_value_borrows_class(element, seen) for element in selected): + return True + return False + return python_dotted_name(value) == "pathlib.Path" + for _ in range(sum(isinstance(statement, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) for statement in ast.walk(tree)) + 1 if name in {"ast", "subprocess", "pathlib", "Path"} else 0): before = set(module_aliases) for statement in ast.walk(tree): @@ -10914,10 +10964,13 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): else: continue if ( - isinstance(provider_value, ast.Name) and provider_value.id in module_aliases - or name == "Path" and python_dotted_name(provider_value) == "pathlib.Path" + provider_value_borrows_class(provider_value) if name == "Path" + else isinstance(provider_value, ast.Name) and provider_value.id in module_aliases ): - module_aliases.update(binding.id for binding in bindings if isinstance(binding, ast.Name)) + module_aliases.update( + part.id for binding in bindings for part in ast.walk(binding) + if isinstance(part, ast.Name) and isinstance(part.ctx, ast.Store) + ) if module_aliases == before: break for candidate in ast.walk(tree): @@ -10949,11 +11002,31 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): for target in targets: if name == "subprocess" and isinstance(target, ast.Attribute) and target.attr != "check_output": continue - if isinstance(target, (ast.Attribute, ast.Subscript)) and any( + if ( + isinstance(target, ast.Attribute) + or isinstance(target, ast.Subscript) and not isinstance(target.value, ast.Name) + ) and any( isinstance(part, ast.Name) and part.id in module_aliases for part in ast.walk(target.value) ): return True + if ( + name == "Path" + and isinstance(candidate, ast.Call) + and (provider_call is None or candidate.lineno <= provider_call.lineno) + and any( + provider_value_borrows_class(argument) + for argument in [*candidate.args, *(keyword.value for keyword in candidate.keywords)] + ) + and not ( + isinstance(candidate.func, ast.Name) + and candidate.func.id in {"getattr", "hasattr", "isinstance", "issubclass", "type", "compile", "exec", "len", "any", "all"} + and not python_compile_primitive_is_shadowed(candidate.func.id, tree) + ) + ): + # A provider passed to an opaque callable may be patched indirectly; + # do not need to prove which container holds the setter to refuse it. + return True if ( isinstance(candidate, ast.Call) and candidate.args and any(isinstance(part, ast.Name) and part.id in module_aliases for part in ast.walk(candidate.args[0])) @@ -11381,6 +11454,14 @@ def python_compile_provenance(tree): for target, value in assignments ) ): + # A bound method's receiver is an input too. Unknown methods + # (including saved method aliases) can mutate borrowed children + # even when their explicit arguments have no reviewed origin. + if not ( + isinstance(candidate.func, ast.Attribute) + and candidate.func.attr in {"get", "keys", "items", "values", "copy", "count", "index"} + ): + mutated_ast_names.update(ast_receiver_names(candidate.func)) for argument in candidate.args: mutated_ast_names.update(ast_receiver_names(argument)) for keyword in candidate.keywords: @@ -32435,3 +32516,63 @@ method selectors. These results replace neither a full harness result nor the required packet selector and exact-candidate independent delta reviews. The preceding HOLD verdicts remain historical; no final approval, new push, GitHub Codex result, merge or live authorization is inferred from GREEN. + +#### Follow-up receiver, container and deferred-provider correction + +Immutable `ee513f82720d09f2ddc69a872837ab8b1fce86a6` passed the packet +selector in 53.115s (331 shell commands, 95 Python bodies, zero violations), +but both completed GPT-6-Luna/max delta reviews retained HOLD. The contract +review passed seven pinned-blob selectors in 4.058s while accepting a later +`subprocess.check_output` patch before a deferred helper ran, plus opaque +AST receiver mutations. The security review accepted a dictionary-held +setter replacing `Path.read_text`; direct/local/imported setters and the +previous child-reader cases were refused. Both reviews were processed, +released to external terminal ownership with no process action, then acked. + +Coordinator AST-only probes reproduced three receiver variants: direct +`__iadd__`, direct list `__init__` and a saved bound initializer. The +canonical method was RED with three failed assertions in 0.138s before +receiver invalidation. The dictionary-held setter was RED (one assertion, +0.119s); adding opaque-helper, dictionary-held-class and tuple-unpack class +aliases produced four failed assertions in 0.134s. The deferred Git witness +initially used an unlisted revision and was refused, which was not meaningful +RED for this finding. Using an existing allowed fixture revision reproduced +the actual bypass: one failed assertion in 0.118s. No specimen was executed. + +Unknown AST methods now include their receiver/callable origin as mutable +input, including saved bound methods; a narrow readonly container-reader +subset preserves helper selection. Path provider aliases include literal +containers and destructuring. Constant-key/index resolution distinguishes +borrowed classes from other values in the same helper namespace. Passing +the class to an opaque callable invalidates its provider authority without +needing to identify the hidden setter. Unshadowed builtin compile/exec are +not themselves treated as class mutation; their existing source and namespace +checks remain required. Deferred function/async/lambda reads cannot use +definition-line order to ignore later provider changes. The 38 fixture +revisions and compiler-input allowlists are unchanged. + +Overly broad intermediate provider checks rejected safe controls: focused +runs failed 78, 73, 42, 23 and 19 assertions respectively. They are not GREEN. +The final eight-method boundary group passed in 4.912s, including all 128 +actual compiler calls and ten loaders. The combined focused group passed +23 methods in 5.103s; it includes the previous 21-method group plus the two +new receiver/deferred methods. CPython remains 3.14.3; no full current harness, +hosted Python or live coverage is claimed. Reproducible boundary invocation: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_deferred_git_provider_does_not_ignore_later_monkeypatch \ + Issue79RegressionTests.test_path_provider_patch_aliases_do_not_retain_source_authority \ + Issue79RegressionTests.test_opaque_ast_receiver_methods_cannot_replace_compiled_children \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers \ + Issue79RegressionTests.test_packet_derived_ast_helper_selection_remains_supported \ + Issue79RegressionTests.test_augmented_assignment_and_forged_source_providers_invalidate_compile_proof \ + Issue79RegressionTests.test_ast_providers_cannot_be_forged_by_markers_or_monkeypatch +``` + +This correction requires its own packet selector and fresh exact-candidate +contract/security delta sign-off before a stable batch push. The passing +`ee513f8` scan does not certify the changed source. Previous HOLDs remain +historical; no current approval, push, GitHub Codex request, merge or live +operation occurred. #79 and parent G01 #1 remain In progress. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 02d245a9..872ebee5 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3776,6 +3776,28 @@ def test_ast_child_reader_results_cannot_hide_mutation_from_the_compiled_origin( with self.subTest(specimen=specimen): self.assertIsNotNone(self.inspect(setup + specimen)) + def test_deferred_git_provider_does_not_ignore_later_monkeypatch(self) -> None: + specimen = ( + 'import os, subprocess\n' + 'def run_probe():\n' + ' wrapper = subprocess.check_output(["git", "show", ' + '"01764bbed0a387129d2a2abbc9e27a87e073f87e:docs/evidence/g01-recovery-packet.md"], text=True)\n' + ' exec(compile(wrapper, "", "exec"), {"os": os})\n' + 'def fake(*args, **kwargs):\n return "print(os.environ)"\n' + 'subprocess.check_output = fake\nrun_probe()\n' + ) + self.assertIsNotNone(self.inspect(specimen)) + + def test_opaque_ast_receiver_methods_cannot_replace_compiled_children(self) -> None: + suffix = 'exec(compile(module, "", "exec"), {"os": os})\n' + for mutation in ( + 'module.body.__iadd__(ast.parse("print(os.environ)").body)\n', + 'module.body.__init__(ast.parse("print(os.environ)").body)\n', + 'reset = module.body.__init__\nreset(ast.parse("print(os.environ)").body)\n', + ): + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect(self.packet_ast_source_prefix() + mutation + suffix)) + def test_path_provider_patch_aliases_do_not_retain_source_authority(self) -> None: setup = 'import os\nfrom pathlib import Path\ndef fake(self):\n return "print(os.environ)"\n' suffix = ( @@ -3785,6 +3807,10 @@ def test_path_provider_patch_aliases_do_not_retain_source_authority(self) -> Non for mutation in ( 'patch_reader = setattr\npatch_reader(Path, "read_text", fake)\n', 'PathAlias = Path\nPathAlias.read_text = fake\n', + 'patches = {"set": setattr}\npatches["set"](Path, "read_text", fake)\n', + 'def alter(cls):\n cls.read_text = fake\nalter(Path)\n', + 'classes = {"reader": Path}\nclasses["reader"].read_text = fake\n', + 'PathAlias, = [Path]\nPathAlias.read_text = fake\n', ): with self.subTest(mutation=mutation): self.assertIsNotNone(self.inspect(setup + mutation + suffix)) From 70b29ce5d922b46efb8a8f684f61c108cfb8bda2 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 15:22:59 +0900 Subject: [PATCH 045/123] fix: disambiguate provider container probe names --- docs/evidence/g01-recovery-packet.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index b9b051aa..5bfe0248 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10939,16 +10939,16 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): if isinstance(value, ast.IfExp): return any(provider_value_borrows_class(element, seen) for element in (value.body, value.orelse)) if isinstance(value, ast.Subscript) and isinstance(value.slice, ast.Constant): - containers = provider_bindings.get(value.value.id, ()) if isinstance(value.value, ast.Name) else [value.value] - for container in containers: - if isinstance(container, ast.Dict): - selected = [element for key, element in zip(container.keys, container.values) + provider_container_values = provider_bindings.get(value.value.id, ()) if isinstance(value.value, ast.Name) else [value.value] + for provider_container_value in provider_container_values: + if isinstance(provider_container_value, ast.Dict): + selected = [element for key, element in zip(provider_container_value.keys, provider_container_value.values) if key is None or isinstance(key, ast.Constant) and key.value == value.slice.value] - elif isinstance(container, (ast.List, ast.Tuple)) and isinstance(value.slice.value, int) and not isinstance(value.slice.value, bool): + elif isinstance(provider_container_value, (ast.List, ast.Tuple)) and isinstance(value.slice.value, int) and not isinstance(value.slice.value, bool): index = value.slice.value - selected = [container.elts[index]] if -len(container.elts) <= index < len(container.elts) else [] + selected = [provider_container_value.elts[index]] if -len(provider_container_value.elts) <= index < len(provider_container_value.elts) else [] else: - selected = [container] + selected = [provider_container_value] if any(provider_value_borrows_class(element, seen) for element in selected): return True return False @@ -32576,3 +32576,13 @@ contract/security delta sign-off before a stable batch push. The passing `ee513f8` scan does not certify the changed source. Previous HOLDs remain historical; no current approval, push, GitHub Codex request, merge or live operation occurred. #79 and parent G01 #1 remain In progress. + +The `97353eaa4ca1a26927de0979b166193869546a0e` packet selector completed +FAIL1 in 46.046s: the scanner's new local `container` iterator collided with +the existing Markdown-prefix regex receiver of the same name, reporting +an unresolved Path receiver for `.group`. The new provider-local iterator +and collection are alpha-renamed to `provider_container_value` and +`provider_container_values`; no provider or mutation rule is relaxed. +Five focused methods passed in 4.900s after the rename, including all 128 +compiler calls and ten loaders. The renamed source still needs its own +packet result and explicitly exact-head independent delta disposition. From 6e41c63251022fae86f09dd696018576c75a44e2 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 15:35:10 +0900 Subject: [PATCH 046/123] fix: preserve provider classes through readers and helper returns --- docs/evidence/g01-recovery-packet.md | 63 ++++++++++++++++++- .../issue79_regression_test.py | 6 ++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 5bfe0248..486a5269 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10906,6 +10906,8 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): break enclosing_provider = provider_parents.get(enclosing_provider) module_aliases = ({name} | python_assigned_module_names(tree, name)) if name in {"ast", "subprocess", "pathlib", "Path"} else {name} + pathlib_provider_modules = {"pathlib"} | python_assigned_module_names(tree, "pathlib") if name == "Path" else set() + class_borrow_parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} if name == "Path" else {} if name == "Path": module_aliases.update( imported.asname or imported.name @@ -10932,12 +10934,24 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): seen = seen | {id(value)} if isinstance(value, ast.Name): return value.id in module_aliases - if isinstance(value, (ast.List, ast.Tuple)): + if isinstance(value, (ast.List, ast.Tuple, ast.Set)): return any(provider_value_borrows_class(element, seen) for element in value.elts) if isinstance(value, ast.Dict): return any(provider_value_borrows_class(element, seen) for element in value.values) if isinstance(value, ast.IfExp): return any(provider_value_borrows_class(element, seen) for element in (value.body, value.orelse)) + if isinstance(value, ast.Call): + return ( + isinstance(value.func, ast.Attribute) + and value.func.attr in {"get", "pop", "popitem", "copy", "items", "values", "__getitem__"} + and provider_value_borrows_class(value.func.value, seen) + ) or ( + isinstance(value.func, ast.Name) and value.func.id in {"next", "iter"} + and any(provider_value_borrows_class(argument, seen) for argument in value.args) + ) or any( + provider_value_borrows_class(returned, seen) + for returned in python_local_call_return_values(value, tree, class_borrow_parents) + ) if isinstance(value, ast.Subscript) and isinstance(value.slice, ast.Constant): provider_container_values = provider_bindings.get(value.value.id, ()) if isinstance(value.value, ast.Name) else [value.value] for provider_container_value in provider_container_values: @@ -10952,7 +10966,10 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): if any(provider_value_borrows_class(element, seen) for element in selected): return True return False - return python_dotted_name(value) == "pathlib.Path" + return ( + isinstance(value, ast.Attribute) and value.attr == "Path" + and isinstance(value.value, ast.Name) and value.value.id in pathlib_provider_modules + ) for _ in range(sum(isinstance(statement, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) for statement in ast.walk(tree)) + 1 if name in {"ast", "subprocess", "pathlib", "Path"} else 0): before = set(module_aliases) @@ -32586,3 +32603,45 @@ and collection are alpha-renamed to `provider_container_value` and Five focused methods passed in 4.900s after the rename, including all 128 compiler calls and ten loaders. The renamed source still needs its own packet result and explicitly exact-head independent delta disposition. + +At immutable `70b29ce5d922b46efb8a8f684f61c108cfb8bda2`, the packet +selector passed in 53.064s (331 shell commands, 95 Python bodies, zero +violations). The completed GPT-6-Luna/max contract delta review explicitly +approved that head, covering the alpha rename and shared exact-head evidence; +its pinned five-selector delta run passed in 0.176s. The completed security +review retained HOLD: set destructuring, dictionary `get` and list `pop` +could borrow the Path class without preserving provider mutation origins. +Both reports were processed, released without a process action and acked. + +The coordinator reproduced those three forms in the canonical provider +method (three failed assertions, 0.146s). Set values and container-reader +results now carry class-borrow origins. An overly broad call-result rule +rejected three safe compiler controls (six methods, 5.038s, FAIL3); it was +narrowed so `Path.cwd()` and other constructed path instances are not +mistaken for the Path class. A neighboring module-alias class attribute +was RED (one assertion, 0.145s), while the literal reflective getter control +was already refused. A local helper returning the Path class was RED +(one assertion, 0.151s). Class borrowing now follows imported/assigned +pathlib module aliases and the existing static local-return resolver rather +than evaluating calls. These rules invalidate mutation authority; they do +not certify additional compiler inputs or snapshot revisions. + +Final focused eight methods passed in 5.263s, preserving all 128 actual +compiler calls and ten loader exports: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_path_provider_patch_aliases_do_not_retain_source_authority \ + Issue79RegressionTests.test_deferred_git_provider_does_not_ignore_later_monkeypatch \ + Issue79RegressionTests.test_opaque_ast_receiver_methods_cannot_replace_compiled_children \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers \ + Issue79RegressionTests.test_packet_derived_ast_helper_selection_remains_supported \ + Issue79RegressionTests.test_augmented_assignment_and_forged_source_providers_invalidate_compile_proof \ + Issue79RegressionTests.test_compile_primitives_and_source_parameters_need_proven_bindings +``` + +The old contract approval and packet PASS apply to `70b29ce`, not this changed +source. A new exact-candidate packet result and focused independent delta +dispositions remain required before pushing. Unsafe examples stayed AST +data; no full current harness, hosted Python, merge or live evidence is claimed. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 872ebee5..10fc7dd2 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3811,6 +3811,12 @@ def test_path_provider_patch_aliases_do_not_retain_source_authority(self) -> Non 'def alter(cls):\n cls.read_text = fake\nalter(Path)\n', 'classes = {"reader": Path}\nclasses["reader"].read_text = fake\n', 'PathAlias, = [Path]\nPathAlias.read_text = fake\n', + 'PathAlias, = {Path}\nPathAlias.read_text = fake\n', + 'classes = {"reader": Path}\nPathAlias = classes.get("reader")\nPathAlias.read_text = fake\n', + 'classes = [Path]\nPathAlias = classes.pop()\nPathAlias.read_text = fake\n', + 'import pathlib as p\nPathAlias = p.Path\nPathAlias.read_text = fake\n', + 'import pathlib\nPathAlias = getattr(pathlib, "Path")\nPathAlias.read_text = fake\n', + 'def reader_class():\n return Path\nPathAlias = reader_class()\nPathAlias.read_text = fake\n', ): with self.subTest(mutation=mutation): self.assertIsNotNone(self.inspect(setup + mutation + suffix)) From 0aac0dfcb4d4fd2c02be2a98db948fac55a3914b Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 15:47:23 +0900 Subject: [PATCH 047/123] fix: follow saved class readers and iterator origins --- docs/evidence/g01-recovery-packet.md | 46 ++++++++++++++++++- .../issue79_regression_test.py | 6 +++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 486a5269..156ca722 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10940,14 +10940,25 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): return any(provider_value_borrows_class(element, seen) for element in value.values) if isinstance(value, ast.IfExp): return any(provider_value_borrows_class(element, seen) for element in (value.body, value.orelse)) + if isinstance(value, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + return any(provider_value_borrows_class(generator.iter, seen) for generator in value.generators) if isinstance(value, ast.Call): return ( isinstance(value.func, ast.Attribute) - and value.func.attr in {"get", "pop", "popitem", "copy", "items", "values", "__getitem__"} + and value.func.attr in {"get", "pop", "popitem", "copy", "items", "values", "__getitem__", "__iter__", "__next__"} and provider_value_borrows_class(value.func.value, seen) ) or ( isinstance(value.func, ast.Name) and value.func.id in {"next", "iter"} and any(provider_value_borrows_class(argument, seen) for argument in value.args) + ) or ( + isinstance(value.func, ast.Name) and id(value.func) not in seen + and any( + provider_value_borrows_class( + ast.copy_location(ast.Call(func=provider_bound_callable, args=value.args, keywords=value.keywords), value), + seen | {id(value.func)}, + ) + for provider_bound_callable in provider_bindings.get(value.func.id, ()) if provider_bound_callable is not None + ) ) or any( provider_value_borrows_class(returned, seen) for returned in python_local_call_return_values(value, tree, class_borrow_parents) @@ -32645,3 +32656,36 @@ The old contract approval and packet PASS apply to `70b29ce`, not this changed source. A new exact-candidate packet result and focused independent delta dispositions remain required before pushing. Unsafe examples stayed AST data; no full current harness, hosted Python, merge or live evidence is claimed. + +Immutable `6e41c63251022fae86f09dd696018576c75a44e2` passed the packet +selector in 53.179s (331 shell commands, 95 Python bodies, zero violations), +but both completed GPT-6-Luna/max focused reviews retained HOLD for saved +container-method aliases. Direct `get`/`pop`, helper return and prior forms +were refused; storing a bound reader as a callable lost its receiver origin. +The security review also accepted a generator/`next` class borrow. The +contract reviewer passed the pinned provider selector in 0.143s and checked +that the `Path.cwd()` instance control did not acquire class mutation taint. +Both reports were processed, released without a process action and acked; +their statements that packet evidence was still pending describe their last +inbox read, not the coordinator's subsequently completed result above. + +The canonical provider method reproduced saved `get`/`pop` in 0.165s +(two failed assertions), then generator-next, comprehension-pop and saved +iterator forms in 0.181s (five failed assertions total). A cyclic saved-reader +alias produced a six-assertion RED group in 0.192s. All specimens remained +AST data. Class borrowing now includes comprehension/iterator origins and +statically expands assigned callable bindings into synthetic call AST nodes, +with the original callable-node identity in the cycle guard. Those synthetic +nodes are inspected, never evaluated or used as certified compiler input. +Ordinary Path construction remains an instance, not a class borrow. + +The eight-method boundary group passed in 5.259s after correction; a final +provider-local callable name disambiguation then passed five focused methods +in 5.210s. Both groups include all 128 actual compiler-helper calls and ten +historical loader controls. The five final selectors are the provider-alias, +deferred-Git, opaque-AST-receiver, actual-helper and historical-loader methods +already named above. The changed candidate requires its own packet selector +and fresh exact-head contract/security delta dispositions. Previous HOLDs +and older packet passes are not approval of this correction. No new push, +GitHub Codex request, merge or live operation occurred; #79 and parent #1 +remain In progress, with G01/G02 production and live gates unchanged. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 10fc7dd2..1483c84c 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3817,6 +3817,12 @@ def test_path_provider_patch_aliases_do_not_retain_source_authority(self) -> Non 'import pathlib as p\nPathAlias = p.Path\nPathAlias.read_text = fake\n', 'import pathlib\nPathAlias = getattr(pathlib, "Path")\nPathAlias.read_text = fake\n', 'def reader_class():\n return Path\nPathAlias = reader_class()\nPathAlias.read_text = fake\n', + 'classes = {"reader": Path}\npick = classes.get\nPathAlias = pick("reader")\nPathAlias.read_text = fake\n', + 'classes = [Path]\npick = classes.pop\nPathAlias = pick()\nPathAlias.read_text = fake\n', + 'PathAlias = next(cls for cls in [Path])\nPathAlias.read_text = fake\n', + 'classes = [cls for cls in [Path]]\nPathAlias = classes.pop()\nPathAlias.read_text = fake\n', + 'classes = [Path]\nwalk = classes.__iter__\nPathAlias = next(walk())\nPathAlias.read_text = fake\n', + 'classes = {"reader": Path}\npick = classes.get\nagain = pick\npick = again\nPathAlias = pick("reader")\nPathAlias.read_text = fake\n', ): with self.subTest(mutation=mutation): self.assertIsNotNone(self.inspect(setup + mutation + suffix)) From 768c53ae9d0cc7aec888890484373929b2166b51 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 15:56:15 +0900 Subject: [PATCH 048/123] docs: reconcile G01 current module directive with source pin --- docs/evidence/g01-recovery-packet.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 156ca722..c770c9e8 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -179,7 +179,7 @@ or exercise the tagged controller driver, and does not close G01. | Boundary | Exact selection | Classification / exposure limit | |---|---|---| | Scale Set SDK | `github.com/actions/scaleset v0.4.0`; tag/source `6ce025902cd964747a078c2aabe7340ebc667eca`; [release](https://github.com/actions/scaleset/releases/tag/v0.4.0), [listener source](https://github.com/actions/scaleset/blob/6ce025902cd964747a078c2aabe7340ebc667eca/listener/listener.go), [module](https://github.com/actions/scaleset/blob/6ce025902cd964747a078c2aabe7340ebc667eca/go.mod) | Source + fixture pin. The module declares Go `1.25.3`; the audited comparison is not the selected release. | -| Go toolchain | Experiment module directive `go 1.26.3`; verification toolchain `GOTOOLCHAIN=go1.26.8` / `go1.26.8`; reviewed `GOEXPERIMENT=none` | Fixture/toolchain evidence only; every rerun records the exact toolchain and experiment setting. | +| Go toolchain | Current experiment module directive `go 1.26.8`; verification toolchain `GOTOOLCHAIN=go1.26.8` / `go1.26.8`; reviewed `GOEXPERIMENT=none` | Fixture/toolchain evidence only; every rerun records the exact toolchain and experiment setting. The historical `go 1.26.3` directive was replaced by `go 1.26.8` at [the #93 merge](https://github.com/1XP-AI/gh-runnerd/commit/94d00b0b07b43c156c0311cfb1f16eb6ca280bb3); older evidence retains its original source pins. | | Go root/FIPS mode | Reviewed `GOROOT` mode is the toolchain default (`GOROOT=""`, identity `goroot-default`); reviewed `GOFIPS140=off` (identity `gofips140-off`) | Fixture/toolchain evidence only; non-empty inherited or command-prefix `GOROOT` and any `GOFIPS140` other than `off` are refused before a guarded Go child. The effective modes are bound in every guarded build identity; raw root paths are never recorded. | | Runner | `v2.337.0`, source `397b032cbf865e9c3ddfab89d533ec19325e1273`; [release](https://github.com/actions/runner/releases/tag/v2.337.0), [command parser](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Listener/CommandSettings.cs), [JIT materialization](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Listener/Runner.cs) | Source pin; no runner binary was downloaded or executed by the contract evidence. | | JIT bootstrap | Select worker-only `ACTIONS_RUNNER_INPUT_JITCONFIG` as parsed by runner `v2.337.0`; [environment parsing](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Listener/CommandSettings.cs) | Source-supported fallback, with no separate transport version. It avoids argv but remains sensitive in initial environment, process memory, container metadata and runner-written files. `--jitconfig` exposes argv; stdin/FD/file transport is not established and is not claimed. | @@ -32689,3 +32689,21 @@ and fresh exact-head contract/security delta dispositions. Previous HOLDs and older packet passes are not approval of this correction. No new push, GitHub Codex request, merge or live operation occurred; #79 and parent #1 remain In progress, with G01/G02 production and live gates unchanged. + +#### Parent pin-metadata reconciliation + +A read-only G01 acceptance audit found that the toolchain table still named +the historical experiment directive `go 1.26.3`, although current +`experiments/g01-scaleset/go.mod` declares `go 1.26.8`. The immutable #93 +merge `94d00b0b07b43c156c0311cfb1f16eb6ca280bb3` records that directive +change and removal of the separate toolchain suggestion. The table now +distinguishes the current directive from the historical record rather than +rewriting old results. SDK `v0.4.0`, runner/JIT pins, verification toolchain, +all Go/scanner/harness sources and executable prescriptions are unchanged. + +This is a prose/source-identity correction, not a new application behavior +test: the authoritative module directive and immutable Git diff establish +the mismatch and correction. No artificial Go RED/GREEN, fresh protocol, +runner or live result is claimed. Whitespace and added-line private-path/ +credential checks remain required; the final candidate's review must include +this metadata delta. Product live authorization/evidence gates remain open. From d618c229197797f7896d6e72c2e5630b0fd30d62 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 16:01:49 +0900 Subject: [PATCH 049/123] fix: preserve Path origins in comprehension results --- docs/evidence/g01-recovery-packet.md | 36 ++++++++++++++++++- .../issue79_regression_test.py | 26 ++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index c770c9e8..8d9fb5f9 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10941,7 +10941,11 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): if isinstance(value, ast.IfExp): return any(provider_value_borrows_class(element, seen) for element in (value.body, value.orelse)) if isinstance(value, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): - return any(provider_value_borrows_class(generator.iter, seen) for generator in value.generators) + provider_result_values = (value.key, value.value) if isinstance(value, ast.DictComp) else (value.elt,) + return any( + provider_value_borrows_class(provider_result_value, seen) + for provider_result_value in provider_result_values + ) or any(provider_value_borrows_class(generator.iter, seen) for generator in value.generators) if isinstance(value, ast.Call): return ( isinstance(value.func, ast.Attribute) @@ -32707,3 +32711,33 @@ the mismatch and correction. No artificial Go RED/GREEN, fresh protocol, runner or live result is claimed. Whitespace and added-line private-path/ credential checks remain required; the final candidate's review must include this metadata delta. Product live authorization/evidence gates remain open. + +#### Comprehension result-origin correction + +Both independent GPT-6-Luna/max Codex reviews completed against immutable +`0aac0dfcb4d4fd2c02be2a98db948fac55a3914b`. One approved the saved-reader, +iterator and alias-cycle delta after inert negative probes and a positive +helper-selection control. The other retained HOLD P1: class borrowing followed +comprehension iterables but omitted their returned expressions. Consequently, +`next(Path for _ in [0])` and `[Path for _ in [0]].pop()` could patch the +reader class without invalidating packet-source authority. Both reports were +processed, released without a process action and acknowledged. Neither report +reviewed the later module-directive metadata correction. + +The new focused result-origin regression was RED: one method in 0.159s with +six failed assertions covering generator, list, set, dictionary value, +dictionary key and conditional yielded expressions. Three instance/scalar +controls did not acquire class mutation taint. All programs stayed inert AST +data. The minimal fix follows comprehension result expressions (both key and +value for dictionaries), in addition to the existing iterable origins. It does +not add compiler-input authority, dependencies or historical source revisions. + +The nine-method focused boundary group passed in 5.438s, including all 128 +actual packet compiler-helper calls and ten historical loader controls. The +command is the eight-selector boundary group recorded above, prefixed with +`Issue79RegressionTests.test_path_provider_comprehension_result_origins_invalidate_authority`. +Whitespace and added-line private-path/credential checks are required for the +batch. An exact-candidate packet selector and new independent dispositions +covering both this result-origin fix and the pin metadata remain required before +a stable push. Old packet passes and the prior scoped approval are not approval +of this changed candidate. No live experiment or production Go change occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 1483c84c..ed7897d4 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3827,6 +3827,32 @@ def test_path_provider_patch_aliases_do_not_retain_source_authority(self) -> Non with self.subTest(mutation=mutation): self.assertIsNotNone(self.inspect(setup + mutation + suffix)) + def test_path_provider_comprehension_result_origins_invalidate_authority(self) -> None: + setup = 'import os\nfrom pathlib import Path\ndef fake(self):\n return "print(os.environ)"\n' + suffix = ( + 'PathAlias.read_text = fake\n' + 'wrapper = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n' + ) + for borrow in ( + 'PathAlias = next(Path for _ in [0])\n', + 'classes = [Path for _ in [0]]\nPathAlias = classes.pop()\n', + 'classes = {Path for _ in [0]}\nPathAlias = classes.pop()\n', + 'classes = {"reader": Path for _ in [0]}\nPathAlias = classes.get("reader")\n', + 'classes = {Path: "reader" for _ in [0]}\nPathAlias, _ = classes.popitem()\n', + 'PathAlias = next((Path if flag else None) for flag in [True])\n', + ): + with self.subTest(borrow=borrow): + self.assertIsNotNone(self.inspect(setup + borrow + suffix)) + for control in ( + 'PathAlias = next(Path("reviewed") for _ in [0])\n', + 'classes = [Path("reviewed") for _ in [0]]\nPathAlias = classes.pop()\n', + 'classes = {"reader": 0 for _ in [0]}\nPathAlias = classes.get("reader")\n', + ): + with self.subTest(control=control): + tree = ast.parse(setup + control + 'PathAlias.read_text = fake\n') + self.assertFalse(self.scanner["python_compile_primitive_is_shadowed"]("Path", tree)) + def test_actual_packet_compile_helpers_retain_provenance(self) -> None: checked = 0 for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): From 29ca0d57d9dc9d16020e86c605cf3e1dd195ab3d Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 16:25:51 +0900 Subject: [PATCH 050/123] fix: constrain packet extraction and fence historical Git sources --- docs/evidence/g01-recovery-packet.md | 285 ++++++++++++++---- .../issue79_regression_test.py | 41 ++- 2 files changed, 264 insertions(+), 62 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 8d9fb5f9..285d5dae 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -4740,7 +4740,7 @@ from pathlib import Path prior_head = "22a2923033c875ddd4f755774f79f60b94649449" previous = subprocess.check_output( - ["git", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) wrapper_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -4896,7 +4896,7 @@ from pathlib import Path prior_head = "22a2923033c875ddd4f755774f79f60b94649449" previous = subprocess.check_output( - ["git", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) wrapper_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -5197,6 +5197,7 @@ starting_head = "3bc8445567fe68cc355cf3f88f0c962a41e9cad5" previous = subprocess.check_output( [ "git", + "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], @@ -5327,6 +5328,7 @@ starting_head = "3bc8445567fe68cc355cf3f88f0c962a41e9cad5" previous = subprocess.check_output( [ "git", + "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], @@ -5449,6 +5451,7 @@ starting_head = "3bc8445567fe68cc355cf3f88f0c962a41e9cad5" previous = subprocess.check_output( [ "git", + "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], @@ -7013,7 +7016,7 @@ from tempfile import TemporaryDirectory prior_head = "22a2923033c875ddd4f755774f79f60b94649449" previous = subprocess.check_output( - ["git", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) wrapper_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -7077,7 +7080,7 @@ namespace = {"label": "synthetic-init-boundary"} exec(compile(wrapper[helper_start:helper_end], "", "exec"), namespace) prior_head = "22a2923033c875ddd4f755774f79f60b94649449" previous = subprocess.check_output( - ["git", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) prior_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -10940,6 +10943,8 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): return any(provider_value_borrows_class(element, seen) for element in value.values) if isinstance(value, ast.IfExp): return any(provider_value_borrows_class(element, seen) for element in (value.body, value.orelse)) + if isinstance(value, ast.BoolOp): + return any(provider_value_borrows_class(element, seen) for element in value.values) if isinstance(value, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): provider_result_values = (value.key, value.value) if isinstance(value, ast.DictComp) else (value.elt,) return any( @@ -11076,6 +11081,132 @@ def python_compile_primitive_is_shadowed(name, tree, provider_call=None): return False +def reviewed_python_source_slice(node, source_names, tree): + """Certify only the packet's closed extraction recipes, never arbitrary text.""" + if not ( + tree is not None and isinstance(node, ast.Subscript) + and isinstance(node.value, ast.Name) and node.value.id in source_names + and isinstance(node.slice, ast.Slice) and node.slice.step is None + ): + return False + slice_context = getattr(tree, "_issue79_source_slice_context", None) + if slice_context is None: + slice_parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + slice_definitions = {} + for slice_statement in ast.walk(tree): + if isinstance(slice_statement, ast.Assign) and len(slice_statement.targets) == 1 and isinstance(slice_statement.targets[0], ast.Name): + slice_definitions.setdefault(slice_statement.targets[0].id, []).append(slice_statement) + slice_context = (slice_parents, slice_definitions, {}) + tree._issue79_source_slice_context = slice_context + slice_parents, slice_definitions, slice_recipe_results = slice_context + # Only the structural recipe is cached. Base provenance is checked above + # on every call, including after source-name invalidation. + if node in slice_recipe_results: + return slice_recipe_results[node] + + def slice_point(value, seen=None): + if value is None: + return (None, 0) + seen = seen or set() + if id(value) in seen: + return None + seen = seen | {id(value)} + if isinstance(value, ast.Name): + slice_scopes = python_lexical_scope_chain(python_enclosing_scope(value, slice_parents), slice_parents) + for slice_scope in slice_scopes: + candidates = [ + statement for statement in slice_definitions.get(value.id, ()) + if python_enclosing_scope(statement, slice_parents) is slice_scope + and statement.lineno < value.lineno + ] + if not candidates: + continue + latest = max(candidates, key=lambda statement: statement.lineno) + if not isinstance(slice_parents.get(latest), (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef)): + return None + if any( + isinstance(part, (ast.Name, ast.arg)) + and (part.id if isinstance(part, ast.Name) else part.arg) == value.id + and (not isinstance(part, ast.Name) or isinstance(part.ctx, ast.Store)) + and python_enclosing_scope(part, slice_parents) is slice_scope + and latest.lineno < getattr(part, "lineno", 0) <= value.lineno + for part in ast.walk(tree) + ): + return None + return slice_point(latest.value, seen) + return None + if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add): + left = slice_point(value.left, seen) + if left is None or left[1] != 0: + return None + if isinstance(value.right, ast.Constant) and value.right.value == 1 and not isinstance(value.right.value, bool): + return (left[0], 1) + if ( + isinstance(value.right, ast.Call) and isinstance(value.right.func, ast.Name) + and value.right.func.id == "len" and not value.right.keywords and len(value.right.args) == 1 + and isinstance(value.right.args[0], ast.Constant) and value.right.args[0].value == left[0] + and not python_compile_primitive_is_shadowed("len", tree) + ): + return (left[0], "length") + return None + if not ( + isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute) + and value.func.attr in {"index", "find", "rfind"} and not value.keywords + and ast.dump(value.func.value) == ast.dump(node.value) and 1 <= len(value.args) <= 3 + and isinstance(value.args[0], ast.Constant) and isinstance(value.args[0].value, str) + ): + return None + for anchor_value in value.args[1:]: + if isinstance(anchor_value, ast.Constant) and anchor_value.value == 0 and not isinstance(anchor_value.value, bool): + continue + anchor_point = slice_point(anchor_value, seen) + if anchor_point is None or anchor_point[0] not in { + "go_test_checked()", "def forbidden_command(tokens, depth=0):", "def python_heredoc_invocation(stripped):", + "def executable_shell_commands", "def executable_shell_commands(markdown):", "def git_query", + "The packet also runs a command-line scan over fenced", '\nfence_languages = {"sh", "bash", "shell", "zsh"}', + 'fence_languages = {"sh", "bash", "shell", "zsh"}', "\nimport hashlib\n", + "def skip_source_ignored", "def source_fuzz_declarations", "def go_compatible_regexp", + "def reject_python_semantic_regexp_constructs", "def validate_test_stream(stdout, stderr):", + "def git_source_control_entries(repo_root, module_dir, env):", + "def git_worktree_matches_pinned_blobs(repo_root, module_dir, env):", + "git_transport_override_names = ", 'source = Path(', + 'source = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")', + "/opt/homebrew/bin/python3 -I - <<'PY'\n", + }: + return None + return (value.args[0].value, 0) + + lower, upper = slice_point(node.slice.lower), slice_point(node.slice.upper) + if lower is None or upper is None: + return False + recipes = { + (("def validate_test_stream(stdout, stderr):", 0), ('\nrun_command = command + ["-json"]', 0)), + (("\nimport hashlib\n", 1), ("\nPY\n}", 0)), + (("def skip_source_ignored", 0), ("\nall_test_names = []", 0)), + (("def source_fuzz_declarations", 0), ("\nsource_fuzz_guard()", 0)), + (("def go_compatible_regexp", 0), ("\nall_test_names = []", 0)), + (("def reject_python_semantic_regexp_constructs", 0), ("\nsource_fuzz_guard()", 0)), + (("def executable_shell_commands", 0), ("\nguarded, fixtures =", 0)), + (("def git_source_control_entries(repo_root, module_dir, env):", 0), ("\ndef package_initialization_guard", 0)), + (("def git_worktree_matches_pinned_blobs(repo_root, module_dir, env):", 0), ("\ndef package_initialization_guard", 0)), + (("git_transport_override_names = ", 0), ("git_environment = {", 0)), + (("/opt/homebrew/bin/python3 -I - <<'PY'\n", "length"), ("\nPY\n```", 0)), + } + recipes.update(((marker, 0), ("\nmatches = []", 0)) for marker in { + 'source = Path(', 'source = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")', + 'fence_languages = {"sh", "bash", "shell", "zsh"}', + }) + recipes.update(((marker, 0), ("\nguarded, fixtures =", 0)) for marker in { + 'source = Path(', 'source = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")', + }) + recipes.update(((None, 0), (marker, 0)) for marker in { + "run_result = run_go_child", "compiler_tool_environment_names =", "repo_root = Path(", + }) + recipes.add(((None, 0), ("\ngo_env = dict(env)", "length"))) + slice_recipe_results[node] = (lower, upper) in recipes + return slice_recipe_results[node] + + def python_compile_provenance(tree): """Resolve source/AST provenance before permitting static compile/exec.""" cached = getattr(tree, "_issue79_compile_provenance", None) @@ -11132,7 +11263,7 @@ def python_compile_provenance(tree): if isinstance(node, ast.Name): return node.id in source_names if isinstance(node, ast.Subscript): - return source_value(node.value) + return reviewed_python_source_slice(node, source_names, tree) 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): @@ -11166,10 +11297,10 @@ def python_compile_provenance(tree): if python_compile_primitive_is_shadowed("subprocess", tree, node) or not node.args: return False command = node.args[0] - if not isinstance(command, (ast.List, ast.Tuple)) or len(command.elts) not in {3, 4}: + if not isinstance(command, (ast.List, ast.Tuple)) or len(command.elts) not in {4, 5}: return False prefix = [part.value if isinstance(part, ast.Constant) else None for part in command.elts[:-1]] - if prefix not in (["git", "show"], ["git", "-P", "show"]): + if prefix not in (["git", "--no-replace-objects", "show"], ["git", "--no-replace-objects", "-P", "show"]): return False def literal_reference(value, seen=None): seen = seen or set() @@ -11530,7 +11661,7 @@ def python_compile_provenance(tree): return tree._issue79_compile_provenance -def reviewed_python_compile_source(node, provenance=None): +def reviewed_python_compile_source(node, provenance=None, tree=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): @@ -11542,20 +11673,10 @@ def reviewed_python_compile_source(node, provenance=None): 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) - ) + return reviewed_python_source_slice(node, source_provenance, tree) 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 reviewed_python_compile_source(node.left, provenance, tree) and reviewed_python_compile_source( + node.right, provenance, tree ) if not ( isinstance(node, ast.Call) @@ -11606,7 +11727,7 @@ def reviewed_python_exec_call(call, safe_marker, tree=None): 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): + if not reviewed_python_compile_source(compiler.args[0], provenance, tree): return False filename, mode = compiler.args[1:3] return ( @@ -13246,7 +13367,7 @@ def reviewed_python_dynamic_git_call(argument, tree=None, parents=None): while ( subcommand_index < len(argument.elts) and isinstance(argument.elts[subcommand_index], ast.Constant) - and argument.elts[subcommand_index].value in {"-P", "--no-pager"} + and argument.elts[subcommand_index].value in {"-P", "--no-pager", "--no-replace-objects"} ): subcommand_index += 1 if subcommand_index >= len(argument.elts): @@ -18037,6 +18158,7 @@ starting_head = "423d4fc501120a014e63f77d3ef6652606d0326a" previous = subprocess.check_output( [ "git", + "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], @@ -18159,6 +18281,7 @@ starting_head = "423d4fc501120a014e63f77d3ef6652606d0326a" previous = subprocess.check_output( [ "git", + "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], @@ -18460,6 +18583,7 @@ starting_head = "01764bbed0a387129d2a2abbc9e27a87e073f87e" previous = subprocess.check_output( [ "git", + "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], @@ -18696,7 +18820,7 @@ from contextlib import redirect_stdout starting_head = "4bd66186ea8d980a06ed8a4adf7f51e76b5028ef" previous = subprocess.check_output( - ["git", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -18802,7 +18926,7 @@ import subprocess starting_head = "4bd66186ea8d980a06ed8a4adf7f51e76b5028ef" previous = subprocess.check_output( - ["git", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_start = previous.index('fence_languages = {"sh", "bash", "shell", "zsh"}') @@ -18871,7 +18995,7 @@ import subprocess starting_head = "4bd66186ea8d980a06ed8a4adf7f51e76b5028ef" previous = subprocess.check_output( - ["git", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) old_selector = re.compile( @@ -18947,7 +19071,7 @@ from pathlib import Path starting_head = "943ebece04882a0faf055d73e5988bd8954088f8" previous = subprocess.check_output( - ["git", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -19357,7 +19481,7 @@ from pathlib import Path starting_head = "d85f99fa70a6f563079b1ed4f29a1bc97740a3c5" packet_path = "docs/evidence/g01-recovery-packet.md" previous = subprocess.check_output( - ["git", "show", f"{starting_head}:{packet_path}"], text=True + ["git", "--no-replace-objects", "show", f"{starting_head}:{packet_path}"], text=True ) wrapper_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 wrapper_end = previous.index("\nPY\n}", wrapper_start) @@ -19708,7 +19832,7 @@ from pathlib import Path parent = "da1af0d041e37e5df9f3ed8028b51a69ec58ed8c" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -20099,7 +20223,7 @@ from pathlib import Path parent = "5297b3c3b05afedf97723b7b58806cdd5519a2b6" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -20869,7 +20993,7 @@ import sys parent = "81787b2e90df496a9c5a51fddc7607d3019834b7" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) lines = packet.splitlines() @@ -21281,7 +21405,7 @@ import tempfile from pathlib import Path parent = "6c55f5b67035fb1c7ac334984499cfe80d6bb86b" -packet = subprocess.check_output(["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) status_start = packet.index(' [\n "git",\n "status",') status_end = packet.index(' ],', status_start) + len(' ],') status_argv = packet[status_start:status_end] @@ -21319,7 +21443,7 @@ import subprocess from pathlib import Path parent = "6c55f5b67035fb1c7ac334984499cfe80d6bb86b" -packet = subprocess.check_output(["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") scanner_start = packet.rfind("source = Path(", 0, scanner_anchor) scanner_end = packet.index("\nmatches = []", scanner_anchor) @@ -21346,7 +21470,7 @@ import subprocess import sys parent = "6c55f5b67035fb1c7ac334984499cfe80d6bb86b" -packet = subprocess.check_output(["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) wrapper_start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 wrapper_end = packet.index("\nPY\n}", wrapper_start) wrapper = packet[wrapper_start:wrapper_end] @@ -21584,7 +21708,7 @@ from pathlib import Path parent = "13b463086a0e7aa710067cafb44dcd9aed118654" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -21787,7 +21911,7 @@ from pathlib import Path parent = "5f42598b94ce5339c35f55be42eb108973850d24" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True ) heredoc_pattern = re.compile( r"\\bpython3\\s+-I\\b[^\\n]*<<-?\\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\\1" @@ -22042,7 +22166,7 @@ from contextlib import redirect_stdout from pathlib import Path parent = "055a05bd9d5a9bb101e4400dd7a9236e3afd9f48" -packet = subprocess.check_output(["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") scanner_start = packet.rfind("source = Path(", 0, scanner_anchor) scanner_end = packet.index("\nmatches = []", scanner_anchor) @@ -22545,7 +22669,7 @@ import sys parent = "4ee7c855b1e79f9478e5cf73c270731dfbf58cf7" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 @@ -23038,7 +23162,7 @@ from pathlib import Path parent = "6af854fb660bc7d9c31c9920a6cec5c2fbb1966d" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 @@ -23414,7 +23538,7 @@ from pathlib import Path parent = "3f6de0b227e4b44aa3d5e259e937e7dc1f0856bb" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -23807,7 +23931,7 @@ from pathlib import Path parent = "7d91bed688dbea21bea7dff62f41d48d1d57ce4b" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -24130,7 +24254,7 @@ from pathlib import Path parent = "71a7a599de567914158b05e3c480f7e0d48c709f" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -24398,7 +24522,7 @@ from pathlib import Path parent = "8dfa9a031bc321f2ccc208104268f7c5ead9281b" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -24784,7 +24908,7 @@ from pathlib import Path parent = "2abe394ee32f09888892c4adea5fc08121845d6b" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -25225,7 +25349,7 @@ from pathlib import Path parent = "14f998f32710f122f5861edfac8fdbc89ef95bfb" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -25606,7 +25730,7 @@ from pathlib import Path parent = "ce417347aa100562722477ea1126a5cf6372ec3a" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) wrapper_start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 @@ -25702,7 +25826,7 @@ from pathlib import Path parent_sha = "21f258a4215c667c245abb44ea419eb7901de2ad" parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") @@ -26144,7 +26268,7 @@ parent_sha = "755968f9b4343c860cd8ddeca12a97b277c6e1b5" packet_path = "docs/evidence/g01-recovery-packet.md" packet = Path(packet_path).read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:{packet_path}"], text=True + ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(text): @@ -26717,7 +26841,7 @@ from pathlib import Path parent = "1fc2cede68f2692de0fad7a18d6faf7810544845" packet = subprocess.check_output( - ["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -27144,7 +27268,7 @@ def inspected(body, safe_marker=True): # Reproduce every RED against the immutable parent scanner loaded only by git show. parent_sha = "b85839cc801395f4ec9560b056a2a6706c7aa306" parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) parent_anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") @@ -27375,7 +27499,7 @@ def load_scanner(text, label): current = load_scanner(packet, "current-scanner-401034") parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) parent = load_scanner(parent_packet, "exact-parent-3c4eb91-scanner") @@ -27660,7 +27784,7 @@ from pathlib import Path parent_sha = "390c89b5e431a165dfbf8fa986cbf2594e3444ce" current_packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -27895,7 +28019,7 @@ from pathlib import Path parent_sha = "c9f986d0256e47aba7fd273c1ae03993193a39c8" packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -28144,7 +28268,7 @@ from pathlib import Path parent_sha = "f84113bf38dd77dacb5dd3ea9b6018c2f2d06471" packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -28455,7 +28579,7 @@ from pathlib import Path parent_sha = "393d029975139b9d28900d477f62e8de392ace96" packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -28678,7 +28802,7 @@ parent_sha = "b16a349509535d6dcb179c9c0bd7a6a313c48bcd" packet_path = "docs/evidence/g01-recovery-packet.md" packet = Path(packet_path).read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:{packet_path}"], text=True + ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(text): @@ -28893,7 +29017,7 @@ from pathlib import Path parent_sha = "8958ec9a5c1e8de6c43d29e389a706f1c9ba75dd" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:{packet_path}"], text=True + ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True ) anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") start = parent_packet.rfind("source = Path(", 0, anchor) @@ -29219,7 +29343,7 @@ from pathlib import Path parent_sha = "518f23c3c875bfda8c8c65239e5171d23c444fc6" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:{packet_path}"], text=True + ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True ) anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") start = parent_packet.rfind("source = Path(", 0, anchor) @@ -29575,7 +29699,7 @@ from pathlib import Path parent_sha = "2d6a1f700e2fb3aa918f2166e4dd4611601b10be" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "show", f"{parent_sha}:{packet_path}"], text=True + ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(packet, label): @@ -29942,7 +30066,7 @@ from pathlib import Path parent_sha = "f44a4871f87c1a8165593549d58ece6ffee61bf2" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "-P", "show", f"{parent_sha}:{packet_path}"], text=True + ["git", "--no-replace-objects", "-P", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(packet, label): @@ -32741,3 +32865,44 @@ batch. An exact-candidate packet selector and new independent dispositions covering both this result-origin fix and the pin metadata remain required before a stable push. Old packet passes and the prior scoped approval are not approval of this changed candidate. No live experiment or production Go change occurred. + +#### GitHub exact-head review at d618c229: three P1 corrections + +Both independent GPT-6-Luna/max delta reviews APPROVED immutable +`d618c229197797f7896d6e72c2e5630b0fd30d62`, including the pin metadata; +the contract reviewer passed four pinned methods in 5.434s and the security +review refused six mutation witnesses plus a nested-generator borrow with +three untainted instance/scalar controls. Both reports were processed, +released without a process action and acknowledged. The stable candidate was +pushed once. Hosted PR quick run `37105263575` passed in 36s at that SHA; +its scope skipped changed-tooling/workflow regressions and ran no Python +harness. These checks did not approve a merge: fresh GitHub Codex review +`5399495944` completed at that head and reported three P1 findings. + +| Exact-head finding | Inert reproduction and local correction | +|---|---| +| [4172117980](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4172117980), Boolean provider origin | Generator `Path or None` and direct `True and Path` results lost class origins. Borrowing now follows Boolean operands, without evaluating them. | +| [4172117984](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4172117984), replacement refs in compiler sources | Both unfenced `git show` prefixes acquired trusted source provenance from an allowlisted textual SHA. Compiler-source reads now require literal `--no-replace-objects`, optionally followed by `-P`. Current executable historical-reader prescriptions use that flag; historical recorded output/results are unchanged. The existing dynamic Git decoder recognizes only this additional no-effect global safety flag. | +| [4172117986](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4172117986), arbitrary packet slicing | Assigned arithmetic slices, named-bound slices and direct compile slices could extract a `print(os.environ)` specimen. Source assignment and direct compiler checks now share closed extraction recipes with lexical bound resolution, same-source literal markers, bounded offsets and no slice step. Unknown extraction cannot acquire compiler authority merely from packet origin or an approved destination name. | + +The three new methods were meaningfully RED in 0.157s with seven failed +assertions; all examples remained AST data. Boolean plus comprehension controls +passed two methods in 0.139s. Git-prefix, deferred-provider and unlisted-source +controls passed three methods in 0.150s; both suppressed-prefix positive controls +were accepted. Initial slice/prescription integration failed 58 assertions +(five methods, 5.091s), then 15 (two methods, 5.811s), then five (two methods, +5.761s): missing command rewrites, the new dynamic global-option shape and one +existing validator recipe were corrected, not relabeled as prior passes. + +The eight-method focused group then passed in 5.841s, preserving all 128 actual +compiler-helper calls and ten historical loaders. Its selectors are the three +new Boolean-provider, replacement-suppression and arbitrary-slice methods; +the existing comprehension-result, provider-alias, deferred-Git, actual-helper +and historical-loader methods. A subsequent refactor shares immutable-AST +slice indexes and caches only structural recipes; every use still rechecks +current base-source provenance. Post-refactor the same eight methods passed +in 5.468s, again preserving 128 actual helpers and ten loaders. Exact-candidate +packet scan and independent review remain required. Old d618c229 internal +approvals/hosted success do not approve these corrections. No new correction +push, merge, full-current harness or product live test is claimed. #79 and +parent #1 remain In progress with G01/G02/live gates unchanged. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index ed7897d4..394f6604 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3735,7 +3735,7 @@ def test_augmented_assignment_and_forged_source_providers_invalidate_compile_pro 'import os\nfrom pathlib import Path\npacket_path = "docs/evidence/g01-recovery-packet.md"\n' 'for packet_path in ("outside",):\n pass\nwrapper = Path(packet_path).read_text()\n' 'exec(compile(wrapper, "", "exec"), {"os": os})\n', - 'import os, subprocess\nwrapper = subprocess.check_output(["git", "show", "' + 'import os, subprocess\nwrapper = subprocess.check_output(["git", "--no-replace-objects", "show", "' + 'f' * 40 + ':docs/evidence/g01-recovery-packet.md"], text=True)\n' 'exec(compile(wrapper, "", "exec"), {"os": os})\n', 'import os\nfrom pathlib import Path\ndef fake(self):\n return "print(os.environ)"\n' @@ -3780,7 +3780,7 @@ def test_deferred_git_provider_does_not_ignore_later_monkeypatch(self) -> None: specimen = ( 'import os, subprocess\n' 'def run_probe():\n' - ' wrapper = subprocess.check_output(["git", "show", ' + ' wrapper = subprocess.check_output(["git", "--no-replace-objects", "show", ' '"01764bbed0a387129d2a2abbc9e27a87e073f87e:docs/evidence/g01-recovery-packet.md"], text=True)\n' ' exec(compile(wrapper, "", "exec"), {"os": os})\n' 'def fake(*args, **kwargs):\n return "print(os.environ)"\n' @@ -3853,6 +3853,43 @@ def test_path_provider_comprehension_result_origins_invalidate_authority(self) - tree = ast.parse(setup + control + 'PathAlias.read_text = fake\n') self.assertFalse(self.scanner["python_compile_primitive_is_shadowed"]("Path", tree)) + def test_boolean_path_provider_results_cannot_patch_trusted_reader(self) -> None: + setup = 'import os\nfrom pathlib import Path\ndef fake(self):\n return "print(os.environ)"\n' + for borrow in ( + 'PathAlias = next((Path or None) for _ in [0])\n', + 'PathAlias = True and Path\n', + ): + with self.subTest(borrow=borrow): + self.assertIsNotNone(self.inspect(setup + borrow + + 'PathAlias.read_text = fake\n' + 'wrapper = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n')) + + def test_historical_compile_sources_require_replacement_suppression(self) -> None: + reference = '01764bbed0a387129d2a2abbc9e27a87e073f87e:docs/evidence/g01-recovery-packet.md' + for prefix in ('["git", "show", ', '["git", "-P", "show", '): + with self.subTest(prefix=prefix): + body = 'import os, subprocess\nwrapper = subprocess.check_output(' + prefix + repr(reference) + '], text=True)\n' + self.assertIsNotNone(self.inspect(body + + 'exec(compile(wrapper, "", "exec"), {"os": os})\n')) + for prefix in ('["git", "--no-replace-objects", "show", ', '["git", "--no-replace-objects", "-P", "show", '): + with self.subTest(safe_prefix=prefix): + body = 'import os, subprocess\nwrapper = subprocess.check_output(' + prefix + repr(reference) + '], text=True)\n' + self.assertIsNone(self.inspect(body + + 'exec(compile(wrapper, "", "exec"), {"os": os})\n')) + + def test_arbitrary_packet_slices_do_not_acquire_compile_authority(self) -> None: + setup = 'import os\nfrom pathlib import Path\npacket = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + for extraction in ( + 'needle = "print(os.environ)"\nstart = packet.index(needle)\nwrapper = packet[start:start + len(needle)]\n', + 'start = packet.index("print(os.environ)")\nend = start + 17\nwrapper = packet[start:end]\n', + 'start = packet.index("print(os.environ)")\nend = start + 17\n', + ): + with self.subTest(extraction=extraction): + argument = 'packet[start:end]' if 'wrapper =' not in extraction else 'wrapper' + self.assertIsNotNone(self.inspect(setup + extraction + + 'exec(compile(' + argument + ', "", "exec"), {"os": os})\n')) + def test_actual_packet_compile_helpers_retain_provenance(self) -> None: checked = 0 for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): From fe38cc195617ee1d71e3704e7ff6b3e7e2880ed7 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 16:57:45 +0900 Subject: [PATCH 051/123] fix: preserve lexical packet bounds and ordered rebindings --- docs/evidence/g01-recovery-packet.md | 86 ++++++++++++++++++- .../issue79_regression_test.py | 33 +++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 285d5dae..1c30e6da 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -11104,6 +11104,9 @@ def reviewed_python_source_slice(node, source_names, tree): if node in slice_recipe_results: return slice_recipe_results[node] + def slice_position(value, end=False): + return (value.end_lineno, value.end_col_offset) if end else (value.lineno, value.col_offset) + def slice_point(value, seen=None): if value is None: return (None, 0) @@ -11117,11 +11120,29 @@ def reviewed_python_source_slice(node, source_names, tree): candidates = [ statement for statement in slice_definitions.get(value.id, ()) if python_enclosing_scope(statement, slice_parents) is slice_scope - and statement.lineno < value.lineno + and slice_position(statement, end=True) < slice_position(value) ] if not candidates: + # Local binders must not borrow same-spelled outer bounds. + if any( + isinstance(part, (ast.Name, ast.arg)) + and (part.id if isinstance(part, ast.Name) else part.arg) == value.id + and (not isinstance(part, ast.Name) or isinstance(part.ctx, ast.Store)) + and python_enclosing_scope(part, slice_parents) is slice_scope + for part in ast.walk(tree) + ): + return None continue - latest = max(candidates, key=lambda statement: statement.lineno) + latest = max(candidates, key=lambda statement: slice_position(statement, end=True)) + if isinstance(slice_scope, ast.Module) and python_enclosing_scope(value, slice_parents) is not slice_scope: + # A deferred outer capture needs one immutable assignment; + # definition-line ordering cannot exclude later rebindings. + if sum( + isinstance(part, ast.Name) and isinstance(part.ctx, ast.Store) and part.id == value.id + and python_enclosing_scope(part, slice_parents) is slice_scope + for part in ast.walk(tree) + ) != 1: + return None if not isinstance(slice_parents.get(latest), (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef)): return None if any( @@ -11129,7 +11150,7 @@ def reviewed_python_source_slice(node, source_names, tree): and (part.id if isinstance(part, ast.Name) else part.arg) == value.id and (not isinstance(part, ast.Name) or isinstance(part.ctx, ast.Store)) and python_enclosing_scope(part, slice_parents) is slice_scope - and latest.lineno < getattr(part, "lineno", 0) <= value.lineno + and slice_position(latest, end=True) < slice_position(part) <= slice_position(value) for part in ast.walk(tree) ): return None @@ -11673,7 +11694,10 @@ def reviewed_python_compile_source(node, provenance=None, tree=None): and node.id in ast_provenance ) if isinstance(node, ast.Subscript): - return reviewed_python_source_slice(node, source_provenance, tree) + return ( + isinstance(node.value, ast.Name) and node.value.id in reviewed_python_compile_slice_bases + and reviewed_python_source_slice(node, source_provenance, tree) + ) if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): return reviewed_python_compile_source(node.left, provenance, tree) and reviewed_python_compile_source( node.right, provenance, tree @@ -32906,3 +32930,57 @@ packet scan and independent review remain required. Old d618c229 internal approvals/hosted success do not approve these corrections. No new correction push, merge, full-current harness or product live test is claimed. #79 and parent #1 remain In progress with G01/G02/live gates unchanged. + +#### Extraction-bound follow-up: lexical parameters and same-line rebinding + +The exact `29ca0d57d9dc9d16020e86c605cf3e1dd195ab3d` packet selector +passed in 40.273s, inspecting 331 shell commands and 95 Python bodies with +zero reported violations. Its independent security review nevertheless retained +HOLD P1: function parameters could borrow same-spelled global extraction bounds, +and two semicolon-separated assignments on one line selected the earlier safe +recipe despite later arbitrary bounds. Both witnesses stayed inert AST data; +the latter's extracted 17-byte text was parsed, never executed. The completed +security report was processed, released without a process action and acknowledged. +The contract review also retained HOLD after four pinned methods passed in +4.221s, including 128 helper checks: its same-line witness selected a different +311-node parseable slice. Later-line rebinding and same-line source invalidation +controls were refused. Repeated-marker probes selected parseable seven- and +263-node slices, but a bounded direct-sink check found no compiler, process, +network or file-write sink; these probes establish no additional confirmed P1. +Those routine hardening observations are triaged once without a fix or follow-up +issue, not promoted to universal safety proof. The reviewer confirmed the Go +1.26.8 metadata, made no edits and used pinned source only. Its report was +processed, released without a process action and acknowledged. + +The parameter-shadow regression was meaningfully RED (one failed assertion, +0.127s). Local binders now prevent fallback to outer bounds, and a deferred +module capture requires one assignment rather than definition-line ordering. +An initial blanket outer-capture refusal broke one existing positive control +(nine methods, 5.542s); refining it preserved legitimate immutable prefixes +(three methods PASS in 5.394s; nine methods PASS in 5.536s). + +A separate compatibility invariant was RED (one failed assertion, 0.127s): +direct compiler slices must retain the pre-existing base-name allowlist as well +as satisfy the new extraction recipe. Restoring that check passed four focused +methods in 5.449s. The security review's same-line witness then produced its +own canonical RED (one failed assertion, 0.126s). Bound selection now compares +statement end and read start line/column positions, choosing the latest fully +evaluated assignment and observing intervening binders. Five focused methods +passed in 5.455s; the combined eleven-method group passed in 5.528s, preserving +all 128 actual compiler-helper checks and ten historical loader controls. + +The eleven selectors are the preceding eight-method boundary group plus +`test_packet_slice_bounds_do_not_fall_back_across_parameter_shadows`, +`test_direct_packet_slice_retains_existing_compile_base_allowlist` and +`test_same_line_packet_bound_reassignments_invalidate_slice_recipe`, each under +`Issue79RegressionTests` in the isolated regression harness. No specimen was +executed, no source revision or compiler-input name was added, and no full +current harness, production protocol or live result is claimed. The corrected +candidate needs its own packet result and fresh exact-source delta dispositions; +the earlier packet success does not clear either review finding. No correction +push or merge has occurred. #79 and parent #1 remain In progress. + +Before this ledger entry was appended, the corrected writer-source packet +selector passed in 40.680s: 331 shell commands, 95 Python bodies and zero +reported violations. This is an intermediate writer checkpoint, not approval +of an immutable final head or a substitute for either independent disposition. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 394f6604..3c138988 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3890,6 +3890,39 @@ def test_arbitrary_packet_slices_do_not_acquire_compile_authority(self) -> None: self.assertIsNotNone(self.inspect(setup + extraction + 'exec(compile(' + argument + ', "", "exec"), {"os": os})\n')) + def test_packet_slice_bounds_do_not_fall_back_across_parameter_shadows(self) -> None: + specimen = ( + 'import os\nfrom pathlib import Path\n' + 'packet = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'start = packet.index("\\nimport hashlib\\n", packet.index("go_test_checked()")) + 1\n' + 'end = packet.index("\\nPY\\n}", start)\n' + 'def execute(start, end):\n' + ' exec(compile(packet[start:end], "", "exec"), {"os": os})\n' + 'needle = "print(os.environ)"\nbad_start = packet.index(needle)\n' + 'execute(bad_start, bad_start + len(needle))\n' + ) + self.assertIsNotNone(self.inspect(specimen)) + + def test_direct_packet_slice_retains_existing_compile_base_allowlist(self) -> None: + specimen = ( + 'import os\nfrom pathlib import Path\n' + 'unlisted_source = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'start = unlisted_source.index("\\nimport hashlib\\n", unlisted_source.index("go_test_checked()")) + 1\n' + 'end = unlisted_source.index("\\nPY\\n}", start)\n' + 'exec(compile(unlisted_source[start:end], "", "exec"), {"os": os})\n' + ) + self.assertIsNotNone(self.inspect(specimen)) + + def test_same_line_packet_bound_reassignments_invalidate_slice_recipe(self) -> None: + specimen = ( + 'import os\nfrom pathlib import Path\n' + 'packet = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' + 'start = packet.index("\\nimport hashlib\\n") + 1; start = packet.index("print(os.environ)")\n' + 'end = packet.index("\\nPY\\n}", start); end = packet.index("print(os.environ)") + 17\n' + 'exec(compile(packet[start:end], "", "exec"), {"os": os})\n' + ) + self.assertIsNotNone(self.inspect(specimen)) + def test_actual_packet_compile_helpers_retain_provenance(self) -> None: checked = 0 for number, body, safe_marker, _ in self.scanner["python_heredoc_bodies"](PACKET_TEXT): From 35c18f3d6f564273287acbff2c103599e05505d4 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 17:34:26 +0900 Subject: [PATCH 052/123] fix: reject stdlib OS reexports and track displayhook sinks --- docs/evidence/g01-recovery-packet.md | 73 +++++++++++++++---- .../issue79_regression_test.py | 41 +++++++++++ 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 1c30e6da..a154db58 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14028,7 +14028,7 @@ def python_module_value_escape_violation(tree, parents, safe_marker): def python_subprocess_os_reexport_violation(tree): - """Do not let subprocess's imported os module bypass direct os guards.""" + """Reject OS re-exports, including private names, from reviewed modules.""" subprocess_names = { alias.asname or alias.name for node in ast.walk(tree) @@ -14048,10 +14048,10 @@ def python_subprocess_os_reexport_violation(tree): 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 + if isinstance(node, ast.ImportFrom) and node.module in reviewed_python_import_modules and any( + alias.name in {"os", "_os"} for alias in node.names ): - return "Python heredoc imports the unreviewed subprocess.os re-export" + return "Python heredoc imports an unreviewed OS module re-export" changed = True while changed: changed = False @@ -14238,11 +14238,9 @@ def python_subprocess_os_reexport_violation(tree): 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": + if isinstance(node, ast.Attribute) and node.attr in {"os", "_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 ( @@ -14250,16 +14248,13 @@ def python_subprocess_os_reexport_violation(tree): 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" + and python_static_string_values(node.args[1], tree) & {"os", "_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 node.slice.value in {"os", "_os"} and ( (isinstance(node.value, ast.Attribute) and node.value.attr == "__dict__") or ( @@ -15595,6 +15590,10 @@ def python_warning_sink_targets(tree): for name in python_assigned_module_names(tree, "warnings") for method in ("warn", "warn_explicit", "showwarning") } + targets.update( + name + ".displayhook" + for name in python_assigned_module_names(tree, "sys") + ) tree._issue79_warning_sink_targets = targets return targets @@ -15610,6 +15609,7 @@ def python_sensitive_output_sink(node, tree=None): if dotted in { "print", "sys.exit", + "sys.displayhook", "warnings.warn", "warnings.warn_explicit", "warnings.showwarning", @@ -15623,6 +15623,7 @@ def python_sensitive_output_sink(node, tree=None): "print", "builtins.print", "sys.exit", + "sys.displayhook", "warnings.warn", "warnings.warn_explicit", "warnings.showwarning", @@ -15651,6 +15652,7 @@ def python_sensitive_output_sink(node, tree=None): "print", "builtins.print", "sys.exit", + "sys.displayhook", "warnings.warn", "warnings.warn_explicit", "warnings.showwarning", @@ -15670,7 +15672,7 @@ def python_sensitive_output_sink(node, tree=None): } imported_functions = { "builtins": {"print"}, - "sys": {"exit"}, + "sys": {"exit", "displayhook"}, "warnings": {"warn", "warn_explicit", "showwarning"}, "traceback": {"print_exc", "print_exception"}, } @@ -32984,3 +32986,48 @@ Before this ledger entry was appended, the corrected writer-source packet selector passed in 40.680s: 331 shell commands, 95 Python bodies and zero reported violations. This is an intermediate writer checkpoint, not approval of an immutable final head or a substitute for either independent disposition. + +#### GitHub exact-head review at fe38cc19: hooks, OS re-exports and displayhook + +Both independent GPT-6-Luna/max delta reviews APPROVED immutable +`fe38cc195617ee1d71e3704e7ff6b3e7e2880ed7`. Contract checks passed four +methods in 4.277s with 128 helper calls; security passed the three new methods, +refused annotated/augmented same-line rebinding and checked base provenance +before cached recipes (True then False). Both reports were processed, released +without a process action and acknowledged. Exact-head packet selection passed +in 40.704s (331 shell commands, 95 Python bodies, zero violations). One stable +correction push followed. Hosted quick run `37108433678` passed in 26s at that +SHA; changed-tooling/workflow regressions were skipped by scope and no Python +suite result is implied. + +GitHub Codex review +[5399684772](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5399684772) +then completed with three P1 findings, including its review-body finding as +well as both inline findings. Internal approval and hosted success did not +authorize a merge. + +| Exact-head finding | Inert reproduction and current disposition | +|---|---| +| [Review-body Git hook isolation finding](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5399684772) | Bare `git status --short`, pager-only and fsmonitor-only variants, `diff` and `ls-files` were certified without a complete isolated Git query boundary. Five canonical negative assertions remain RED; this P1 is unresolved pending its separate local correction. No hook was executed by these specimens. | +| [4172285626](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4172285626), other standard-library OS re-exports | Six witnesses used `tempfile._os`, import/assigned aliases, `pathlib.os` and literal/concatenated `getattr` keys. Re-export rejection now applies to `os`/`_os` attribute names, imports and static reflective keys without requiring a `subprocess` import. | +| [4172285630](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4172285630), `sys.displayhook` output | Six direct, assigned, imported, module-alias and named-expression output witnesses were accepted. Direct/alias sink targets now include `displayhook`; existing protected-module reflection checks also refuse dynamic retrieval. Safe literal output remains accepted. | + +The three new methods were RED in 0.131s with 18 failed assertions: 17 +unsafe-query/output witnesses plus one invalid positive control that supplied +a non-reviewed PATH assignment. Removing that assignment from the inert +isolated-query control, without changing source or widening the loader policy, +left the Git method meaningfully RED (five assertions, 0.120s) and its full +explicit isolated prefix accepted by the previous scanner. + +After the two Python corrections, five focused methods passed in 5.483s: +`test_os_reexports_from_allowed_modules_are_not_certified`, +`test_displayhook_aliases_are_sensitive_output_sinks`, +`test_warning_and_absolute_path_aliases_preserve_sensitive_values`, +`test_actual_packet_compile_helpers_retain_provenance` and +`test_historic_scanner_loaders_export_only_their_required_helpers`, under +`Issue79RegressionTests` in the isolated harness. All 128 helper checks and ten +historical loaders remained supported. Additional inert sys-reflection and +named OS-re-export probes were refused; no adversarial program was executed. +The Git isolation correction, new exact-source review and fresh pushed-head +Codex/hosted gates remain required. No new correction push, merge, full current +harness or live operation is claimed; #79 and parent #1 remain In progress. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 3c138988..b6aa58d0 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3510,6 +3510,47 @@ def counted_walk(node): finally: ast.walk = original_walk + def test_os_reexports_from_allowed_modules_are_not_certified(self) -> None: + for body in ( + 'import tempfile\ntempfile._os.remove("synthetic-owned")\n', + 'import tempfile as fixtures\nfixtures._os.remove("synthetic-owned")\n', + 'from tempfile import _os as inherited\ninherited.remove("synthetic-owned")\n', + 'import pathlib\npathlib.os.remove("synthetic-owned")\n', + 'import tempfile\ngetattr(tempfile, "_os").remove("synthetic-owned")\n', + 'import tempfile\ngetattr(tempfile, "_" + "os").remove("synthetic-owned")\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('import tempfile\nprint("offline fixture")\n')) + + def test_displayhook_aliases_are_sensitive_output_sinks(self) -> None: + for body in ( + 'import os, sys\nsys.displayhook(os.environ)\n', + 'import os, sys\nemit = sys.displayhook\nemit(os.environ)\n', + 'import os\nfrom sys import displayhook as emit\nemit(os.environ)\n', + 'import os\nimport sys as runtime\nruntime.displayhook(os.environ)\n', + 'import os, sys\nruntime = sys\nemit = runtime.displayhook\nemit(os.environ)\n', + 'import os, sys\n(emit := sys.displayhook)(os.environ)\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('import sys\nsys.displayhook("offline fixture")\n')) + self.assertIsNone(self.inspect('from sys import displayhook as emit\nemit("offline fixture")\n')) + + def test_standalone_git_queries_require_explicit_hook_isolation(self) -> None: + for command in ( + "git status --short", "git -P status --short", + "git -c core.fsmonitor=false status --short", + "git diff --stat", "git ls-files --cached", + ): + with self.subTest(command=command): + self.assertIsNotNone(self.shell_violation(command)) + self.assertIsNone(self.shell_violation( + "env -i GIT_CONFIG_NOSYSTEM=1 " + "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null " + "git -P -c core.fsmonitor=false -c core.hooksPath=/dev/null status --short" + )) + def test_warning_sink_and_absolute_path_do_not_disclose_local_data(self) -> None: self.assertIsNotNone(self.inspect( 'import os, warnings\nwarnings.showwarning(os.environ, UserWarning, "x", 1)\n' From 7abfdf25bc3af4cd4eca964ace1410df6c7dddcb Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 18:52:32 +0900 Subject: [PATCH 053/123] Checkpoint Git isolation with binding and argv RED evidence --- docs/evidence/g01-recovery-packet.md | 860 +++++++++++++++--- .../issue79_regression_test.py | 272 +++++- 2 files changed, 1005 insertions(+), 127 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a154db58..9b610585 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -1036,7 +1036,14 @@ env.update( def git_command(arguments): return [ - "git", + "/usr/bin/env", + "-i", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", + "/usr/bin/git", + "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", @@ -4740,7 +4747,7 @@ from pathlib import Path prior_head = "22a2923033c875ddd4f755774f79f60b94649449" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) wrapper_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -4896,7 +4903,7 @@ from pathlib import Path prior_head = "22a2923033c875ddd4f755774f79f60b94649449" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) wrapper_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -5063,7 +5070,11 @@ import subprocess previous = subprocess.check_output( [ - "git", "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", "36ec84b27c934a25484b0a5391af0a20c7643912:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -5120,8 +5131,11 @@ import subprocess prior_head = "ec5eb8087420bbbbb2a8ccf5c5df190b3c644895" previous = subprocess.check_output( [ - "git", - "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -5196,9 +5210,11 @@ from tempfile import TemporaryDirectory starting_head = "3bc8445567fe68cc355cf3f88f0c962a41e9cad5" previous = subprocess.check_output( [ - "git", - "--no-replace-objects", - "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -5327,9 +5343,11 @@ from pathlib import Path starting_head = "3bc8445567fe68cc355cf3f88f0c962a41e9cad5" previous = subprocess.check_output( [ - "git", - "--no-replace-objects", - "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -5450,9 +5468,11 @@ import subprocess starting_head = "3bc8445567fe68cc355cf3f88f0c962a41e9cad5" previous = subprocess.check_output( [ - "git", - "--no-replace-objects", - "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -5502,7 +5522,16 @@ import unicodedata from pathlib import Path from urllib.parse import unquote -files = subprocess.check_output(["git", "ls-files", "*.md"], text=True).splitlines() +files = subprocess.check_output( + [ + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "ls-files", "*.md", + ], + text=True, +).splitlines() link = re.compile(r"(?", "exec"), namespace) prior_head = "22a2923033c875ddd4f755774f79f60b94649449" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{prior_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) prior_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -7171,7 +7206,7 @@ RED source-name gap: prior 22a2923033c875ddd4f755774f79f60b94649449 omitted Test After staging only this packet file, the final local checks were: -```sh +```text set -euo pipefail export 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 -P diff --cached --name-only | grep -Fxq "docs/evidence/g01-recovery-packet.md" @@ -7427,6 +7462,38 @@ def shell_git_config_assignment_violation(tokens): return "unreviewed Git configuration environment assignment" return None + +reviewed_git_query_environment = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_ATTR_NOSYSTEM": "1", +} + + +def git_query_environment_violation(tokens): + """Require standalone Git reads to clear inherited config before exec.""" + tokens = list(tokens) + while tokens and tokens[0] in {"if", "then", "else", "elif", "do", "while", "until", "!"}: + tokens.pop(0) + if not tokens or executable_basename(tokens[0]) != "env": + return "Git query requires an isolated env -i configuration" + if len(tokens) < 3 or tokens[1] != "-i": + return "Git query requires an isolated env -i configuration" + index = 2 + environment = {} + while index < len(tokens) and assignment.fullmatch(tokens[index]): + name, value = tokens[index].split("=", 1) + if name in environment: + return "Git query isolated environment contains a duplicate setting" + environment[name] = value + index += 1 + if environment != reviewed_git_query_environment: + return "Git query isolated environment must contain only reviewed Git settings" + if index >= len(tokens) or executable_basename(tokens[index]) != "git": + return "Git query isolation must invoke Git directly" + return None + def fence_details(line): """Normalize Markdown container prefixes before recognizing a fence.""" candidate = line @@ -8411,6 +8478,62 @@ def git_diff_path_violation(tokens): return None +def git_query_protection_violation(tokens): + """Require pager, fsmonitor and hook protections on every read-only query.""" + if not tokens or executable_basename(tokens[0]) != "git": + return None + subcommand_index = git_subcommand_index(tokens) + if subcommand_index is None: + return "Git query must name an approved read-only subcommand" + global_options = tokens[1:subcommand_index] + if not any(option in {"-P", "--no-pager"} for option in global_options): + return "Git query must disable the configured pager" + required = { + "core.fsmonitor": "false", + "core.hookspath": "/dev/null", + } + observed = {} + index = 0 + while index < len(global_options): + token = global_options[index] + if token == "--config-env" and index + 1 < len(global_options): + key = normalized_git_config_key(global_options[index + 1]) + if key in required: + return f"Git query {key} protection cannot be environment-overridden" + index += 2 + continue + if token.startswith("--config-env="): + key = normalized_git_config_key(token.split("=", 1)[1]) + if key in required: + return f"Git query {key} protection cannot be environment-overridden" + index += 1 + continue + setting = None + if token == "-c" and index + 1 < len(global_options): + setting = global_options[index + 1] + index += 2 + elif token.startswith("-c="): + setting = token[3:] + index += 1 + elif token.startswith("-c") and len(token) > 2: + setting = token[2:] + index += 1 + else: + index += 1 + if setting is None: + continue + key = normalized_git_config_key(setting) + if key in required: + if "=" not in setting or setting.split("=", 1)[1] != required[key]: + return f"Git query {key} protection has an unreviewed value" + if key in observed: + return f"Git query {key} protection is duplicated" + observed[key] = setting.split("=", 1)[1] + if observed != required: + return "Git query requires explicit core.fsmonitor=false and core.hooksPath=/dev/null" + return None + + def git_read_only_violation(tokens): """Allow only the packet's read-only Git queries; reject remote/mutating Git.""" if not tokens or executable_basename(tokens[0]) != "git": @@ -8431,6 +8554,9 @@ def git_read_only_violation(tokens): if subcommand is None: return "Git command must name an approved read-only subcommand" return f"Git {subcommand} subcommand is not allowed" + protection_violation = git_query_protection_violation(tokens) + if protection_violation: + return protection_violation if subcommand == "ls-remote": index = 1 while index < len(tokens): @@ -9081,6 +9207,10 @@ def forbidden_command(tokens, depth=0): 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 executable_basename(tokens[0]) == "git": + git_environment_violation = git_query_environment_violation(original_tokens) + if git_environment_violation: + return git_environment_violation 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) @@ -11318,10 +11448,16 @@ def python_compile_provenance(tree): if python_compile_primitive_is_shadowed("subprocess", tree, node) or not node.args: return False command = node.args[0] - if not isinstance(command, (ast.List, ast.Tuple)) or len(command.elts) not in {4, 5}: + if not isinstance(command, (ast.List, ast.Tuple)) or len(command.elts) != 15: return False prefix = [part.value if isinstance(part, ast.Constant) else None for part in command.elts[:-1]] - if prefix not in (["git", "--no-replace-objects", "show"], ["git", "--no-replace-objects", "-P", "show"]): + if prefix != [ + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", + ]: return False def literal_reference(value, seen=None): seen = seen or set() @@ -13210,9 +13346,62 @@ def python_process_signal_violation(tree, parents): 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": +def reviewed_python_git_builder_definition(tree, name): + """Require the unique module-level builder to clear config and protect Git.""" + definitions = [ + statement for statement in tree.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + and statement.name == name + ] + if len(definitions) != 1: + return False + definition = definitions[0] + if ( + not isinstance(definition, ast.FunctionDef) + or definition.decorator_list + or definition.returns is not None + or getattr(definition, "type_params", ()) + or len(definition.args.args) != 1 + or definition.args.args[0].arg != "arguments" + or definition.args.posonlyargs + or definition.args.kwonlyargs + or definition.args.vararg is not None + or definition.args.kwarg is not None + or definition.args.defaults + or definition.body.__len__() != 1 + or not isinstance(definition.body[0], ast.Return) + or not isinstance(definition.body[0].value, ast.List) + ): + return False + returned = definition.body[0].value + expected = ( + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", + ) + if len(returned.elts) != len(expected) + 1: + return False + for element, value in zip(returned.elts, expected): + if not isinstance(element, ast.Constant) or element.value != value: + return False + tail = returned.elts[-1] + return ( + isinstance(tail, ast.Starred) + and isinstance(tail.value, ast.Name) + and tail.value.id == "arguments" + ) + + +def reviewed_python_git_builder(node, tree): + """Allow only calls to the packet's exact config-isolated Git builders.""" + if not isinstance(node, ast.Call): + return False + builder_name = python_dotted_name(node.func) + if builder_name not in {"git_command", "git_query"}: + return False + if not reviewed_python_git_builder_definition(tree, builder_name): return False if len(node.args) != 1 or node.keywords: return False @@ -13220,7 +13409,104 @@ def reviewed_python_git_builder(node): 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 + if not isinstance(first, ast.Constant) or first.value not in git_read_only_subcommands: + return False + prefix = [ + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", + ] + reconstructed = list(prefix) + for element in arguments.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + reconstructed.append(element.value) + elif reviewed_python_dynamic_path_value(element, tree): + reconstructed.append("__g01_reviewed_dynamic_path__") + else: + return False + return ( + git_query_environment_violation(reconstructed) is None + and git_command_delegation(reconstructed) is None + and git_diff_path_violation(reconstructed) is None + and git_filter_attribute_violation(reconstructed) is None + and git_read_only_violation(reconstructed) is None + ) + + +def reviewed_python_git_command_origin(node, tree, parents, seen=None): + """Trace a child argv only through unique bindings to the canonical builder.""" + if seen is None: + seen = set() + if id(node) in seen: + return False + seen = seen | {id(node)} + if isinstance(node, ast.Call): + return reviewed_python_git_builder(node, tree) + if not isinstance(node, ast.Name): + return False + scope = python_enclosing_scope(node, parents) + assignments = [] + 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 python_enclosing_scope(candidate, parents) is scope and any( + isinstance(target, ast.Name) and target.id == node.id for target in targets + ): + assignments.append(value) + if assignments: + return len(assignments) == 1 and reviewed_python_git_command_origin( + assignments[0], tree, parents, seen + ) + if not isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): + return False + parameters = ( + list(scope.args.posonlyargs) + list(scope.args.args) + list(scope.args.kwonlyargs) + ) + parameter_index = next( + (index for index, parameter in enumerate(parameters) if parameter.arg == node.id), + None, + ) + if parameter_index is None or id(scope) in seen: + return False + callers = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == scope.name + ] + if not callers: + return False + for caller in callers: + keywords = [keyword.value for keyword in caller.keywords if keyword.arg == node.id] + if len(keywords) > 1: + return False + if keywords: + argument = keywords[0] + elif parameter_index < len(caller.args) and not any( + isinstance(value, ast.Starred) for value in caller.args + ): + argument = caller.args[parameter_index] + else: + return False + if not reviewed_python_git_command_origin( + argument, tree, parents, seen | {id(scope)} + ): + return False + return True + + +def reviewed_python_git_child_environment(node, tree, parents): + """Require a known Git argv and the reviewed child environment together.""" + environments = [keyword.value for keyword in node.keywords if keyword.arg == "env"] + return len(environments) == 1 and ( + python_environment_mapping_state(environments[0], tree, parents=parents) == "safe" + and python_git_environment_isolated(environments[0], tree, parents) + ) def reviewed_python_static_loop_binding(node, tree): @@ -13384,26 +13670,9 @@ def reviewed_python_dynamic_path_value(node, tree=None, parents=None, seen=None) def reviewed_python_dynamic_git_call(argument, tree=None, parents=None): - """Allow only read-only Git argv with reviewed immutable path components.""" + """Allow only isolated read-only Git argv with reviewed 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", "--no-replace-objects"} - ): - 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): @@ -13412,18 +13681,35 @@ def reviewed_python_dynamic_git_call(argument, tree=None, parents=None): reconstructed.append("__g01_reviewed_dynamic_path__") else: return False + if git_query_environment_violation(reconstructed) is not None: + return False + git_index = 0 + if executable_basename(reconstructed[0]) == "env": + if len(reconstructed) < 4 or reconstructed[1] != "-i": + return False + git_index = 2 + while git_index < len(reconstructed) and assignment.fullmatch(reconstructed[git_index]): + git_index += 1 + git_tokens = reconstructed[git_index:] + if not git_tokens or executable_basename(git_tokens[0]) != "git": + return False + subcommand_index = git_subcommand_index(git_tokens) 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 + subcommand_index is None + or subcommand_index >= len(git_tokens) + or git_tokens[subcommand_index] not in git_read_only_subcommands + or git_command_delegation(git_tokens) is not None + or git_diff_path_violation(git_tokens) is not None + or git_filter_attribute_violation(git_tokens) is not None + or git_read_only_violation(git_tokens) is not None ): return False + original_subcommand_index = git_index + subcommand_index 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:] + for element in argument.elts[original_subcommand_index + 1:] ) @@ -13444,8 +13730,11 @@ def reviewed_python_dynamic_call( and argument.elts[3].value == "import json; print(json.__file__)" ): return True - if reviewed_python_git_builder(argument): - return True + if ( + python_dotted_name(node.func) in python_command_functions + and reviewed_python_git_command_origin(argument, tree, parents) + ): + return reviewed_python_git_child_environment(node, tree, parents) if reviewed_python_case_args(argument, tree): return True if reviewed_python_static_loop_binding(argument, tree): @@ -13861,7 +14150,7 @@ def python_unknown_os_call_violation(tree): def python_module_value_escape_violation(tree, parents, safe_marker): """Keep security-sensitive modules on direct, inspectable attribute paths.""" - protected = {"os", "subprocess", "shutil", "signal", "sys", "pathlib", "warnings", "builtins", "importlib", "ast"} + protected = {"os", "subprocess", "shutil", "signal", "sys", "pathlib", "tempfile", "warnings", "builtins", "importlib", "ast"} names = { alias.asname or alias.name.split(".")[0] for node in ast.walk(tree) @@ -13878,6 +14167,24 @@ def python_module_value_escape_violation(tree, parents, safe_marker): } names.update(path_modules) os_names = python_assigned_module_names(tree, "os") + tempfile_names = {"tempfile"} | python_assigned_module_names(tree, "tempfile") + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module in protected and any( + imported.name.startswith("_") for imported in node.names + ): + return f"Python private standard-library re-export is not reviewed on line {node.lineno}" + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + if node.func.id == "getattr" and len(node.args) >= 2: + module_value = node.args[0] + if isinstance(module_value, ast.Name) and module_value.id in tempfile_names: + attributes = python_static_string_values(node.args[1], tree) + if not attributes or any(attribute.startswith("_") for attribute in attributes): + return f"Python dynamic private standard-library lookup is not reviewed on line {node.lineno}" + if node.func.id == "vars" and node.args: + module_value = node.args[0] + if isinstance(module_value, ast.Name) and module_value.id in tempfile_names: + return f"Python standard-library module dictionary lookup is not reviewed on line {node.lineno}" changed = True while changed: changed = False @@ -13994,8 +14301,8 @@ def python_module_value_escape_violation(tree, parents, safe_marker): } for node in ast.walk(tree): if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in names: - if node.attr.startswith("__"): - return f"Python security module reflection is not reviewed on line {node.lineno}" + if node.attr.startswith("_"): + return f"Python private standard-library module attribute is not reviewed on line {node.lineno}" if node.value.id in path_modules: return f"Python OS path module alias is not reviewed on line {node.lineno}" if node.attr == "path" and node.value.id in os_names and node.value.id != "os": @@ -14836,6 +15143,268 @@ def python_environment_mapping_state(node, tree, seen=None, parents=None): return "unknown" +def python_git_environment_values_are_isolated(values): + if not isinstance(values, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in values.items() + ): + return False + required = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + } + if any(values.get(name) != value for name, value in required.items()): + return False + config_names = {name for name in values if name.startswith("GIT_CONFIG_")} + safe_config_names = set(required) + numbered_settings = { + "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", + } + present_numbered = config_names.intersection(numbered_settings) + if present_numbered: + if present_numbered != set(numbered_settings) or any( + values.get(name) != value for name, value in numbered_settings.items() + ): + return False + safe_config_names.update(numbered_settings) + if config_names != safe_config_names: + return False + attribute_names = {name for name in values if name.startswith("GIT_ATTR_")} + if any( + name.startswith("GIT_") + and name not in safe_config_names | {"GIT_ATTR_NOSYSTEM"} + for name in values + ): + return False + if not attribute_names <= {"GIT_ATTR_NOSYSTEM"}: + return False + return ( + "GIT_ATTR_NOSYSTEM" not in values or values["GIT_ATTR_NOSYSTEM"] == "1" + ) + + +def python_git_environment_isolated(node, tree, parents, seen=None): + """Trace only literal Git config maps or the packet's two filtered child maps.""" + if seen is None: + seen = set() + if id(node) in seen: + return False + seen = seen | {id(node)} + if isinstance(node, ast.Dict): + try: + return python_git_environment_values_are_isolated(ast.literal_eval(node)) + except (ValueError, TypeError, SyntaxError, RecursionError): + return False + if not isinstance(node, ast.Name): + return False + scope = python_enclosing_scope(node, parents) + for candidate in ast.walk(tree): + if ( + isinstance(candidate, ast.Name) + and candidate.id == node.id + and python_enclosing_scope(candidate, parents) is not scope + ): + return False + if python_enclosing_scope(candidate, parents) is not scope: + continue + if ( + isinstance(candidate, ast.AugAssign) + and isinstance(candidate.target, ast.Name) + and candidate.target.id == node.id + ): + return False + if ( + isinstance(candidate, ast.Name) + and candidate.id == node.id + and isinstance(candidate.ctx, ast.Load) + ): + parent = parents.get(candidate) + if ( + isinstance(parent, ast.keyword) + and parent.arg == "env" + and parent.value is candidate + ): + continue + if ( + isinstance(parent, ast.Attribute) + and parent.value is candidate + and parent.attr == "update" + and isinstance(parents.get(parent), ast.Call) + and parents[parent].func is parent + ): + continue + return False + assignments = [] + 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 python_enclosing_scope(candidate, parents) is scope and any( + isinstance(target, ast.Name) and target.id == node.id for target in targets + ): + assignments.append(value) + if not assignments: + if not isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): + return False + parameters = ( + list(scope.args.posonlyargs) + list(scope.args.args) + list(scope.args.kwonlyargs) + ) + parameter_index = next( + (index for index, parameter in enumerate(parameters) if parameter.arg == node.id), + None, + ) + if parameter_index is None or id(scope) in seen: + return False + callers = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == scope.name + ] + if not callers: + return False + for caller in callers: + keywords = [keyword.value for keyword in caller.keywords if keyword.arg == node.id] + if len(keywords) > 1: + return False + if keywords: + argument = keywords[0] + elif parameter_index < len(caller.args) and not any( + isinstance(value, ast.Starred) for value in caller.args + ): + argument = caller.args[parameter_index] + else: + return False + if not python_git_environment_isolated(argument, tree, parents, seen | {id(scope)}): + return False + return True + if len(assignments) != 1: + return False + base = assignments[0] + config_values = {} + if isinstance(base, ast.Dict): + try: + literal = ast.literal_eval(base) + except (ValueError, TypeError, SyntaxError, RecursionError): + return False + if not isinstance(literal, dict): + return False + config_values.update({ + key: value for key, value in literal.items() + if isinstance(key, str) and key.startswith("GIT_") + }) + elif isinstance(base, ast.DictComp) and len(base.generators) == 1: + generator = base.generators[0] + allowlist_name = None + if ( + isinstance(generator.iter, ast.Call) + and python_dotted_name(generator.iter.func) == "os.environ.items" + and len(generator.target.elts) == 2 + and isinstance(generator.target.elts[0], ast.Name) + and isinstance(generator.target.elts[1], ast.Name) + and isinstance(base.key, ast.Name) + and base.key.id == generator.target.elts[0].id + and isinstance(base.value, ast.Name) + and base.value.id == generator.target.elts[1].id + ): + allowlist_name = "reviewed_child_environment_names" + elif ( + isinstance(generator.iter, ast.Name) + and generator.iter.id == "git_child_environment_names" + and isinstance(generator.target, ast.Name) + and isinstance(base.key, ast.Name) + and base.key.id == generator.target.id + and isinstance(base.value, ast.Subscript) + and python_dotted_name(base.value.value) == "os.environ" + and isinstance(base.value.slice, ast.Name) + and base.value.slice.id == generator.target.id + ): + allowlist_name = "git_child_environment_names" + if allowlist_name is None: + return False + allowlist_assignments = [ + candidate.value for candidate in ast.walk(tree) + if isinstance(candidate, ast.Assign) + and python_enclosing_scope(candidate, parents) is python_enclosing_scope(base, parents) + and any(isinstance(target, ast.Name) and target.id == allowlist_name for target in candidate.targets) + ] + if len(allowlist_assignments) != 1: + return False + try: + allowlist = ast.literal_eval(allowlist_assignments[0]) + except (ValueError, TypeError, SyntaxError, RecursionError): + return False + if not isinstance(allowlist, (set, tuple, list)) or any( + not isinstance(name, str) or name.startswith("GIT_CONFIG_") + for name in allowlist + ): + return False + if allowlist_name == "git_child_environment_names" and set(allowlist) != {"PATH", "LANG", "LC_ALL"}: + return False + if allowlist_name == "reviewed_child_environment_names" and not any( + isinstance(candidate, ast.Compare) + and isinstance(candidate.left, ast.Name) + and candidate.left.id == generator.target.elts[0].id + and any(isinstance(operator, ast.In) for operator in candidate.ops) + and any(isinstance(value, ast.Name) and value.id == allowlist_name for value in candidate.comparators) + for condition in generator.ifs for candidate in ast.walk(condition) + ): + return False + if allowlist_name == "git_child_environment_names" and not any( + isinstance(candidate, ast.Compare) + and isinstance(candidate.left, ast.Name) + and candidate.left.id == generator.target.id + and any(isinstance(operator, ast.In) for operator in candidate.ops) + and isinstance(candidate.comparators[0], ast.Attribute) + and python_dotted_name(candidate.comparators[0]) == "os.environ" + for condition in generator.ifs for candidate in ast.walk(condition) + if isinstance(candidate, ast.Compare) and candidate.comparators + ): + return False + else: + return False + for candidate in ast.walk(tree): + if python_enclosing_scope(candidate, parents) is not scope: + continue + if isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Attribute) and isinstance(candidate.func.value, ast.Name) and candidate.func.value.id == node.id: + if candidate.func.attr != "update" or len(candidate.args) != 1 or candidate.keywords: + return False + try: + update = ast.literal_eval(candidate.args[0]) + except (ValueError, TypeError, SyntaxError, RecursionError): + return False + if not isinstance(update, dict): + return False + config_values.update({ + key: value for key, value in update.items() + if isinstance(key, str) and key.startswith("GIT_") + }) + if isinstance(candidate, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + if any( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == node.id + for target in targets + ): + return False + if isinstance(candidate, ast.Delete) and any( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == node.id + for target in candidate.targets + ): + return False + return python_git_environment_values_are_isolated(config_values) + + def python_child_environment_violation(tree, modules, functions, parents=None): """Reject unreviewed PATH changes before Python child argv approval.""" if parents is None: @@ -18183,9 +18752,11 @@ from tempfile import TemporaryDirectory starting_head = "423d4fc501120a014e63f77d3ef6652606d0326a" previous = subprocess.check_output( [ - "git", - "--no-replace-objects", - "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -18306,9 +18877,11 @@ import warnings starting_head = "423d4fc501120a014e63f77d3ef6652606d0326a" previous = subprocess.check_output( [ - "git", - "--no-replace-objects", - "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -18608,9 +19181,11 @@ import sys starting_head = "01764bbed0a387129d2a2abbc9e27a87e073f87e" previous = subprocess.check_output( [ - "git", - "--no-replace-objects", - "show", + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md", ], text=True, @@ -18846,7 +19421,7 @@ from contextlib import redirect_stdout starting_head = "4bd66186ea8d980a06ed8a4adf7f51e76b5028ef" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -18952,7 +19527,7 @@ import subprocess starting_head = "4bd66186ea8d980a06ed8a4adf7f51e76b5028ef" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_start = previous.index('fence_languages = {"sh", "bash", "shell", "zsh"}') @@ -19021,7 +19596,7 @@ import subprocess starting_head = "4bd66186ea8d980a06ed8a4adf7f51e76b5028ef" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) old_selector = re.compile( @@ -19097,7 +19672,7 @@ from pathlib import Path starting_head = "943ebece04882a0faf055d73e5988bd8954088f8" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{starting_head}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 @@ -19507,7 +20082,7 @@ from pathlib import Path starting_head = "d85f99fa70a6f563079b1ed4f29a1bc97740a3c5" packet_path = "docs/evidence/g01-recovery-packet.md" previous = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{starting_head}:{packet_path}"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{starting_head}:{packet_path}"], text=True ) wrapper_start = previous.index("\nimport hashlib\n", previous.index("go_test_checked()")) + 1 wrapper_end = previous.index("\nPY\n}", wrapper_start) @@ -19858,7 +20433,7 @@ from pathlib import Path parent = "da1af0d041e37e5df9f3ed8028b51a69ec58ed8c" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -19951,7 +20526,16 @@ from types import SimpleNamespace parent = "da1af0d041e37e5df9f3ed8028b51a69ec58ed8c" packet_path = Path("docs/evidence/g01-recovery-packet.md") packet = packet_path.read_text(encoding="utf-8") -if subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() != parent: +if subprocess.check_output( + [ + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "rev-parse", "HEAD", + ], + text=True, +).strip() != parent: raise SystemExit("green probe must run at the immutable exact parent") wrapper_start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 @@ -20249,7 +20833,7 @@ from pathlib import Path parent = "5297b3c3b05afedf97723b7b58806cdd5519a2b6" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -21019,7 +21603,7 @@ import sys parent = "81787b2e90df496a9c5a51fddc7607d3019834b7" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) lines = packet.splitlines() @@ -21431,7 +22015,7 @@ import tempfile from pathlib import Path parent = "6c55f5b67035fb1c7ac334984499cfe80d6bb86b" -packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) status_start = packet.index(' [\n "git",\n "status",') status_end = packet.index(' ],', status_start) + len(' ],') status_argv = packet[status_start:status_end] @@ -21469,7 +22053,7 @@ import subprocess from pathlib import Path parent = "6c55f5b67035fb1c7ac334984499cfe80d6bb86b" -packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") scanner_start = packet.rfind("source = Path(", 0, scanner_anchor) scanner_end = packet.index("\nmatches = []", scanner_anchor) @@ -21496,7 +22080,7 @@ import subprocess import sys parent = "6c55f5b67035fb1c7ac334984499cfe80d6bb86b" -packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) wrapper_start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 wrapper_end = packet.index("\nPY\n}", wrapper_start) wrapper = packet[wrapper_start:wrapper_end] @@ -21734,7 +22318,7 @@ from pathlib import Path parent = "13b463086a0e7aa710067cafb44dcd9aed118654" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -21937,7 +22521,7 @@ from pathlib import Path parent = "5f42598b94ce5339c35f55be42eb108973850d24" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True ) heredoc_pattern = re.compile( r"\\bpython3\\s+-I\\b[^\\n]*<<-?\\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\\1" @@ -22192,7 +22776,7 @@ from contextlib import redirect_stdout from pathlib import Path parent = "055a05bd9d5a9bb101e4400dd7a9236e3afd9f48" -packet = subprocess.check_output(["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) +packet = subprocess.check_output(["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") scanner_start = packet.rfind("source = Path(", 0, scanner_anchor) scanner_end = packet.index("\nmatches = []", scanner_anchor) @@ -22695,7 +23279,7 @@ import sys parent = "4ee7c855b1e79f9478e5cf73c270731dfbf58cf7" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 @@ -23188,7 +23772,7 @@ from pathlib import Path parent = "6af854fb660bc7d9c31c9920a6cec5c2fbb1966d" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 @@ -23564,7 +24148,7 @@ from pathlib import Path parent = "3f6de0b227e4b44aa3d5e259e937e7dc1f0856bb" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -23957,7 +24541,7 @@ from pathlib import Path parent = "7d91bed688dbea21bea7dff62f41d48d1d57ce4b" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -24280,7 +24864,7 @@ from pathlib import Path parent = "71a7a599de567914158b05e3c480f7e0d48c709f" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -24548,7 +25132,7 @@ from pathlib import Path parent = "8dfa9a031bc321f2ccc208104268f7c5ead9281b" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -24934,7 +25518,7 @@ from pathlib import Path parent = "2abe394ee32f09888892c4adea5fc08121845d6b" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -25375,7 +25959,7 @@ from pathlib import Path parent = "14f998f32710f122f5861edfac8fdbc89ef95bfb" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -25756,7 +26340,7 @@ from pathlib import Path parent = "ce417347aa100562722477ea1126a5cf6372ec3a" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) wrapper_start = packet.index("\nimport hashlib\n", packet.index("go_test_checked()")) + 1 @@ -25852,7 +26436,7 @@ from pathlib import Path parent_sha = "21f258a4215c667c245abb44ea419eb7901de2ad" parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") @@ -26174,7 +26758,7 @@ if fences: print(f"GREEN certification shape: {fresh_shell_probes} scoped fresh shell probes (exact-parent RED/current GREEN), four-row ledger, rollback parent {rollback_parent}, Markdown fences balanced") PY jq empty docs/backlog.json -git diff --check +/usr/bin/env -i GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_ATTR_NOSYSTEM=1 /usr/bin/git --no-replace-objects -P -c core.fsmonitor=false -c core.hooksPath=/dev/null diff --check ``` Recorded final packet-shape certification output: @@ -26294,7 +26878,7 @@ parent_sha = "755968f9b4343c860cd8ddeca12a97b277c6e1b5" packet_path = "docs/evidence/g01-recovery-packet.md" packet = Path(packet_path).read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(text): @@ -26403,10 +26987,10 @@ print("GREEN 5674030950 explicit default build tag/CURRENT: current rejected sen if tag_outcome(packet, []) != "default": raise SystemExit("GREEN failure: current rejected no-tags default sentinel") print("GREEN 5674030950 no-tags sentinel/CURRENT: reviewed implicit default remained accepted") -safe("Git ls-remote local repository", shell(current, "git ls-remote .")) +safe("Git ls-remote local repository", shell(current, "/usr/bin/env -i GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_ATTR_NOSYSTEM=1 /usr/bin/git --no-replace-objects -P -c core.fsmonitor=false -c core.hooksPath=/dev/null ls-remote .")) safe("GNU awk literal print", shell(current, "awk 'BEGIN { print \"safe\" }' docs/backlog.json")) safe("Python reviewed packet read", inspect(current, 'from pathlib import Path\nPath("docs/evidence/g01-recovery-packet.md").read_text()')) -safe("Python canonical child env", inspect(current, 'import subprocess\nsubprocess.run(["git", "status"], env={"PATH": "/usr/bin:/bin"})')) +safe("Python canonical child env", inspect(current, 'import subprocess\nsubprocess.run(["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "status"])')) safe("Python owned temporary copyfile", inspect(current, 'import shutil\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nwith TemporaryDirectory() as td:\n shutil.copyfile(Path(td) / "source", Path(td) / "destination")')) safe("shell owned cleanup", shell(current, 'pair_fragment_tmp=/tmp/g01-paired-fragment.$$\nmkdir "$pair_fragment_tmp"\ntrap \'rm -rf "$pair_fragment_tmp"\' EXIT')) print("focused 5674030950 nine-finding RED/GREEN/CURRENT boundaries: passed") @@ -26867,7 +27451,7 @@ from pathlib import Path parent = "1fc2cede68f2692de0fad7a18d6faf7810544845" packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True, ) scanner_anchor = packet.index("def forbidden_command(tokens, depth=0):") @@ -27294,7 +27878,7 @@ def inspected(body, safe_marker=True): # Reproduce every RED against the immutable parent scanner loaded only by git show. parent_sha = "b85839cc801395f4ec9560b056a2a6706c7aa306" parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) parent_anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") @@ -27525,7 +28109,7 @@ def load_scanner(text, label): current = load_scanner(packet, "current-scanner-401034") parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) parent = load_scanner(parent_packet, "exact-parent-3c4eb91-scanner") @@ -27810,7 +28394,7 @@ from pathlib import Path parent_sha = "390c89b5e431a165dfbf8fa986cbf2594e3444ce" current_packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -28045,7 +28629,7 @@ from pathlib import Path parent_sha = "c9f986d0256e47aba7fd273c1ae03993193a39c8" packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -28294,7 +28878,7 @@ from pathlib import Path parent_sha = "f84113bf38dd77dacb5dd3ea9b6018c2f2d06471" packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -28605,7 +29189,7 @@ from pathlib import Path parent_sha = "393d029975139b9d28900d477f62e8de392ace96" packet = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"], text=True, ) @@ -28828,7 +29412,7 @@ parent_sha = "b16a349509535d6dcb179c9c0bd7a6a313c48bcd" packet_path = "docs/evidence/g01-recovery-packet.md" packet = Path(packet_path).read_text(encoding="utf-8") parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(text): @@ -29043,7 +29627,7 @@ from pathlib import Path parent_sha = "8958ec9a5c1e8de6c43d29e389a706f1c9ba75dd" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:{packet_path}"], text=True ) anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") start = parent_packet.rfind("source = Path(", 0, anchor) @@ -29214,7 +29798,7 @@ safe = [ ("reviewed no-sensitive printf", shell("printf '%s\\n' safe")), ("reviewed owned mkdir", shell('pair_fragment_tmp=/tmp/g01-paired-fragment.$$\nmkdir "$pair_fragment_tmp"\nmkdir -p "$pair_fragment_tmp/nested"')), ("reviewed literal subprocess", inspect('import subprocess\nsubprocess.run(["printf", "safe"])')), - ("reviewed Git dynamic path", inspect('import subprocess\nparent = "8958ec9a5c1e8de6c43d29e389a706f1c9ba75dd"\nsubprocess.check_output(["git", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True)')), + ("reviewed Git dynamic path", inspect('import subprocess\nparent = "8958ec9a5c1e8de6c43d29e389a706f1c9ba75dd"\nsubprocess.check_output(["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent}:docs/evidence/g01-recovery-packet.md"], text=True)')), ] for label, result in safe: if rejected(result): @@ -29369,7 +29953,7 @@ from pathlib import Path parent_sha = "518f23c3c875bfda8c8c65239e5171d23c444fc6" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:{packet_path}"], text=True ) anchor = parent_packet.index("def forbidden_command(tokens, depth=0):") start = parent_packet.rfind("source = Path(", 0, anchor) @@ -29574,7 +30158,7 @@ safe = [ ("reviewed repository file iteration", inspect('list(open("docs/backlog.json"))')), ("reviewed ordinary callback", inspect('list(map(str, [["safe"]]))')), ("reviewed import-path read", inspect('import sys\nvalue=sys.path[0]')), - ("reviewed dynamic Git show", inspect('import subprocess\nparent_sha=f"518f23c3c875bfda8c8c65239e5171d23c444fc6"\nsubprocess.run(["git","show",f"{parent_sha}:docs/evidence/g01-recovery-packet.md"])')), + ("reviewed dynamic Git show", inspect('import subprocess\nparent_sha=f"518f23c3c875bfda8c8c65239e5171d23c444fc6"\nsubprocess.run(["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:docs/evidence/g01-recovery-packet.md"])')), ("reviewed os.path query", inspect('import os\nos.path.basename("docs/backlog.json")')), ] for label, result in safe: @@ -29725,7 +30309,7 @@ from pathlib import Path parent_sha = "2d6a1f700e2fb3aa918f2166e4dd4611601b10be" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "show", f"{parent_sha}:{packet_path}"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(packet, label): @@ -30092,7 +30676,7 @@ from pathlib import Path parent_sha = "f44a4871f87c1a8165593549d58ece6ffee61bf2" packet_path = "docs/evidence/g01-recovery-packet.md" parent_packet = subprocess.check_output( - ["git", "--no-replace-objects", "-P", "show", f"{parent_sha}:{packet_path}"], text=True + ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "show", f"{parent_sha}:{packet_path}"], text=True ) def load_scanner(packet, label): @@ -33031,3 +33615,59 @@ named OS-re-export probes were refused; no adversarial program was executed. The Git isolation correction, new exact-source review and fresh pushed-head Codex/hosted gates remain required. No new correction push, merge, full current harness or live operation is claimed; #79 and parent #1 remain In progress. + +#### Local correction checkpoint: Git query isolation and private module values + +At the 2026-10-03 local checkpoint, repository `HEAD` was `35c18f3d6f564273287acbff2c103599e05505d4`; the edited working tree had no commit SHA. This checkpoint addresses the Git hook/config isolation P1 in Codex review [5399684772](https://github.com/1XP-AI/gh-runnerd/pull/103#pullrequestreview-5399684772) and the independently reproduced `tempfile._shutil` escape. No commit, push, packet-selector result, full-harness result or live Git hook execution is claimed here. + +The Git boundary now requires the complete direct shell prefix: `env -i`, system/global config isolation, `GIT_ATTR_NOSYSTEM=1`, the absolute Git path, `--no-replace-objects`, pager suppression, `core.fsmonitor=false` and `core.hooksPath=/dev/null`. Python process queries additionally require the unique literal packet builder and an isolated child map. The map checker retains and rejects unexpected `GIT_*` values rather than discarding non-`GIT_CONFIG_*` entries; direct, aliased, augmented, subscript and nested-scope environment mutation fail closed. Local AST-only witnesses include `GIT_DIR`, `GIT_CONFIG_PARAMETERS`, a saved dictionary alias whose `.update()` adds a config override, `|=` rebinding, and a nested helper that mutates the outer map. None was executed. Existing config include, unbounded config, output, filters/attributes, executable-location, source provenance and replacement-ref guards remain in the focused controls. The exact historical `--no-replace-objects` source reader allowlist is unchanged; formerly bare historical shell transcripts are visibly marked as text records, with their recorded results retained. + +The private module correction refuses `tempfile._shutil` through direct and assigned module access, `from tempfile import _shutil`, reflective `getattr`, `vars(tempfile)` and `tempfile.__dict__`. Public `tempfile.TemporaryDirectory` and `pathlib.Path` controls remain accepted. The only code reviewed by the local harness is the packet scanner itself; every adversarial example remains a string parsed for AST inspection. + +TDD checkpoints were observed locally. The original isolated Git selector pair was RED with 12 failed assertions in 0.131s. The saved-environment-alias witness then failed its expected-refusal assertion once; a `GIT_DIR` map witness and a nested helper mutation witness each separately reproduced one accepted unsafe case before their corresponding provenance/state checks were added. The private module selector was RED with four failed assertions in 0.128s before the private-attribute/re-export rule. After those changes the final combined focused command, including the direct and dictionary-reflection private-module regressions, passed 20 methods in 5.533s; it retained all 128 actual compiler-helper checks and all ten historical loader controls: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_standalone_git_queries_require_explicit_hook_isolation \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_git_child_environment_uses_a_positive_allowlist \ + Issue79RegressionTests.test_git_config_include_options_are_rejected_before_read_only_classification \ + Issue79RegressionTests.test_unbounded_git_config_dumps_are_rejected \ + Issue79RegressionTests.test_git_config_queries_allow_only_reviewed_keys \ + Issue79RegressionTests.test_shell_origin_url_query_is_rejected_but_verifier_capture_remains \ + Issue79RegressionTests.test_explicit_executable_paths_require_reviewed_locations \ + Issue79RegressionTests.test_git_config_assignments_cannot_replace_reviewed_fence_state \ + Issue79RegressionTests.test_git_show_output_and_reader_option_paths_are_reviewed \ + Issue79RegressionTests.test_git_diff_output_cannot_replace_reviewed_source \ + Issue79RegressionTests.test_private_standard_library_reexports_are_refused \ + Issue79RegressionTests.test_os_reexports_from_allowed_modules_are_not_certified \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_warning_and_absolute_path_aliases_preserve_sensitive_values \ + Issue79RegressionTests.test_module_values_cannot_escape_through_containers_or_helpers \ + Issue79RegressionTests.test_module_reflection_and_os_path_outputs_are_rejected \ + Issue79RegressionTests.test_module_namespace_storage_requires_reviewed_uses \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +``` + +The isolated diff hygiene check is `git diff --check`; the added-line scan covered 940 lines and found zero personal-machine path or credential-pattern matches. At handoff, only `docs/evidence/g01-recovery-packet.md` and `scripts/evidence_packet/issue79_regression_test.py` were dirty. #79 and parent #1 remain In progress, and the coordinator still owns the exact packet selector and final-source review gates. + +#### Coordinator delta audit: builder rebinding and mutable argv remain HOLD + +After the writer's handoff, the coordinator read the complete source delta and +reproduced two related gaps using inert AST data only. A correctly shaped +module-level `git_command` or `git_query` can be reassigned to a lambda returning +unprotected Git arguments before its use. Separately, a list produced by the +canonical builder can be overwritten directly or through a saved alias before +the child invocation. The origin checker still certifies these changed values. +No Git query, hook or adversarial specimen was executed. + +Two meaningful regressions were added before implementation: +`test_python_git_builder_rebinding_is_not_certified` and +`test_python_git_argv_mutation_is_not_certified`, under `Issue79RegressionTests`. +Their isolated command was RED: two methods, four expected-refusal assertions +failed in 0.157s. The three canonical direct/list positive controls remained +accepted. The earlier 20-method GREEN result does not cover these new gaps. +The local checkpoint is held pending a fail-closed binding/mutation correction; +no packet selector, final independent approval, correction push or live result +is claimed for it. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index b6aa58d0..165ae71c 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -533,6 +533,21 @@ def _run_git_checked(arguments: list[str], cwd: Path, env: dict[str, str]) -> by return result.stdout +def _isolated_git_shell_command(arguments: list[str]) -> str: + """Build inert scanner data for the explicit standalone Git boundary.""" + return shlex.join( + [ + "/usr/bin/env", "-i", + "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", + "/usr/bin/git", "--no-replace-objects", "-P", + "-c", "core.fsmonitor=false", + "-c", "core.hooksPath=/dev/null", + *arguments, + ] + ) + + class Issue79RegressionTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -2148,6 +2163,19 @@ def test_git_child_environment_uses_a_positive_allowlist(self) -> None: 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") + self.assertEqual(child_environment.get("GIT_CONFIG_SYSTEM"), "/dev/null") + query_runtime = _bounded_git_query_namespace(self.verification) + query = query_runtime["git_query"](["status", "--short"]) + self.assertEqual( + query, + [ + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "status", "--short", + ], + ) def test_final_parity_helper_rejects_intent_bits_and_raw_byte_divergence(self) -> None: function = _verification_function(self.verification, "require_packet_head_parity") @@ -2407,9 +2435,15 @@ def test_git_config_include_options_are_rejected_before_read_only_classification ): with self.subTest(command=command): self.assertIsNotNone(self.shell_violation(command)) - for command in ("git -P status", "git -c core.fsmonitor=false status"): + for command in ( + "git -P status", + "git -c core.fsmonitor=false status", + _isolated_git_shell_command(["status"]).replace( + "-c core.hooksPath=/dev/null", "-c core.hooksPath=/tmp/synthetic-hook" + ), + ): with self.subTest(command=command): - self.assertIsNone(self.shell_violation(command)) + self.assertIsNotNone(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: @@ -2482,7 +2516,7 @@ def test_core_worktree_override_cannot_mask_a_dirty_invocation_worktree(self) -> "git -c core.worktree=/synthetic/alternate status --short" ) ) - self.assertIsNone(self.shell_violation("git status --short")) + self.assertIsNone(self.shell_violation(_isolated_git_shell_command(["status", "--short"]))) def test_unbounded_git_config_dumps_are_rejected(self) -> None: for command in ( @@ -2494,10 +2528,16 @@ def test_unbounded_git_config_dumps_are_rejected(self) -> None: 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)) + self.assertIsNone( + self.shell_violation( + _isolated_git_shell_command(shlex.split(command)[1:]) + ) + ) + self.assertIsNone( + self.shell_violation(_isolated_git_shell_command(["status", "--short"])) + ) def test_git_config_queries_allow_only_reviewed_keys(self) -> None: for command in ( @@ -2517,14 +2557,18 @@ def test_git_config_queries_allow_only_reviewed_keys(self) -> None: "git config --get-urlmatch http.sslverify https://github.com/1XP-AI/gh-runnerd", ): with self.subTest(command=command): - self.assertIsNone(self.shell_violation(command)) + self.assertIsNone( + self.shell_violation(_isolated_git_shell_command(shlex.split(command)[1:])) + ) def test_shell_origin_url_query_is_rejected_but_verifier_capture_remains(self) -> None: query = "git config --local --get-all remote.origin.url" + safe_query = _isolated_git_shell_command(shlex.split(query)[1:]) + safe_tokens = self.scanner["executable_tokens"](shlex.split(safe_query)) # type: ignore[operator] self.assertIsNone( - self.scanner["git_read_only_violation"](shlex.split(query)) # type: ignore[operator] + self.scanner["git_read_only_violation"](safe_tokens) # type: ignore[operator] ) - self.assertIsNotNone(self.shell_violation(query)) + self.assertIsNotNone(self.shell_violation(safe_query)) self.assertIsNotNone( self.shell_violation("trap 'git config --local --get-all remote.origin.url' EXIT") ) @@ -2560,7 +2604,9 @@ def test_explicit_executable_paths_require_reviewed_locations(self) -> None: ): with self.subTest(command=command): self.assertIsNotNone(self.shell_violation(command)) - self.assertIsNone(self.shell_violation("git status --porcelain=v1")) + self.assertIsNone( + self.shell_violation(_isolated_git_shell_command(["status", "--porcelain=v1"])) + ) def test_git_config_assignments_cannot_replace_reviewed_fence_state(self) -> None: commands = ( @@ -2592,7 +2638,7 @@ def test_git_diff_output_cannot_replace_reviewed_source(self) -> None: ): with self.subTest(command=command): self.assertIsNotNone(self.shell_violation(command)) - self.assertIsNone(self.shell_violation("git diff HEAD^ HEAD")) + self.assertIsNone(self.shell_violation(_isolated_git_shell_command(["diff", "HEAD^", "HEAD"]))) def test_nested_raise_does_not_prove_module_root_guard(self) -> None: bodies = [ @@ -3538,18 +3584,181 @@ def test_displayhook_aliases_are_sensitive_output_sinks(self) -> None: self.assertIsNone(self.inspect('from sys import displayhook as emit\nemit("offline fixture")\n')) def test_standalone_git_queries_require_explicit_hook_isolation(self) -> None: - for command in ( + unsafe = ( "git status --short", "git -P status --short", "git -c core.fsmonitor=false status --short", "git diff --stat", "git ls-files --cached", - ): + "env GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null " + "GIT_CONFIG_SYSTEM=/dev/null git -P -c core.fsmonitor=false " + "-c core.hooksPath=/dev/null status --short", + "env -i PATH=/usr/bin:/bin GIT_CONFIG_NOSYSTEM=1 " + "GIT_CONFIG_GLOBAL=/dev/null git -P -c core.fsmonitor=false " + "-c core.hooksPath=/dev/null status --short", + "env -i PATH=/usr/bin:/bin GIT_CONFIG_NOSYSTEM=1 " + "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null " + "git -P -c core.fsmonitor=false status --short", + "env -i PATH=/usr/bin:/bin GIT_CONFIG_NOSYSTEM=1 " + "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null " + "git -P -c core.hooksPath=/dev/null status --short", + "env -i PATH=/usr/bin:/bin GIT_CONFIG_NOSYSTEM=1 " + "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null " + "git -P -c core.fsmonitor=false -c core.hooksPath=/dev/null " + "-c core.fsmonitor=/synthetic/hook status --short", + ) + for command in unsafe: with self.subTest(command=command): self.assertIsNotNone(self.shell_violation(command)) - self.assertIsNone(self.shell_violation( - "env -i GIT_CONFIG_NOSYSTEM=1 " - "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null " - "git -P -c core.fsmonitor=false -c core.hooksPath=/dev/null status --short" - )) + for args in ( + ["status", "--short"], + ["diff", "--stat"], + ["ls-files", "--cached"], + ): + with self.subTest(args=args): + self.assertIsNone(self.shell_violation(_isolated_git_shell_command(args))) + self.assertIsNotNone( + self.shell_document_violation( + "export PATH=/usr/bin:/bin\n" + "git -P -c core.fsmonitor=false -c core.hooksPath=/dev/null status --short" + ) + ) + + def test_python_git_queries_require_isolated_environment_and_known_builder(self) -> None: + unsafe = ( + 'import subprocess\n' + 'subprocess.run(["git", "-P", "-c", "core.fsmonitor=false", ' + '"-c", "core.hooksPath=/dev/null", "status"])\n', + 'import subprocess\n' + 'git_environment = {"GIT_CONFIG_NOSYSTEM": "1"}\n' + 'subprocess.run(["git", "-P", "-c", "core.fsmonitor=false", ' + '"-c", "core.hooksPath=/dev/null", "status"], env=git_environment)\n', + 'import subprocess\n' + 'git_environment = {"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", ' + '"GIT_CONFIG_SYSTEM": "/dev/null", "GIT_ATTR_NOSYSTEM": "1", ' + '"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"}\n' + 'subprocess.run(["git", "-P", "-c", "core.fsmonitor=false", ' + '"-c", "core.hooksPath=/dev/null", "status"], env=git_environment)\n', + 'import subprocess\n' + 'git_environment = {"PATH": "/usr/bin:/bin", "GIT_CONFIG_NOSYSTEM": "1", ' + '"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null"}\n' + 'def git_command(arguments):\n return ["git", *arguments]\n' + 'subprocess.run(git_command(["status"]), env=git_environment)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + + canonical = ( + 'import subprocess\n' + 'git_environment = {"PATH": "/usr/bin:/bin", "GIT_CONFIG_NOSYSTEM": "1", ' + '"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null", ' + '"GIT_ATTR_NOSYSTEM": "1"}\n' + 'def git_command(arguments):\n' + ' return ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", ' + '"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", ' + '"GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", ' + '"-P", "-c", "core.fsmonitor=false", "-c", ' + '"core.hooksPath=/dev/null", *arguments]\n' + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)\n' + ) + self.assertIsNone(self.inspect(canonical)) + self.assertIsNotNone( + self.inspect(canonical.rsplit("subprocess.run(", 1)[0] + + 'subprocess.run(git_command(["status", "--short"]))\n') + ) + self.assertIsNotNone( + self.inspect( + canonical.replace( + '"GIT_ATTR_NOSYSTEM": "1"}', + '"GIT_ATTR_NOSYSTEM": "1", "GIT_DIR": "/synthetic"}', + ) + ) + ) + self.assertIsNotNone( + self.inspect( + canonical.replace( + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + 'git_environment |= {"GIT_CONFIG_PARAMETERS": "synthetic"}\n' + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + ) + ) + ) + self.assertIsNotNone( + self.inspect( + canonical.replace( + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + 'git_environment_alias = git_environment\n' + 'git_environment_alias.update({"GIT_CONFIG_PARAMETERS": "synthetic"})\n' + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + ) + ) + ) + self.assertIsNotNone( + self.inspect( + canonical.replace( + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + 'git_environment["GIT_DIR"] = "/synthetic"\n' + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + ) + ) + ) + self.assertIsNotNone( + self.inspect( + canonical.replace( + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + 'def change_git_environment():\n' + ' git_environment.update({"GIT_CONFIG_PARAMETERS": "synthetic"})\n' + 'change_git_environment()\n' + 'subprocess.run(git_command(["status", "--short"]), env=git_environment)', + ) + ) + ) + + def test_python_git_builder_rebinding_is_not_certified(self) -> None: + for builder in ("git_command", "git_query"): + prelude = ( + 'import subprocess\n' + 'git_environment = {"PATH": "/usr/bin:/bin", ' + '"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", ' + '"GIT_CONFIG_SYSTEM": "/dev/null", "GIT_ATTR_NOSYSTEM": "1"}\n' + f'def {builder}(arguments):\n' + ' return ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", ' + '"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", ' + '"GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", ' + '"-P", "-c", "core.fsmonitor=false", "-c", ' + '"core.hooksPath=/dev/null", *arguments]\n' + ) + call = f'subprocess.run({builder}(["status", "--short"]), env=git_environment)\n' + with self.subTest(builder=builder, kind="safe literal builder"): + self.assertIsNone(self.inspect(prelude + call)) + with self.subTest(builder=builder, kind="rebound builder"): + self.assertIsNotNone(self.inspect( + prelude + f'{builder} = lambda arguments: ["/usr/bin/git", "status"]\n' + call + )) + + def test_python_git_argv_mutation_is_not_certified(self) -> None: + prelude = ( + 'import subprocess\n' + 'git_environment = {"PATH": "/usr/bin:/bin", ' + '"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", ' + '"GIT_CONFIG_SYSTEM": "/dev/null", "GIT_ATTR_NOSYSTEM": "1"}\n' + 'def git_command(arguments):\n' + ' return ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", ' + '"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", ' + '"GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", ' + '"-P", "-c", "core.fsmonitor=false", "-c", ' + '"core.hooksPath=/dev/null", *arguments]\n' + 'argv = git_command(["status", "--short"])\n' + ) + call = 'subprocess.run(argv, env=git_environment)\n' + self.assertIsNone(self.inspect(prelude + call)) + for mutation in ( + 'argv[:] = ["/usr/bin/git", "status"]\n', + 'saved_argv = argv\nsaved_argv[:] = ["/usr/bin/git", "status"]\n', + ): + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect(prelude + mutation + call)) def test_warning_sink_and_absolute_path_do_not_disclose_local_data(self) -> None: self.assertIsNotNone(self.inspect( @@ -3574,6 +3783,35 @@ def test_module_values_cannot_escape_through_containers_or_helpers(self) -> None self.assertIsNone(self.inspect('import os\nprint("reviewed")\n')) self.assertIsNone(self.inspect('import os\nalias = os\nprint("reviewed")\n')) + def test_private_standard_library_reexports_are_refused(self) -> None: + unsafe = ( + 'import tempfile\ntempfile._shutil.rmtree("synthetic-owned")\n', + 'import tempfile\nprivate_tools = tempfile._shutil\n' + 'private_tools.rmtree("synthetic-owned")\n', + 'from tempfile import _shutil as private_tools\n' + 'private_tools.rmtree("synthetic-owned")\n', + 'import tempfile\n' + 'private_tools = getattr(tempfile, "_shutil")\n' + 'private_tools.rmtree("synthetic-owned")\n', + 'import tempfile\n' + 'module_items = vars(tempfile)\n' + 'module_items["_shutil"].rmtree("synthetic-owned")\n', + 'import tempfile\n' + 'module_items = tempfile.__dict__\n' + 'module_items["_shutil"].rmtree("synthetic-owned")\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import tempfile\n' + 'with tempfile.TemporaryDirectory() as temporary:\n' + ' print("reviewed")\n' + )) + self.assertIsNone(self.inspect( + 'from pathlib import Path\nPath("docs/backlog.json")\n' + )) + def test_warning_and_absolute_path_aliases_preserve_sensitive_values(self) -> None: unsafe = ( 'import os, warnings\nemit = warnings.showwarning\nemit(os.environ, UserWarning, "x", 1)\n', From 3b7202bca95d647e495363703b0a805d58c52d2e Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 19:12:37 +0900 Subject: [PATCH 054/123] Reject shadowed Git builders and mutable argv origins --- docs/evidence/g01-recovery-packet.md | 185 +++++++++++++++++- .../issue79_regression_test.py | 48 +++++ 2 files changed, 230 insertions(+), 3 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 9b610585..842d12b4 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13394,14 +13394,86 @@ def reviewed_python_git_builder_definition(tree, name): ) -def reviewed_python_git_builder(node, tree): +def reviewed_python_git_name_bound_in_scope(tree, parents, scope, name, allowed_definition=None): + """Find Python binders that could shadow a reviewed Git callable name.""" + for candidate in ast.walk(tree): + if ( + isinstance(candidate, ast.Name) + and candidate.id == name + and isinstance(candidate.ctx, (ast.Store, ast.Del)) + and python_enclosing_scope(candidate, parents) is scope + ): + return True + if ( + isinstance(candidate, ast.arg) + and candidate.arg == name + and python_enclosing_scope(candidate, parents) is scope + ): + return True + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if ( + candidate is not allowed_definition + and candidate.name == name + and python_enclosing_scope(parents.get(candidate), parents) is scope + ): + return True + if isinstance(candidate, ast.Import) and python_enclosing_scope(candidate, parents) is scope: + if any((alias.asname or alias.name.split(".")[0]) == name for alias in candidate.names): + return True + if isinstance(candidate, ast.ImportFrom) and python_enclosing_scope(candidate, parents) is scope: + if any(alias.name == "*" or (alias.asname or alias.name) == name for alias in candidate.names): + return True + if isinstance(candidate, ast.ExceptHandler) and candidate.name == name: + if python_enclosing_scope(candidate, parents) is scope: + return True + if isinstance(candidate, (ast.MatchAs, ast.MatchStar)) and candidate.name == name: + if python_enclosing_scope(candidate, parents) is scope: + return True + if isinstance(candidate, ast.MatchMapping) and candidate.rest == name: + if python_enclosing_scope(candidate, parents) is scope: + return True + if isinstance(candidate, (ast.Global, ast.Nonlocal)) and name in candidate.names: + declared_scope = python_enclosing_scope(candidate, parents) + if declared_scope is scope: + return True + if isinstance(candidate, ast.Global) and isinstance(scope, ast.Module): + return True + if isinstance(candidate, ast.Nonlocal) and scope in python_lexical_scope_chain( + declared_scope, parents + ): + return True + return False + + +def reviewed_python_git_builder_binding(node, tree, parents, name): + """Prove a builder reference reaches its sole unshadowed module definition.""" + if not isinstance(node.func, ast.Name) or node.func.id != name: + return False + definitions = [ + statement for statement in tree.body + if isinstance(statement, ast.FunctionDef) and statement.name == name + ] + if len(definitions) != 1: + return False + definition = definitions[0] + for scope in python_lexical_scope_chain(python_enclosing_scope(node, parents), parents): + allowed = definition if scope is tree else None + if reviewed_python_git_name_bound_in_scope(tree, parents, scope, name, allowed): + return False + return True + + +def reviewed_python_git_builder(node, tree, parents): """Allow only calls to the packet's exact config-isolated Git builders.""" if not isinstance(node, ast.Call): return False builder_name = python_dotted_name(node.func) if builder_name not in {"git_command", "git_query"}: return False - if not reviewed_python_git_builder_definition(tree, builder_name): + if ( + not reviewed_python_git_builder_definition(tree, builder_name) + or not reviewed_python_git_builder_binding(node, tree, parents, builder_name) + ): return False if len(node.args) != 1 or node.keywords: return False @@ -13435,6 +13507,63 @@ def reviewed_python_git_builder(node, tree): ) +def reviewed_python_git_argv_name_has_no_escape(node, tree, parents, scope): + """Allow the list only at a process argv slot; reject mutation and aliasing.""" + if not isinstance(node, ast.Name): + return False + stores = [] + has_parameter = False + for candidate in ast.walk(tree): + if isinstance(candidate, ast.arg) and candidate.arg == node.id: + candidate_scope = python_enclosing_scope(candidate, parents) + if candidate_scope is scope: + has_parameter = True + continue + if not isinstance(candidate, ast.Name) or candidate.id != node.id: + continue + candidate_scope = python_enclosing_scope(candidate, parents) + if candidate_scope is not scope: + if scope in python_lexical_scope_chain(candidate_scope, parents): + if any( + isinstance(declaration, (ast.Global, ast.Nonlocal)) + and node.id in declaration.names + and python_enclosing_scope(declaration, parents) is candidate_scope + for declaration in ast.walk(candidate_scope) + ): + return False + if not reviewed_python_git_name_bound_in_scope( + tree, parents, candidate_scope, node.id + ): + return False + continue + if isinstance(candidate.ctx, ast.Store): + parent = parents.get(candidate) + direct_assignment = ( + isinstance(parent, ast.Assign) + and candidate in parent.targets + or isinstance(parent, ast.AnnAssign) + and parent.target is candidate + or isinstance(parent, ast.NamedExpr) + and parent.target is candidate + ) + if not direct_assignment: + return False + stores.append(candidate) + continue + if isinstance(candidate.ctx, ast.Load): + parent = parents.get(candidate) + if not ( + isinstance(parent, ast.Call) + and parent.args + and parent.args[0] is candidate + and python_dotted_name(parent.func) in python_command_functions + ): + return False + continue + return False + return len(stores) <= 1 and not (has_parameter and stores) + + def reviewed_python_git_command_origin(node, tree, parents, seen=None): """Trace a child argv only through unique bindings to the canonical builder.""" if seen is None: @@ -13443,10 +13572,12 @@ def reviewed_python_git_command_origin(node, tree, parents, seen=None): return False seen = seen | {id(node)} if isinstance(node, ast.Call): - return reviewed_python_git_builder(node, tree) + return reviewed_python_git_builder(node, tree, parents) if not isinstance(node, ast.Name): return False scope = python_enclosing_scope(node, parents) + if not reviewed_python_git_argv_name_has_no_escape(node, tree, parents, scope): + return False assignments = [] for candidate in ast.walk(tree): if isinstance(candidate, ast.Assign): @@ -33671,3 +33802,51 @@ accepted. The earlier 20-method GREEN result does not cover these new gaps. The local checkpoint is held pending a fail-closed binding/mutation correction; no packet selector, final independent approval, correction push or live result is claimed for it. + +#### Writer correction checkpoint: reviewed builder binding and argv immutability + +At the 2026-10-03 local checkpoint, repository `HEAD` was +`7abfdf25bc3af4cd4eca964ace1410df6c7dddcb` and the working tree was clean before +this correction. The builder checker now verifies that a call resolves to the +sole reviewed top-level `git_command`/`git_query` definition, rejecting direct +rebinding and visible local parameter, inner-definition, import, alias or +global/nonlocal shadows. An argv name traced to a canonical builder is accepted +only when every same-binding read occupies a direct process argv slot; it is +refused when stored elsewhere, passed to helpers, mutated by subscription or +list methods, rebound, or reached by a nested global/nonlocal writer. This +preserves the canonical direct-builder, bound-list and isolated-environment +controls without changing source SHA/compiler allowlists or adding call names. +All adversarial inputs remained inert strings for AST inspection. + +The original supplied RED was four expected-refusal assertions across the two +methods in 0.157s. Expanded shadow probes were RED at six assertions in 0.161s; +the extended direct/saved-list, append and helper-escape group was RED at six +assertions in 0.175s. A separate nested `global argv` mutation was RED at one +assertion in 0.160s. The parameter and inner-definition tests use a safe literal +child environment so they exercise callable resolution rather than failing the +environment gate; after correction, both are refused. Saved callable aliases, +local/global reflection, direct and saved-list mutation, method mutation, +helper escape, and nested `global`/`nonlocal` mutation are refused. The existing +canonical builder and argv positive controls remain accepted. + +The final bounded command passed six methods in 5.615s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_git_child_environment_uses_a_positive_allowlist \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +``` + +The run retained all 128 actual compiler-helper checks and ten historical +loader controls. No full harness or packet selector was run; the coordinator +owns both. The edited working tree has no new immutable commit SHA and is dirty +only in `docs/evidence/g01-recovery-packet.md` and +`scripts/evidence_packet/issue79_regression_test.py`; `git diff --check` passed, +and the added-line path/credential scan examined 230 lines with zero matches. +Independent final-source review and the coordinator packet selector remain +required before a candidate can be considered complete. These focused checks +do not claim a universal Python sandbox or resolve broader live-operation gates. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 165ae71c..d25ea777 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3716,6 +3716,11 @@ def test_python_git_queries_require_isolated_environment_and_known_builder(self) ) def test_python_git_builder_rebinding_is_not_certified(self) -> None: + safe_environment = ( + '{"PATH": "/usr/bin:/bin", "GIT_CONFIG_NOSYSTEM": "1", ' + '"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null", ' + '"GIT_ATTR_NOSYSTEM": "1"}' + ) for builder in ("git_command", "git_query"): prelude = ( 'import subprocess\n' @@ -3736,6 +3741,28 @@ def test_python_git_builder_rebinding_is_not_certified(self) -> None: self.assertIsNotNone(self.inspect( prelude + f'{builder} = lambda arguments: ["/usr/bin/git", "status"]\n' + call )) + with self.subTest(builder=builder, kind="parameter shadow"): + self.assertIsNotNone(self.inspect( + prelude + + f'def invoke({builder}):\n' + + f' subprocess.run({builder}(["status", "--short"]), env={safe_environment})\n' + + 'invoke(lambda arguments: ["/usr/bin/git", "status"])\n' + )) + with self.subTest(builder=builder, kind="inner definition shadow"): + self.assertIsNotNone(self.inspect( + prelude + + 'def invoke():\n' + + f' def {builder}(arguments):\n' + + ' return ["/usr/bin/git", "status"]\n' + + f' subprocess.run({builder}(["status", "--short"]), env={safe_environment})\n' + + 'invoke()\n' + )) + with self.subTest(builder=builder, kind="saved callable alias"): + self.assertIsNotNone(self.inspect( + prelude + + f'{builder}_alias = {builder}\n' + + f'subprocess.run({builder}_alias(["status", "--short"]), env=git_environment)\n' + )) def test_python_git_argv_mutation_is_not_certified(self) -> None: prelude = ( @@ -3756,9 +3783,30 @@ def test_python_git_argv_mutation_is_not_certified(self) -> None: for mutation in ( 'argv[:] = ["/usr/bin/git", "status"]\n', 'saved_argv = argv\nsaved_argv[:] = ["/usr/bin/git", "status"]\n', + 'argv.append("--config-env=core.fsmonitor=GIT_CONFIG_PARAMETERS")\n', + 'def rewrite(command):\n' + ' command[:] = ["/usr/bin/git", "status"]\n' + 'rewrite(argv)\n', + 'locals()["argv"][:] = ["/usr/bin/git", "status"]\n', + 'globals()["argv"][:] = ["/usr/bin/git", "status"]\n', + 'def rewrite():\n' + ' global argv\n' + ' argv[:] = ["/usr/bin/git", "status"]\n' + 'rewrite()\n', ): with self.subTest(mutation=mutation): self.assertIsNotNone(self.inspect(prelude + mutation + call)) + nonlocal_mutation = ( + 'def wrapper():\n' + ' argv = git_command(["status", "--short"])\n' + ' def rewrite():\n' + ' nonlocal argv\n' + ' argv[:] = ["/usr/bin/git", "status"]\n' + ' rewrite()\n' + ' subprocess.run(argv, env=git_environment)\n' + 'wrapper()\n' + ) + self.assertIsNotNone(self.inspect(prelude + nonlocal_mutation)) def test_warning_sink_and_absolute_path_do_not_disclose_local_data(self) -> None: self.assertIsNotNone(self.inspect( From 05044cfc34a76528d1c8d4baa6a8a195ee59c323 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 20:19:50 +0900 Subject: [PATCH 055/123] Harden Issue 79 Git and path provenance checks --- docs/evidence/g01-recovery-packet.md | 689 ++++++++++++++++-- .../issue79_regression_test.py | 212 +++++- 2 files changed, 799 insertions(+), 102 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 842d12b4..6603c7a4 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -1049,6 +1049,19 @@ def git_command(arguments): "-c", "core.hooksPath=/dev/null", *arguments, ] + + +root_git_environment = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_ATTR_NOSYSTEM": "1", + "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", +} loader_assignment_names = { "PATH", "LD_PRELOAD", @@ -1096,7 +1109,7 @@ repo_root = Path( subprocess.check_output( git_command(["rev-parse", "--show-toplevel"]), cwd=invocation_root, - env=env, + env=root_git_environment, text=True, ).strip() ).resolve() @@ -2206,7 +2219,9 @@ def create_immutable_source_snapshot(repo_root, module_dir, env): """Materialize reviewed source bytes into a private read-only test tree.""" snapshot_directory = tempfile.TemporaryDirectory() snapshot_root = Path(snapshot_directory.name) - source_blobs = git_worktree_matches_pinned_blobs(repo_root, module_dir, env) + source_blobs = git_worktree_matches_pinned_blobs( + repo_root, module_dir, env=env + ) directories = {Path(module_dir)} for relative_path, payload in sorted(source_blobs.items()): relative = Path(relative_path) @@ -2244,10 +2259,21 @@ def create_immutable_source_snapshot(repo_root, module_dir, env): def recheck_reviewed_source_checkout(phase): """Recheck the reviewed tree, raw bytes, status and intent at a boundary.""" + recheck_git_environment = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_ATTR_NOSYSTEM": "1", + "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", + } source_tree = subprocess.run( git_command(["rev-parse", f"HEAD:{module_dir}"]), cwd=repo_root, - env=env, + env=recheck_git_environment, text=True, capture_output=True, check=False, @@ -2256,22 +2282,39 @@ def recheck_reviewed_source_checkout(phase): raise SystemExit(f"{label}: {phase} source-tree query failed") if source_tree.stdout.strip() != reviewed_module_tree: raise SystemExit(f"{label}: {phase} source tree drifted from the reviewed pin") - git_worktree_matches_pinned_blobs(repo_root, module_dir, env) - source_status = run_bounded_git_status(repo_root, module_dir, env) + git_worktree_matches_pinned_blobs( + repo_root, module_dir, env=recheck_git_environment + ) + source_status = run_bounded_git_status( + repo_root, module_dir, env=recheck_git_environment + ) if source_status.returncode != 0 or source_status.stderr.strip(): raise SystemExit(f"{label}: {phase} source status query failed") if source_status.stdout.strip(): raise SystemExit(f"{label}: {phase} source checkout was not clean") - if git_source_control_entries(repo_root, module_dir, env): + if git_source_control_entries( + repo_root, module_dir, env=recheck_git_environment + ): raise SystemExit(f"{label}: {phase} source has intent-bit overrides") def package_initialization_guard(): global go_repo_root, source_snapshot_directory, source_snapshot_root, source_snapshot_digest + package_git_environment = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_ATTR_NOSYSTEM": "1", + "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", + } source_tree = subprocess.run( git_command(["rev-parse", f"HEAD:{module_dir}"]), cwd=repo_root, - env=env, + env=package_git_environment, text=True, capture_output=True, check=False, @@ -2282,8 +2325,12 @@ def package_initialization_guard(): raise SystemExit( f"{label}: package-initialization guard requires reviewed source tree" ) - git_worktree_matches_pinned_blobs(repo_root, module_dir, env) - source_status = run_bounded_git_status(repo_root, module_dir, env) + git_worktree_matches_pinned_blobs( + repo_root, module_dir, env=package_git_environment + ) + source_status = run_bounded_git_status( + repo_root, module_dir, env=package_git_environment + ) if source_status.returncode != 0 or source_status.stderr.strip(): raise SystemExit(f"{label}: package source status query failed") if source_status.stdout.strip(): @@ -2291,7 +2338,9 @@ def package_initialization_guard(): f"{label}: package-initialization guard requires a clean source tree " "with no tracked, untracked or ignored paths" ) - source_control_entries = git_source_control_entries(repo_root, module_dir, env) + source_control_entries = git_source_control_entries( + repo_root, module_dir, env=package_git_environment + ) if source_control_entries: raise SystemExit( f"{label}: source has skip-worktree or assume-unchanged entries" @@ -2303,7 +2352,9 @@ def package_initialization_guard(): source_snapshot_directory, source_snapshot_root, source_snapshot_digest, - ) = create_immutable_source_snapshot(repo_root, module_dir, env) + ) = create_immutable_source_snapshot( + repo_root, module_dir, env=package_git_environment + ) go_repo_root = source_snapshot_root verify_downloaded_module_sources() recheck_reviewed_source_checkout("after module download before metadata") @@ -5522,14 +5573,30 @@ import unicodedata from pathlib import Path from urllib.parse import unquote -files = subprocess.check_output( - [ +git_query_environment = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_ATTR_NOSYSTEM": "1", + "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", +} + +def git_command(arguments): + return [ "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", - "core.hooksPath=/dev/null", "ls-files", "*.md", - ], + "core.hooksPath=/dev/null", *arguments, + ] + +files = subprocess.check_output( + git_command(["ls-files", "--", "*.md"]), + env=git_query_environment, text=True, ).splitlines() link = re.compile(r"(?") if target.startswith(("http://", "https://", "mailto:")): continue if target.startswith("#"): - path, fragment = source, target[1:] + fragment = target[1:] + target = source.name else: target, separator, fragment = target.partition("#") if Path(target).is_absolute() or target.startswith(("~", "$HOME", "${HOME}")): errors.append(f"{name}: absolute local link target is not allowed {target}") continue - path = (source.parent / target).resolve() fragment = fragment if separator else None - try: - path.relative_to(repository_root) - except ValueError: - errors.append(f"{name}: local link target escapes repository {target}") - continue + path = (source.parent / target).resolve() + try: + path.relative_to(repository_root) + except ValueError: + errors.append(f"{name}: local link target escapes repository {target}") + continue checked += 1 if not path.is_file(): errors.append(f"{name}: missing target {target}") - elif fragment and slug(fragment) not in anchors(path): + elif fragment and slug(fragment) not in anchors(path.read_text(encoding="utf-8")): errors.append(f"{name}: missing anchor {path}#{fragment}") if errors: raise SystemExit("\n".join(errors)) @@ -6381,7 +6461,7 @@ mismatch. It runs from the repository root with literal subprocess arguments, so a missing or renamed build-tagged alternative cannot produce a false green. The `-tags=osusergo` case is intentionally included in that fail-closed set. -```sh +```text set -euo pipefail export 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 # g01-safe-python-heredoc: reviewed synthetic Go test argv @@ -6851,10 +6931,11 @@ Go files selected by the exact build tags, and rejects any active `func init` before source derivation. Because selector validation parses those source files instead of invoking `go test -list`, effectful package-level variable initializers and imported initialization paths cannot run before validation; -package-source changes still require a new source-tree review. The guard audit -was read-only and did not run test bodies or live resources: +package-source changes still require a new source-tree review. The next +block is the historical pre-isolation package-init audit, retained as inert +source data with its original output; it is not a current prescription: -```sh +```text set -euo pipefail export 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 # g01-safe-python-heredoc: reviewed synthetic Go metadata argv @@ -13540,6 +13621,7 @@ def reviewed_python_git_argv_name_has_no_escape(node, tree, parents, scope): parent = parents.get(candidate) direct_assignment = ( isinstance(parent, ast.Assign) + and len(parent.targets) == 1 and candidate in parent.targets or isinstance(parent, ast.AnnAssign) and parent.target is candidate @@ -15319,6 +15401,54 @@ def python_git_environment_values_are_isolated(values): ) +def reviewed_python_git_environment_argument(node, tree, parents): + """Recognize only a direct value passed to one statically bound env parameter.""" + parent = parents.get(node) + if isinstance(parent, ast.keyword): + call = parents.get(parent) + argument_name = parent.arg + elif isinstance(parent, ast.Call) and node in parent.args: + call = parent + argument_name = None + else: + return False + if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name): + return False + function_name = call.func.id + call_scope = python_enclosing_scope(call, parents) + definitions = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) + and candidate.name == function_name + and python_enclosing_scope(parents.get(candidate), parents) is call_scope + ] + if len(definitions) != 1: + return False + definition = definitions[0] + for scope in python_lexical_scope_chain(call_scope, parents): + allowed = definition if scope is call_scope else None + if reviewed_python_git_name_bound_in_scope( + tree, parents, scope, function_name, allowed + ): + return False + parameters = list(definition.args.posonlyargs) + list(definition.args.args) + keyword_only = {parameter.arg for parameter in definition.args.kwonlyargs} + if argument_name is not None: + return argument_name == "env" and ( + any(parameter.arg == "env" for parameter in parameters) + or "env" in keyword_only + ) and sum(keyword.arg == "env" for keyword in call.keywords) == 1 + if any(isinstance(argument, ast.Starred) for argument in call.args): + return False + if any(keyword.arg == "env" for keyword in call.keywords): + return False + return any( + index < len(parameters) and parameters[index].arg == "env" + for index, argument in enumerate(call.args) + if argument is node + ) + + def python_git_environment_isolated(node, tree, parents, seen=None): """Trace only literal Git config maps or the packet's two filtered child maps.""" if seen is None: @@ -15340,7 +15470,12 @@ def python_git_environment_isolated(node, tree, parents, seen=None): and candidate.id == node.id and python_enclosing_scope(candidate, parents) is not scope ): - return False + candidate_scope = python_enclosing_scope(candidate, parents) + if not reviewed_python_git_name_bound_in_scope( + tree, parents, candidate_scope, node.id + ): + return False + continue if python_enclosing_scope(candidate, parents) is not scope: continue if ( @@ -15361,6 +15496,10 @@ def python_git_environment_isolated(node, tree, parents, seen=None): and parent.value is candidate ): continue + if reviewed_python_git_environment_argument( + candidate, tree, parents + ): + continue if ( isinstance(parent, ast.Attribute) and parent.value is candidate @@ -15662,7 +15801,6 @@ python_reviewed_read_path_names = { # 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"), @@ -15702,8 +15840,8 @@ def python_reviewed_read_path_parameter(node, tree, parents): 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.""" +def python_reviewed_markdown_file_value(node, tree, parents=None): + """Prove the link checker Path(name) value came from isolated Git output.""" if not ( isinstance(node, ast.Call) and python_dotted_name(node.func) in {"Path", "pathlib.Path"} @@ -15713,48 +15851,261 @@ def python_reviewed_markdown_file_value(node, tree): and node.args[0].id == "name" ): return False - files_from_git = False + if parents is None: + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + scope = python_enclosing_scope(node, parents) + files_assignments = [] for candidate in ast.walk(tree): - if not isinstance(candidate, ast.Assign): + if not isinstance(candidate, (ast.Assign, ast.AnnAssign)): continue - if not any( - isinstance(target, ast.Name) and target.id == "files" - for target in candidate.targets - ): + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + if python_enclosing_scope(candidate, parents) is not scope: 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"] + if any( + isinstance(target, ast.Name) and target.id == "files" + for target in targets ): - continue - files_from_git = True - break - if not files_from_git: + files_assignments.append(candidate) + if len(files_assignments) != 1: return False - return any( - isinstance(candidate, ast.For) + value = files_assignments[0].value + if not ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Attribute) + and value.func.attr == "splitlines" + and not value.args + and not value.keywords + ): + return False + query = value.func.value + if not ( + isinstance(query, ast.Call) + and python_dotted_name(query.func) == "subprocess.check_output" + and query.args + and len(query.args) == 1 + and reviewed_python_git_child_environment(query, tree, parents) + ): + return False + builder = query.args[0] + if not ( + isinstance(builder, ast.Call) + and python_dotted_name(builder.func) == "git_command" + and len(builder.args) == 1 + and not builder.keywords + and isinstance(builder.args[0], (ast.List, ast.Tuple)) + and [ + item.value for item in builder.args[0].elts + if isinstance(item, ast.Constant) + ] == ["ls-files", "--", "*.md"] + and reviewed_python_git_command_origin(builder, tree, parents) + ): + return False + files_targets = ( + files_assignments[0].targets + if isinstance(files_assignments[0], ast.Assign) + else [files_assignments[0].target] + ) + if not ( + len(files_targets) == 1 + and isinstance(files_targets[0], ast.Name) + and files_targets[0].id == "files" + ): + return False + source_loops = [ + 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 scope + and any(current is candidate for current in _python_parent_chain(node, parents)) + ] + if len(source_loops) != 1: + return False + source_loop = source_loops[0] + files_names = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) + and candidate.id == "files" + ] + name_stores = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) + and candidate.id == "name" + and isinstance(candidate.ctx, (ast.Store, ast.Del)) + ] + return ( + len(files_names) == 2 + and files_targets[0] in files_names + and source_loop.iter in files_names + and len(name_stores) == 1 + and name_stores[0] is source_loop.target + ) + + +def python_reviewed_markdown_source_path(node, tree, parents): + """Allow tracked Markdown reads only after rejecting symlink/path escapes.""" + if not isinstance(node, ast.Name) or node.id != "source_path": + return False + scope = python_enclosing_scope(node, parents) + source_loops = [ + 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 scope + and any(current is candidate for current in _python_parent_chain(node, parents)) + ] + if len(source_loops) != 1: + return False + source_loop = source_loops[0] + + def inside_source_loop(candidate): + return any( + current is source_loop + for current in _python_parent_chain(candidate, parents) + ) + + source_assignments = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Assign) + and python_enclosing_scope(candidate, parents) is scope + and inside_source_loop(candidate) + and any( + isinstance(target, ast.Name) and target.id == "source" + for target in candidate.targets + ) + ] + path_assignments = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Assign) + and python_enclosing_scope(candidate, parents) is scope + and inside_source_loop(candidate) + and any( + isinstance(target, ast.Name) and target.id == "source_path" + for target in candidate.targets + ) + ] + root_assignments = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Assign) + and python_enclosing_scope(candidate, parents) is scope + and any( + isinstance(target, ast.Name) and target.id == "repository_root" + for target in candidate.targets + ) + ] + if not ( + len(source_assignments) == 1 + and isinstance(source_assignments[0].value, ast.Call) + and python_reviewed_markdown_file_value( + source_assignments[0].value, tree, parents + ) + and len(path_assignments) == 1 + and isinstance(path_assignments[0].value, ast.Call) + and isinstance(path_assignments[0].value.func, ast.Attribute) + and path_assignments[0].value.func.attr == "resolve" + and not path_assignments[0].value.args + and not path_assignments[0].value.keywords + and isinstance(path_assignments[0].value.func.value, ast.BinOp) + and isinstance(path_assignments[0].value.func.value.op, ast.Div) + and isinstance(path_assignments[0].value.func.value.left, ast.Name) + and path_assignments[0].value.func.value.left.id == "repository_root" + and isinstance(path_assignments[0].value.func.value.right, ast.Name) + and path_assignments[0].value.func.value.right.id == "source" + and len(root_assignments) == 1 + and isinstance(root_assignments[0].value, ast.Call) + and ast.dump(root_assignments[0].value, include_attributes=False) + == ast.dump(ast.parse("Path.cwd().resolve()", mode="eval").body, include_attributes=False) + ): + return False + if any( + isinstance(candidate, ast.Name) + and candidate.id == "source_path" + and isinstance(candidate.ctx, ast.Store) + and candidate is not next( + name for name in ast.walk(path_assignments[0]) + if isinstance(name, ast.Name) and name.id == "source_path" + ) for candidate in ast.walk(tree) + ): + return False + read_position = (node.lineno, node.col_offset) + + def before_read(candidate): + return (candidate.lineno, candidate.col_offset) < read_position + + def has_continue(node): + return any(isinstance(child, ast.Continue) for child in node.body) + + safe_path_guards = [ + candidate for candidate in ast.walk(source_loop) + if isinstance(candidate, ast.If) + and before_read(candidate) + and has_continue(candidate) + ] + lexical_guard = False + symlink_guard = False + for candidate in safe_path_guards: + test = candidate.test + if ( + isinstance(test, ast.BoolOp) + and isinstance(test.op, ast.Or) + and len(test.values) == 2 + and isinstance(test.values[0], ast.Call) + and python_dotted_name(test.values[0].func) == "source.is_absolute" + and not test.values[0].args + and not test.values[0].keywords + and isinstance(test.values[1], ast.Compare) + and isinstance(test.values[1].left, ast.Constant) + and test.values[1].left.value == ".." + and len(test.values[1].ops) == 1 + and isinstance(test.values[1].ops[0], ast.In) + and isinstance(test.values[1].comparators[0], ast.Attribute) + and test.values[1].comparators[0].attr == "parts" + and isinstance(test.values[1].comparators[0].value, ast.Name) + and test.values[1].comparators[0].value.id == "source" + ): + lexical_guard = True + if ( + isinstance(test, ast.Call) + and python_dotted_name(test.func) == "source.is_symlink" + and not test.args + and not test.keywords + ): + symlink_guard = True + containment_guard = any( + isinstance(candidate, ast.Try) + and candidate.end_lineno < node.lineno + and any( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Call) + and python_dotted_name(statement.value.func) == "source_path.relative_to" + and len(statement.value.args) == 1 + and isinstance(statement.value.args[0], ast.Name) + and statement.value.args[0].id == "repository_root" + for statement in candidate.body + ) + and any( + isinstance(handler.type, ast.Name) + and handler.type.id == "ValueError" + and has_continue(handler) + for handler in candidate.handlers + ) + and not python_try_in_unreachable_if_body(candidate, parents) + for candidate in ast.walk(source_loop) ) + return lexical_guard and symlink_guard and containment_guard def python_reviewed_read_path(node, tree, parents, seen=None): @@ -16608,7 +16959,10 @@ def python_reviewed_go_package_directory(node, tree, parents): 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 check_output_keywords["env"].id == "root_git_environment" + and python_git_environment_isolated( + check_output_keywords["env"], tree, parents + ) and isinstance(check_output_keywords["text"], ast.Constant) and check_output_keywords["text"].value is True ): @@ -16664,11 +17018,18 @@ def python_reviewed_go_package_directory(node, tree, parents): 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 len(snapshot_binding.value.args) == 2 and [ argument.id for argument in snapshot_binding.value.args if isinstance(argument, ast.Name) - ] == ["repo_root", "module_dir", "env"] + ] == ["repo_root", "module_dir"] + and len(snapshot_binding.value.keywords) == 1 + and snapshot_binding.value.keywords[0].arg == "env" + and isinstance(snapshot_binding.value.keywords[0].value, ast.Name) + and snapshot_binding.value.keywords[0].value.id == "package_git_environment" + and python_git_environment_isolated( + snapshot_binding.value.keywords[0].value, tree, parents + ) and root_binding is not None and isinstance(root_binding.value, ast.Name) and root_binding.value.id == "source_snapshot_root" @@ -16927,7 +17288,7 @@ def python_reviewed_markdown_link_target_path(node, tree, parents): for target in candidate.targets ) and isinstance(candidate.value, ast.Call) - and python_reviewed_markdown_file_value(candidate.value, tree) + and python_reviewed_markdown_file_value(candidate.value, tree, parents) for candidate in ast.walk(source_loop) ) target_from_link_text = any( @@ -17070,6 +17431,10 @@ def python_reviewed_markdown_link_target_path(node, tree, parents): def python_reviewed_path_reader(node, method, tree, parents): if python_reviewed_read_path(node, tree, parents): return True + if method == "read_text" and python_reviewed_markdown_source_path( + node, tree, parents + ): + return True if method in {"glob", "rglob", "iterdir", "walk", "is_dir"} and ( python_reviewed_go_package_directory(node, tree, parents) ): @@ -17078,7 +17443,7 @@ def python_reviewed_path_reader(node, method, tree, parents): node, tree, parents ): return True - return method == "is_file" and python_reviewed_markdown_link_target_path( + return method in {"is_file", "read_text"} and python_reviewed_markdown_link_target_path( node, tree, parents ) @@ -20197,7 +20562,7 @@ sanitized synthetic environment; the historical Git probe used a temporary repository only and did not touch this worktree. The exact command is retained below for reproducibility: -```sh +```text set -euo pipefail [ "${PATH-}" = "/opt/homebrew/bin:/usr/bin:/bin" ] && [ -x /opt/homebrew/bin/python3 ] || { printf '%s\n' 'reviewed canonical PATH and absolute Python interpreter required' >&2; exit 1; } [ -z "${LD_PRELOAD-}" ] && [ -z "${LD_PRELOAD_32-}" ] && [ -z "${LD_PRELOAD_64-}" ] && [ -z "${LD_LIBRARY_PATH-}" ] && [ -z "${LD_LIBRARY_PATH_32-}" ] && [ -z "${LD_LIBRARY_PATH_64-}" ] && [ -z "${LD_AUDIT-}" ] && [ -z "${DYLD_INSERT_LIBRARIES-}" ] && [ -z "${DYLD_LIBRARY_PATH-}" ] && [ -z "${DYLD_FALLBACK_LIBRARY_PATH-}" ] && [ -z "${DYLD_FRAMEWORK_PATH-}" ] && [ -z "${DYLD_FALLBACK_FRAMEWORK_PATH-}" ] && [ -z "${DYLD_ROOT_PATH-}" ] || { printf '%s\n' 'inherited dynamic-loader hooks are not allowed before Python startup' >&2; exit 1; } @@ -22134,7 +22499,7 @@ after supplying inherited custom `GOSUMDB`/`GOPROXY`, proving that the old wrapper neither refused the trust settings nor queried effective `GOVERSION`. No Go child, test body, live operation or credential-bearing process ran. -```sh +```text set -euo pipefail export PATH=/opt/homebrew/bin:/usr/bin:/bin [ "${PATH-}" = "/opt/homebrew/bin:/usr/bin:/bin" ] && [ -x /opt/homebrew/bin/python3 ] || { printf '%s\n' 'reviewed canonical PATH and absolute Python interpreter required' >&2; exit 1; } @@ -22380,6 +22745,29 @@ for inherited, assignment, expected in (({"GOSUMDB": "sum.invalid+deadbeef"}, () os.environ.clear() os.environ.update(saved_env) +git_query_environment = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_ATTR_NOSYSTEM": "1", + "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", +} + + +def git_command(arguments): + return [ + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", *arguments, + ] + + with tempfile.TemporaryDirectory() as td: root = Path(td) env = {"PATH": "/opt/homebrew/bin:/usr/bin:/bin", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null", "HOME": td} @@ -22392,8 +22780,14 @@ with tempfile.TemporaryDirectory() as td: hook.write_text(f"#!/bin/sh\nprintf invoked > {marker}\nprintf 'builtin:fake\\n'\n", encoding="utf-8") hook.chmod(0o700) subprocess.run(["git", "config", "core.fsmonitor", str(hook)], cwd=root, env=env, check=True) - guarded = ["git", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "status", "--porcelain=v1", "--untracked-files=all", "--ignored=matching", "--", "."] - result = subprocess.run(guarded, cwd=root, env=env, capture_output=True, text=True, check=False) + guarded = git_command([ + "status", "--porcelain=v1", "--untracked-files=all", + "--ignored=matching", "--", ".", + ]) + result = subprocess.run( + guarded, cwd=root, env=git_query_environment, + capture_output=True, text=True, check=False, + ) if result.returncode != 0 or marker.exists(): raise SystemExit("guarded status invoked configured fsmonitor hook") print("GREEN focused packet regression: passed; all source-status/intent Git queries use core.fsmonitor=false and core.hooksPath=/dev/null with isolated config; inherited/command custom GOSUMDB/GOPROXY refused before child, reviewed trust/effective GOVERSION query and identity binding present; subprocess/os aliases and unresolved command-capable calls fail closed; no Go/live child started") @@ -22637,7 +23031,7 @@ parent's static scanner text, synthetic shell/AST inputs and a temporary Git repository; it did not execute the Python heredoc body, a forbidden command, a Go child or any live operation: -```sh +```text set -euo pipefail [ "${PATH-}" = "/opt/homebrew/bin:/usr/bin:/bin" ] && [ -x /opt/homebrew/bin/python3 ] || { printf '%s\n' 'reviewed canonical PATH and absolute Python interpreter required' >&2; exit 1; } [ -z "${LD_PRELOAD-}" ] && [ -z "${LD_PRELOAD_32-}" ] && [ -z "${LD_PRELOAD_64-}" ] && [ -z "${LD_LIBRARY_PATH-}" ] && [ -z "${LD_LIBRARY_PATH_32-}" ] && [ -z "${LD_LIBRARY_PATH_64-}" ] && [ -z "${LD_AUDIT-}" ] && [ -z "${DYLD_INSERT_LIBRARIES-}" ] && [ -z "${DYLD_LIBRARY_PATH-}" ] && [ -z "${DYLD_FALLBACK_LIBRARY_PATH-}" ] && [ -z "${DYLD_FRAMEWORK_PATH-}" ] && [ -z "${DYLD_FALLBACK_FRAMEWORK_PATH-}" ] && [ -z "${DYLD_ROOT_PATH-}" ] || { printf '%s\n' 'inherited dynamic-loader hooks are not allowed before Python startup' >&2; exit 1; } @@ -23546,8 +23940,9 @@ synthetic temporary Git repository for the mutation witness, and patched child boundaries. It uses a short synthetic deadline and a reduced test byte cap, so it never waits for the reviewed 300-second deadline and starts no compiler, Go child, test body, workflow, live operation or credential-bearing process. +This retained probe is historical source data, not a current run prescription. -```sh +```text set -euo pipefail [ "${PATH-}" = "/opt/homebrew/bin:/usr/bin:/bin" ] && [ -x /opt/homebrew/bin/python3 ] || { printf '%s\n' 'reviewed canonical PATH and absolute Python interpreter required' >&2; exit 1; } [ -z "${LD_PRELOAD-}" ] && [ -z "${LD_PRELOAD_32-}" ] && [ -z "${LD_PRELOAD_64-}" ] && [ -z "${LD_LIBRARY_PATH-}" ] && [ -z "${LD_LIBRARY_PATH_32-}" ] && [ -z "${LD_LIBRARY_PATH_64-}" ] && [ -z "${LD_AUDIT-}" ] && [ -z "${DYLD_INSERT_LIBRARIES-}" ] && [ -z "${DYLD_LIBRARY_PATH-}" ] && [ -z "${DYLD_FALLBACK_LIBRARY_PATH-}" ] && [ -z "${DYLD_FRAMEWORK_PATH-}" ] && [ -z "${DYLD_FALLBACK_FRAMEWORK_PATH-}" ] && [ -z "${DYLD_ROOT_PATH-}" ] || { printf '%s\n' 'inherited dynamic-loader hooks are not allowed before Python startup' >&2; exit 1; } @@ -24262,7 +24657,7 @@ Git delegation strings were passed only to pure token functions; the parent wrapper's prospective Go-child environment was inspected without starting Go, and the temporary fsmonitor hook was the only synthetic external Git behavior. -```sh +```text set -euo pipefail # g01-safe-python-heredoc: reviewed immutable-parent fresh-P2 red probes [ "${PATH-}" = "/opt/homebrew/bin:/usr/bin:/bin" ] && [ -x /opt/homebrew/bin/python3 ] || { printf '%s\n' 'reviewed canonical PATH and absolute Python interpreter required' >&2; exit 1; } @@ -33850,3 +34245,143 @@ and the added-line path/credential scan examined 230 lines with zero matches. Independent final-source review and the coordinator packet selector remain required before a candidate can be considered complete. These focused checks do not claim a universal Python sandbox or resolve broader live-operation gates. + +#### Writer bounded executable-prescription correction at local 3b7202bc + +At entry on 2026-10-03, local `HEAD` was +`3b7202bca95d647e495363703b0a805d58c52d2e`; the worktree was clean. The +coordinator's frozen packet selector had failed one test in 43.662s with ten +violations; this worker did not rerun that selector, the full harness, or any +live command. Edits remained limited to this packet and +`scripts/evidence_packet/issue79_regression_test.py`. + +The compatibility correction makes the currently executable Go wrapper pass +the literal isolated Git environment to source-root lookup, package +initialization, checkout rechecks, status, intent-bit, raw-blob and snapshot +queries. Scope-specific environment names keep the read-only path proof +distinct from function-local maps; the proof now checks the exact two-argument +snapshot call and its isolated `env` keyword. The environment checker accepts +only a direct positional/keyword value passed to one statically bound `env` +parameter, then follows all helper callers; captured outer maps and extra uses +remain refused. + +The current Markdown audit retains an executable canonical `git_command` and +the complete isolated environment. It reads only paths from the literal +`git ls-files -- "*.md"` selection, rejects absolute, parent-traversal and +symlink names, resolves each source under the repository root before reading, +and checks resolved link targets inside the same root. The broad +`anchors(path)` parameter exception was removed; anchors now consume Markdown +text. An inert `anchors(path)` plus synthetic private-file disclosure witness +is refused. The current temporary-repository fsmonitor control remains an +executable probe; its status query now uses the same canonical builder and +isolated environment, while fixture setup stays scoped to its temporary tree. + +The argv regression now includes the concrete sibling-alias witness +`argv = saved = git_command([...]); saved[:] = ["/usr/bin/git", "status"]; +subprocess.run(argv, env=git_environment)`. It was RED as one unexpected +acceptance (one method, 0.163s). A canonical argv assignment must now have one +target, so chained bindings and their aliases cannot mutate a certified +command. Parameterized safe environments and canonical direct/helper flows +remain covered; an inner capture of the outer environment remains refused. + +Older selector, package-init, exact-parent red and focused-Git transcript +blocks that predate these guards are now fenced as `text`. Their code and +recorded output are unchanged. The selector regression checks those historical +fences, and a separate static recipe selector ensures the active wrapper, +Markdown checker and synthetic Git control remain executable and accepted. + +The final seven-method focused command passed in 4.679s. It covered +`test_python_git_queries_require_isolated_environment_and_known_builder`, +`test_python_git_builder_rebinding_is_not_certified`, +`test_python_git_argv_mutation_is_not_certified`, +`test_current_executable_recipes_keep_git_and_path_provenance`, +`test_unbound_anchor_reader_cannot_read_or_disclose_paths`, +`test_historical_unsafe_git_transcripts_are_inert_source_text`, and +`test_markdown_link_containment_guard_must_be_direct_and_reachable`. The +dedicated compile-helper and historical-loader controls each also passed once; +the helper control reported 119 calls, while earlier shared evidence of 128 +compiler-helper checks and ten loader exports remains unchanged. The path +fixture was updated to model the current tracked-file and containment proof; +its negative variants still reject unreachable, nested, rebound and +pre-assignment guards. + +The correction has no new source revisions, compiler-input names or helper +whitelists. Review specimens were parsed as source data only; no hook, +adversarial body, Go child, packet selector, full suite or live resource was +executed. Final whitespace/private-path hygiene, local commit and exact +candidate SHA are reported at handoff; coordinator-owned packet and independent +review gates remain outstanding. + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_current_executable_recipes_keep_git_and_path_provenance \ + Issue79RegressionTests.test_unbound_anchor_reader_cannot_read_or_disclose_paths \ + Issue79RegressionTests.test_historical_unsafe_git_transcripts_are_inert_source_text \ + Issue79RegressionTests.test_markdown_link_containment_guard_must_be_direct_and_reachable +Ran 7 tests in 4.679s — OK +``` + +#### Follow-up Markdown source-list provenance closure at local 3b7202bc + +A bounded AST-data probe found three accepted mutations of the active Markdown +link audit: appending a path to the Git-derived `files` list, appending through +a saved alias, and rebinding the `name` loop target before `Path(name)`. The +new `test_markdown_source_paths_cannot_escape_git_list_origin` first accepted +the unchanged recipe and then failed on all three mutations in 0.399s; no +mutated source body was executed. An added nested-helper mutation was also +accepted and failed its regression in 0.387s. The scanner now requires exactly +the Git query assignment and its one `for name in files` consumer, with no +other `files` references or `name` stores/deletes anywhere in the heredoc, +including nested scopes. This keeps the existing tracked-Markdown recipe +accepted while rejecting direct and aliased list mutation, closure mutation +and loop-target reassignment. + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_markdown_source_paths_cannot_escape_git_list_origin \ + Issue79RegressionTests.test_current_executable_recipes_keep_git_and_path_provenance \ + Issue79RegressionTests.test_unbound_anchor_reader_cannot_read_or_disclose_paths \ + Issue79RegressionTests.test_markdown_link_containment_guard_must_be_direct_and_reachable \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_historical_unsafe_git_transcripts_are_inert_source_text +Ran 8 tests in 4.770s — OK + +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +Ran 2 tests in 5.203s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +After extending the proof to reject nested-scope references, the final bounded +combined run executed the same eight path/Git/history selectors together with +the two actual-helper and historic-loader controls above: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_markdown_source_paths_cannot_escape_git_list_origin \ + Issue79RegressionTests.test_current_executable_recipes_keep_git_and_path_provenance \ + Issue79RegressionTests.test_unbound_anchor_reader_cannot_read_or_disclose_paths \ + Issue79RegressionTests.test_markdown_link_containment_guard_must_be_direct_and_reachable \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_historical_unsafe_git_transcripts_are_inert_source_text \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +Ran 10 tests in 10.087s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +No new compiler input or source revision was added. These focused selectors +preserved the active recipes and 119 actual helper checks plus the historical +loader controls; they are not a full harness or packet-wide scan. Final +whitespace and added-line private-path/credential hygiene, the exact local +commit state and coordinator-owned packet/review gates are reported at handoff. +Final `git diff --check` passed; the added-line personal-path, credential-token +and private-key pattern scan covered 798 lines with zero matches. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index d25ea777..01919d23 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -557,6 +557,17 @@ def setUpClass(cls) -> None: def inspect(self, code: str) -> str | None: return self.scanner["inspect_python_heredoc"](code, False) # type: ignore[operator] + def packet_heredoc_containing(self, *fragments: str) -> tuple[str, bool]: + matches = [ + (body, marker) + for _number, body, marker, _invocation in self.scanner[ + "python_heredoc_bodies" + ](PACKET_TEXT) # type: ignore[operator] + if all(fragment in body for fragment in fragments) + ] + self.assertEqual(1, len(matches), f"expected one packet heredoc containing {fragments!r}") + return matches[0] + def markdown_link_target_path_is_reviewed(self, code: str) -> bool: tree = ast.parse(code, filename="") parents = { @@ -580,41 +591,55 @@ def test_markdown_link_containment_guard_must_be_direct_and_reachable(self) -> N canonical = ( 'import subprocess\n' 'from pathlib import Path\n' - 'files = subprocess.check_output(["git", "ls-files", "*.md"], text=True).splitlines()\n' + 'git_query_environment = {"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null", "GIT_ATTR_NOSYSTEM": "1", "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"}\n' + 'def git_command(arguments):\n' + ' return ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", "-P", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", *arguments]\n' + 'files = subprocess.check_output(git_command(["ls-files", "--", "*.md"]), env=git_query_environment, 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' + ' if source.is_absolute() or ".." in source.parts:\n' + ' continue\n' + ' if source.is_symlink():\n' + ' continue\n' + ' source_path = (repository_root / source).resolve()\n' + ' try:\n' + ' source_path.relative_to(repository_root)\n' + ' except ValueError:\n' + ' continue\n' + ' markdown = source_path.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' + ' fragment = target[1:]\n' + ' target = source.name\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' + ' fragment = fragment if separator else None\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', - ' 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', - ' if False:\n' - ' try:\n' - ' path.relative_to(repository_root)\n' - ' except ValueError:\n' - ' continue\n', 1, ) path_rebound_after_guard = canonical.replace( @@ -624,16 +649,16 @@ def test_markdown_link_containment_guard_must_be_direct_and_reachable(self) -> 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', + ' 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) @@ -3622,6 +3647,113 @@ def test_standalone_git_queries_require_explicit_hook_isolation(self) -> None: ) ) + def test_current_executable_recipes_keep_git_and_path_provenance(self) -> None: + recipes = ( + ( + "current bounded Go wrapper Git queries", + ( + "def git_source_control_entries(repo_root, module_dir, env):", + "def git_command(arguments):", + "package_initialization_guard()", + ), + ), + ( + "current Markdown link audit", + ( + 'files = subprocess.check_output(', + "def anchors(markdown):", + "source_path.read_text(encoding=\"utf-8\")", + ), + ), + ( + "current synthetic Git hook control", + ( + 'marker = root / "fsmonitor-invoked"', + 'guarded = git_command([', + '"core.hooksPath=/dev/null", *arguments', + 'print("GREEN focused packet regression:', + ), + ), + ) + for label, fragments in recipes: + with self.subTest(recipe=label): + body, marker = self.packet_heredoc_containing(*fragments) + self.assertIsNone( + self.scanner["inspect_python_heredoc"](body, marker), # type: ignore[operator] + label, + ) + + def test_markdown_source_paths_cannot_escape_git_list_origin(self) -> None: + body, marker = self.packet_heredoc_containing( + 'files = subprocess.check_output(', + 'def anchors(markdown):', + 'source_path.read_text(encoding="utf-8")', + ) + inspect = self.scanner["inspect_python_heredoc"] + self.assertIsNone(inspect(body, marker)) # type: ignore[operator] + mutations = { + "direct append": body.replace( + 'repository_root = Path.cwd().resolve()\n', + 'files.append("experiments/g01-scaleset/.env")\n' + 'repository_root = Path.cwd().resolve()\n', + 1, + ), + "saved-list append": body.replace( + 'repository_root = Path.cwd().resolve()\n', + 'files_alias = files\n' + 'files_alias.append("experiments/g01-scaleset/.env")\n' + 'repository_root = Path.cwd().resolve()\n', + 1, + ), + "nested mutator": body.replace( + 'repository_root = Path.cwd().resolve()\n', + 'def append_untracked_path():\n' + ' files.append("experiments/g01-scaleset/.env")\n' + 'append_untracked_path()\n' + 'repository_root = Path.cwd().resolve()\n', + 1, + ), + "loop variable rebound": body.replace( + ' source = Path(name)\n', + ' name = "experiments/g01-scaleset/.env"\n' + ' source = Path(name)\n', + 1, + ), + } + for label, source in mutations.items(): + with self.subTest(mutation=label): + self.assertIsNotNone( + inspect(source, marker), # type: ignore[operator] + f"Markdown read lost Git-list provenance after {label}", + ) + + def test_unbound_anchor_reader_cannot_read_or_disclose_paths(self) -> None: + self.assertIsNotNone(self.inspect( + 'from pathlib import Path\n' + 'def anchors(path):\n' + ' return path.read_text(encoding="utf-8")\n' + 'print(anchors(Path("synthetic-private/file")))\n' + )) + + def test_historical_unsafe_git_transcripts_are_inert_source_text(self) -> None: + historical_sections = ( + "The four focused offline selector checks below are historical list-only source", + "block is the historical pre-isolation package-init audit", + "The red extraction and assertions were run from the immutable parent with a", + "The three red probes ran first against the exact immutable parent.", + "The red probe ran first against the exact immutable parent. It used only the\nparent's static scanner text", + "The focused green probe uses fake Go Popen objects backed by synthetic pipes", + "The red probes ran first against immutable parent\n`3f6de0b227e4b44aa3d5e259e937e7dc1f0856bb`", + ) + for section in historical_sections: + with self.subTest(section=section): + section_start = PACKET_TEXT.index(section) + fence_start = PACKET_TEXT.index("```", section_start) + self.assertTrue( + PACKET_TEXT.startswith("```text", fence_start), + "historical executable examples must be visibly inert source text", + ) + def test_python_git_queries_require_isolated_environment_and_known_builder(self) -> None: unsafe = ( 'import subprocess\n' @@ -3663,6 +3795,27 @@ def test_python_git_queries_require_isolated_environment_and_known_builder(self) 'subprocess.run(git_command(["status", "--short"]), env=git_environment)\n' ) self.assertIsNone(self.inspect(canonical)) + direct_call = 'subprocess.run(git_command(["status", "--short"]), env=git_environment)' + self.assertIsNone( + self.inspect( + canonical.replace( + direct_call, + 'def query(env):\n' + ' subprocess.run(git_command(["status", "--short"]), env=env)\n' + 'query(git_environment)', + ) + ) + ) + self.assertIsNotNone( + self.inspect( + canonical.replace( + direct_call, + 'def query():\n' + ' subprocess.run(git_command(["status", "--short"]), env=git_environment)\n' + 'query()', + ) + ) + ) self.assertIsNotNone( self.inspect(canonical.rsplit("subprocess.run(", 1)[0] + 'subprocess.run(git_command(["status", "--short"]))\n') @@ -3807,6 +3960,15 @@ def test_python_git_argv_mutation_is_not_certified(self) -> None: 'wrapper()\n' ) self.assertIsNotNone(self.inspect(prelude + nonlocal_mutation)) + chained_alias = ( + prelude.replace( + 'argv = git_command(["status", "--short"])\n', + 'argv = saved_argv = git_command(["status", "--short"])\n', + ) + + 'saved_argv[:] = ["/usr/bin/git", "status"]\n' + + call + ) + self.assertIsNotNone(self.inspect(chained_alias)) def test_warning_sink_and_absolute_path_do_not_disclose_local_data(self) -> None: self.assertIsNotNone(self.inspect( From e0c8156ca81d680448918c4ff1ca9c4e353d575a Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 20:52:45 +0900 Subject: [PATCH 056/123] Close Issue 79 evidence recipe provenance gaps --- docs/evidence/g01-recovery-packet.md | 174 ++++++++++++++---- .../issue79_regression_test.py | 75 ++++++++ 2 files changed, 211 insertions(+), 38 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 6603c7a4..73490882 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10512,17 +10512,19 @@ def python_sensitive_member_assignment_index(tree, parents): 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): + for call_node in ast.walk(tree): + if not isinstance(call_node, ast.Call) or not isinstance(call_node.func, ast.Name): continue - writes = helper_writes.get(call.func.id, ()) + writes = helper_writes.get(call_node.func.id, ()) if not writes: continue - visible = python_local_function_candidates(call.func.id, call, tree, parents) + visible = python_local_function_candidates( + call_node.func.id, call_node, tree, parents + ) for function, position, prefix, key, value in writes: - if function not in visible or position >= len(call.args): + if function not in visible or position >= len(call_node.args): continue - argument = call.args[position] + argument = call_node.args[position] if isinstance(argument, ast.Name): alias_key = "member:name:" + argument.id + ":" + key[len(prefix):] index.setdefault(alias_key, []).append(value) @@ -13139,13 +13141,13 @@ def reviewed_python_helper_definition(node, parents): ) if function_node is None: return False - for call in ast.walk(function_node): - if not isinstance(call, ast.Call): + for call_node in ast.walk(function_node): + if not isinstance(call_node, 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) + if isinstance(call_node.func, ast.Name): + calls.add(call_node.func.id) + elif isinstance(call_node.func, ast.Attribute): + calls.add(call_node.func.attr) return required.issubset(calls) @@ -15405,17 +15407,17 @@ def reviewed_python_git_environment_argument(node, tree, parents): """Recognize only a direct value passed to one statically bound env parameter.""" parent = parents.get(node) if isinstance(parent, ast.keyword): - call = parents.get(parent) + call_node = parents.get(parent) argument_name = parent.arg elif isinstance(parent, ast.Call) and node in parent.args: - call = parent + call_node = parent argument_name = None else: return False - if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name): + if not isinstance(call_node, ast.Call) or not isinstance(call_node.func, ast.Name): return False - function_name = call.func.id - call_scope = python_enclosing_scope(call, parents) + function_name = call_node.func.id + call_scope = python_enclosing_scope(call_node, parents) definitions = [ candidate for candidate in ast.walk(tree) if isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) @@ -15437,14 +15439,14 @@ def reviewed_python_git_environment_argument(node, tree, parents): return argument_name == "env" and ( any(parameter.arg == "env" for parameter in parameters) or "env" in keyword_only - ) and sum(keyword.arg == "env" for keyword in call.keywords) == 1 - if any(isinstance(argument, ast.Starred) for argument in call.args): + ) and sum(keyword.arg == "env" for keyword in call_node.keywords) == 1 + if any(isinstance(argument, ast.Starred) for argument in call_node.args): return False - if any(keyword.arg == "env" for keyword in call.keywords): + if any(keyword.arg == "env" for keyword in call_node.keywords): return False return any( index < len(parameters) and parameters[index].arg == "env" - for index, argument in enumerate(call.args) + for index, argument in enumerate(call_node.args) if argument is node ) @@ -15999,7 +16001,6 @@ def python_reviewed_markdown_source_path(node, tree, parents): root_assignments = [ candidate for candidate in ast.walk(tree) if isinstance(candidate, ast.Assign) - and python_enclosing_scope(candidate, parents) is scope and any( isinstance(target, ast.Name) and target.id == "repository_root" for target in candidate.targets @@ -16051,8 +16052,9 @@ def python_reviewed_markdown_source_path(node, tree, parents): safe_path_guards = [ candidate for candidate in ast.walk(source_loop) if isinstance(candidate, ast.If) + and parents.get(candidate) is source_loop and before_read(candidate) - and has_continue(candidate) + and any(isinstance(statement, ast.Continue) for statement in candidate.body) ] lexical_guard = False symlink_guard = False @@ -17298,12 +17300,12 @@ def python_reviewed_markdown_link_target_path(node, tree, parents): 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) + isinstance(call_node, ast.Call) + and python_dotted_name(call_node.func) == "match.group" + and call_node.args + and isinstance(call_node.args[0], ast.Constant) + and call_node.args[0].value == 1 + for call_node in ast.walk(candidate.value) ) for candidate in ast.walk(link_loop) ) @@ -17839,14 +17841,14 @@ def python_sensitive_read_violation(tree, parents): (function, default) ) - for call in ast.walk(tree): - if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name): + for call_node in ast.walk(tree): + if not isinstance(call_node, ast.Call) or not isinstance(call_node.func, ast.Name): continue for function in python_local_function_candidates( - call.func.id, call, tree, parents + call_node.func.id, call_node, tree, parents ): positional = helper_parameters(function) - for index, argument in enumerate(call.args): + for index, argument in enumerate(call_node.args): if isinstance(argument, ast.Starred): for parameter in positional[index:]: assignments_by_name.setdefault(parameter.arg, []).append( @@ -17864,7 +17866,7 @@ def python_sensitive_read_violation(tree, parents): parameter.arg for parameter in positional + function.args.kwonlyargs } - for keyword in call.keywords: + for keyword in call_node.keywords: if keyword.arg in named_parameters: assignments_by_name.setdefault(keyword.arg, []).append( (function, keyword.value) @@ -24538,8 +24540,27 @@ with tempfile.TemporaryDirectory() as directory: ["git", "-c", "user.name=probe", "-c", "user.email=probe@example.invalid", "commit", "-q", "-m", "source"], cwd=root, env=git_env, check=True, ) + git_query_environment = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_SYSTEM": "/dev/null", + "GIT_ATTR_NOSYSTEM": "1", + "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", + } reviewed_tree = subprocess.check_output( - ["git", "rev-parse", "HEAD:experiments/g01-scaleset"], cwd=root, env=git_env, text=True, + [ + "/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", + "-P", "-c", "core.fsmonitor=false", "-c", + "core.hooksPath=/dev/null", "rev-parse", + "HEAD:experiments/g01-scaleset", + ], + cwd=root, env=git_query_environment, text=True, ).strip() namespace = { "os": os, @@ -24584,11 +24605,17 @@ for command in ( violation = scanner_ns["forbidden_command"](segments[0]) if segments else None if violation is None: raise SystemExit(f"alias assertion: shell-form alias accepted: {command!r}") -for command in ("git status", "git -c user.name=probe status"): +for command in ( + "/usr/bin/env -i GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_ATTR_NOSYSTEM=1 /usr/bin/git --no-replace-objects -P -c core.fsmonitor=false -c core.hooksPath=/dev/null status", + "/usr/bin/env -i GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_ATTR_NOSYSTEM=1 /usr/bin/git --no-replace-objects -P -c core.fsmonitor=false -c core.hooksPath=/dev/null diff --stat", +): segments = scanner_ns["shell_token_segments"](command) if not segments or scanner_ns["forbidden_command"](segments[0]) is not None: - raise SystemExit(f"alias assertion: safe Git form rejected: {command!r}") -print("GREEN 4005139673: pure static scanner rejected split/attached/wrapped shell-form Git aliases before generic Git classification; safe git status/config remained accepted and no command executed") + raise SystemExit(f"alias assertion: isolated safe Git form rejected: {command!r}") +unisolated = scanner_ns["shell_token_segments"]("git status") +if not unisolated or scanner_ns["forbidden_command"](unisolated[0]) is None: + raise SystemExit("alias assertion: unisolated Git query was accepted") +print("GREEN 4005139673: pure static scanner rejected split/attached/wrapped shell-form Git aliases and unisolated Git queries before generic Git classification; explicit isolated status/diff controls remained accepted and no command executed") PY ``` @@ -34385,3 +34412,74 @@ whitespace and added-line private-path/credential hygiene, the exact local commit state and coordinator-owned packet/review gates are reported at handoff. Final `git diff --check` passed; the added-line personal-path, credential-token and private-key pattern scan covered 798 lines with zero matches. + +#### Writer bounded executable-recipe and source-path correction from 05044cfc + +At entry on 2026-10-03, `HEAD` was the clean local checkpoint +`05044cfc34a76528d1c8d4baa6a8a195ee59c323`. The coordinator's exact packet +selector had failed one test in 42.123s with two violations: the current +scanner self-audit reported an unresolved command-capable `call` at body line +2263, and the current synthetic vet-mutation recipe contained a Git query +without an isolated environment. This worker did not rerun the packet selector, +full harness or full suite, and did not execute any recipe or adversarial body. + +The scanner self-audit issue came from generic AST binding collection treating +local AST variables named `call` as command-capable callback aliases. The +current scanner's AST-node variables were renamed to `call_node`; no callback, +function or compiler-input allowlist was expanded. A focused regression parses +the active self-audit helper and confirms that its `call` parameter is not +certified as a process callback. + +The current synthetic Git fixture now supplies a distinct literal child +environment with system/global config disabled, system attributes disabled, +`core.fsmonitor=false` and `core.hooksPath=/dev/null`; its current source-root +query uses explicit `env -i`, `--no-replace-objects`, `-P` and the protective +config options. Static controls keep the isolated status/diff forms accepted +and check that an unisolated `git status` is rejected. The temporary fixture +setup remains scoped to its temporary repository. The active recipe remains +executable and is still inspected by the scanner; historical recorded outputs +were not changed. + +The bounded source-list proof was also tightened after independent review. +Assignments to `repository_root` in nested scopes now invalidate its single +canonical binding, and lexical/symlink guards must be direct children of the +tracked-path loop with a direct `continue`, so an unreachable or nested guard +cannot establish source-read safety. The tracked-Markdown positive recipe +remains accepted. Added inert AST mutations cover nested global root retarget, +unreachable lexical and symlink guards, and a symlink `continue` nested under +an unreachable branch; no mutated body was executed. + +The meaningful AST-only RED evidence was: the current synthetic Git recipe +selector failed one test in 0.271s on the missing `env -i` proof; the nested +`repository_root` mutation failed its regression in 0.437s; and the combined +source-path/self-audit/Git selection ran three tests in 3.348s, with the +source-path method exposing two unreachable-guard acceptances after the +root-retarget case had been closed. The final focused command passed 12 +methods in 13.200s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_current_scanner_self_audit_ast_parameters_are_not_callbacks \ + Issue79RegressionTests.test_current_vet_mutation_recipe_git_fixture_is_isolated \ + Issue79RegressionTests.test_markdown_source_paths_cannot_escape_git_list_origin \ + Issue79RegressionTests.test_current_executable_recipes_keep_git_and_path_provenance \ + Issue79RegressionTests.test_unbound_anchor_reader_cannot_read_or_disclose_paths \ + Issue79RegressionTests.test_markdown_link_containment_guard_must_be_direct_and_reachable \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_historical_unsafe_git_transcripts_are_inert_source_text \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +Ran 12 tests in 13.200s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +The run preserves the ten historical-loader controls; this candidate's helper +selector reported 119 actual helper calls, while the previously shared 128-call +result remains evidence for its earlier exact candidate only. No source +revision, compiler-input name, callback exemption or helper whitelist was +added. No full packet scan, full suite, live command or specimen execution is +claimed. The final worktree SHA, focused-only scope, hygiene and local commit +state are reported at handoff; coordinator-owned exact-head packet and +independent review gates remain outstanding. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 01919d23..c95445aa 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3683,6 +3683,41 @@ def test_current_executable_recipes_keep_git_and_path_provenance(self) -> None: label, ) + def test_current_scanner_self_audit_ast_parameters_are_not_callbacks(self) -> None: + body, _marker = self.packet_heredoc_containing( + "def python_local_function_candidates(", + "def inspect_python_heredoc(body, safe_marker):", + "matches = []", + ) + tree = ast.parse(body, filename="") + helper = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "python_local_function_candidates" + ) + source = ast.get_source_segment(body, helper) + self.assertIsNotNone(source) + modules, functions, unresolved = self.scanner["python_import_bindings"](tree) # type: ignore[operator] + self.assertFalse(unresolved) + helper_tree = ast.Module(body=[helper], type_ignores=[]) + self.assertIsNone( + self.scanner["python_callback_command_violation"]( + helper_tree, modules, functions + ) # type: ignore[operator] + ) + + def test_current_vet_mutation_recipe_git_fixture_is_isolated(self) -> None: + body, marker = self.packet_heredoc_containing( + "reviewed_tree = subprocess.check_output(", + "git_env = {", + "GREEN 4005139662:", + ) + self.assertIsNone( + self.scanner["inspect_python_heredoc"](body, marker), # type: ignore[operator] + "current isolated synthetic Git/vet recipe must remain executable", + ) + def test_markdown_source_paths_cannot_escape_git_list_origin(self) -> None: body, marker = self.packet_heredoc_containing( 'files = subprocess.check_output(', @@ -3713,15 +3748,55 @@ def test_markdown_source_paths_cannot_escape_git_list_origin(self) -> None: 'repository_root = Path.cwd().resolve()\n', 1, ), + "nested repository-root retarget": body.replace( + 'for name in files:\n', + 'def retarget_repository_root():\n' + ' global repository_root\n' + ' repository_root = Path("synthetic-private").resolve()\n' + 'retarget_repository_root()\n' + 'for name in files:\n', + 1, + ), "loop variable rebound": body.replace( ' source = Path(name)\n', ' name = "experiments/g01-scaleset/.env"\n' ' source = Path(name)\n', 1, ), + "lexical guard unreachable": body.replace( + ' if source.is_absolute() or ".." in source.parts:\n' + ' errors.append(f"{name}: tracked Markdown path was not relative")\n' + ' continue\n', + ' if False:\n' + ' if source.is_absolute() or ".." in source.parts:\n' + ' errors.append(f"{name}: tracked Markdown path was not relative")\n' + ' continue\n', + 1, + ), + "symlink guard unreachable": body.replace( + ' if source.is_symlink():\n' + ' errors.append(f"{name}: tracked Markdown path was a symlink")\n' + ' continue\n', + ' if False:\n' + ' if source.is_symlink():\n' + ' errors.append(f"{name}: tracked Markdown path was a symlink")\n' + ' continue\n', + 1, + ), + "symlink continue does not dominate": body.replace( + ' if source.is_symlink():\n' + ' errors.append(f"{name}: tracked Markdown path was a symlink")\n' + ' continue\n', + ' if source.is_symlink():\n' + ' if False:\n' + ' errors.append(f"{name}: tracked Markdown path was a symlink")\n' + ' continue\n', + 1, + ), } for label, source in mutations.items(): with self.subTest(mutation=label): + self.assertNotEqual(body, source, f"failed to construct {label} witness") self.assertIsNotNone( inspect(source, marker), # type: ignore[operator] f"Markdown read lost Git-list provenance after {label}", From e7206034600adcae9afae2738dd7be690209ea48 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 21:21:55 +0900 Subject: [PATCH 057/123] Harden Issue 79 evidence path provenance --- docs/evidence/g01-recovery-packet.md | 129 +++++++++++++- .../issue79_regression_test.py | 159 +++++++++++++++--- 2 files changed, 258 insertions(+), 30 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 73490882..5673088d 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14760,7 +14760,11 @@ def python_subprocess_os_reexport_violation(tree): and node.target.id in subprocess_names ): return "Python heredoc rebinds a subprocess-bearing container indirectly" - if isinstance(node, ast.Attribute) and node.attr in {"os", "_os"}: + if ( + isinstance(node, ast.Attribute) + and node.attr in {"os", "_os"} + and may_refer_to_subprocess(node.value) + ): return "Python heredoc accesses an unreviewed OS module re-export" if ( isinstance(node, ast.Call) @@ -16006,6 +16010,48 @@ def python_reviewed_markdown_source_path(node, tree, parents): for target in candidate.targets ) ] + root_binding_nodes = [] + for candidate in ast.walk(tree): + if ( + isinstance(candidate, ast.Name) + and candidate.id == "repository_root" + and isinstance(candidate.ctx, (ast.Store, ast.Del)) + ) or ( + isinstance(candidate, ast.arg) + and candidate.arg == "repository_root" + ) or ( + isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and candidate.name == "repository_root" + ) or ( + isinstance(candidate, ast.ExceptHandler) + and candidate.name == "repository_root" + ) or ( + isinstance(candidate, (ast.MatchAs, ast.MatchStar)) + and candidate.name == "repository_root" + ) or ( + isinstance(candidate, ast.MatchMapping) + and candidate.rest == "repository_root" + ) or ( + isinstance(candidate, (ast.Global, ast.Nonlocal)) + and "repository_root" in candidate.names + ): + root_binding_nodes.append(candidate) + elif isinstance(candidate, ast.Import) and any( + (alias.asname or alias.name.split(".", 1)[0]) == "repository_root" + for alias in candidate.names + ): + root_binding_nodes.append(candidate) + elif isinstance(candidate, ast.ImportFrom) and any( + (alias.asname or alias.name) == "repository_root" + for alias in candidate.names + ): + root_binding_nodes.append(candidate) + root_assignment_target = ( + root_assignments[0].targets[0] + if len(root_assignments) == 1 + and len(root_assignments[0].targets) == 1 + else None + ) if not ( len(source_assignments) == 1 and isinstance(source_assignments[0].value, ast.Call) @@ -16024,7 +16070,9 @@ def python_reviewed_markdown_source_path(node, tree, parents): and path_assignments[0].value.func.value.left.id == "repository_root" and isinstance(path_assignments[0].value.func.value.right, ast.Name) and path_assignments[0].value.func.value.right.id == "source" - and len(root_assignments) == 1 + and isinstance(root_assignment_target, ast.Name) + and root_assignment_target.id == "repository_root" + and root_binding_nodes == [root_assignment_target] and isinstance(root_assignments[0].value, ast.Call) and ast.dump(root_assignments[0].value, include_attributes=False) == ast.dump(ast.parse("Path.cwd().resolve()", mode="eval").body, include_attributes=False) @@ -16043,8 +16091,11 @@ def python_reviewed_markdown_source_path(node, tree, parents): return False read_position = (node.lineno, node.col_offset) - def before_read(candidate): - return (candidate.lineno, candidate.col_offset) < read_position + def finishes_before_read(candidate): + return ( + candidate.end_lineno, + candidate.end_col_offset, + ) < read_position def has_continue(node): return any(isinstance(child, ast.Continue) for child in node.body) @@ -16053,7 +16104,7 @@ def python_reviewed_markdown_source_path(node, tree, parents): candidate for candidate in ast.walk(source_loop) if isinstance(candidate, ast.If) and parents.get(candidate) is source_loop - and before_read(candidate) + and finishes_before_read(candidate) and any(isinstance(statement, ast.Continue) for statement in candidate.body) ] lexical_guard = False @@ -16088,7 +16139,7 @@ def python_reviewed_markdown_source_path(node, tree, parents): symlink_guard = True containment_guard = any( isinstance(candidate, ast.Try) - and candidate.end_lineno < node.lineno + and finishes_before_read(candidate) and any( isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call) @@ -34483,3 +34534,69 @@ added. No full packet scan, full suite, live command or specimen execution is claimed. The final worktree SHA, focused-only scope, hygiene and local commit state are reported at handoff; coordinator-owned exact-head packet and independent review gates remain outstanding. + +#### Writer scoped failure corrections from `e0c8156c` + +At entry the worktree was clean at `e0c8156ca81d680448918c4ff1ca9c4e353d575a`. +The coordinator reported a 163-test offline harness run with four failing +assertions across three methods; its packet selector within that run passed +with 261 shell commands, 86 Python bodies and no violations, including the +119 current compiler-helper checks and ten loader controls. This worker did +not rerun the full harness or packet selector. + +The three reported methods were `test_historical_compile_sources_require_replacement_suppression`, +`test_packet_loader_rejects_unsupported_top_level_assignment` and +`test_reexported_os_module_does_not_bypass_heredoc_checks`. The historical +compile positive fixtures now use the current explicit Git isolation boundary; +missing replacement suppression, system config, fsmonitor and hooks fences +remain negative controls. The loader assertion now mutates the unique extracted +current scanner source assignment and checks that extraction reflects the +mutation before requiring the reviewed-source loader error; no packet-loader +runtime rule was weakened. An OS attribute now requires a statically tracked +subprocess-bearing receiver before it is classified as an OS-module re-export, +preserving the benign `Settings.os = "darwin"` data-owner case and existing +subprocess module/reflection rejection checks. + +The path regression first exposed three accepted inert AST mutations in one +focused method (one test in 0.691s): an annotated root rebind, an augmented +root rebind, and a `source_path.read_text` call inside the `source.is_symlink` +branch before its direct `continue`. The scanner now requires one canonical +simple `repository_root = Path.cwd().resolve()` binding and rejects other +binding forms across the heredoc, including store/delete names, arguments, +definitions, imports, exception/match bindings and global/nonlocal declarations. +Both lexical and symlink guards, as well as the containment `try`, must end +before the read; guards must remain direct children of the tracked-path loop +with a direct `continue`. Additional inert cases cover a function-definition +and import-alias root rebind. The canonical tracked-Markdown reader remains +accepted. + +The focused regression passed after these changes: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_current_scanner_self_audit_ast_parameters_are_not_callbacks \ + Issue79RegressionTests.test_current_vet_mutation_recipe_git_fixture_is_isolated \ + Issue79RegressionTests.test_historical_compile_sources_require_replacement_suppression \ + Issue79RegressionTests.test_packet_loader_rejects_unsupported_top_level_assignment \ + Issue79RegressionTests.test_reexported_os_module_does_not_bypass_heredoc_checks \ + Issue79RegressionTests.test_markdown_source_paths_cannot_escape_git_list_origin \ + Issue79RegressionTests.test_current_executable_recipes_keep_git_and_path_provenance \ + Issue79RegressionTests.test_unbound_anchor_reader_cannot_read_or_disclose_paths \ + Issue79RegressionTests.test_markdown_link_containment_guard_must_be_direct_and_reachable \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_historical_unsafe_git_transcripts_are_inert_source_text \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +Ran 15 tests in 13.564s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +The source-path method also passed alone in 0.773s after adding the function- +definition and import-alias mutations. All adversarial recipes and mutations +remained AST/source data; only the trusted standard-library harness and reviewed +scanner ran with `python3 -I -B`. No source revision, compiler-input name or +helper allowlist was added. This is focused evidence only: the updated packet +selector, full harness, Go/hosted CI and live operations remain coordinator-owned +and are not claimed as passed here. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index c95445aa..6cb88ad6 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2857,30 +2857,51 @@ def test_packet_loader_rejects_packet_controlled_definition_time_code(self) -> N def test_packet_loader_rejects_unsupported_top_level_assignment(self) -> None: original = globals()["PACKET_TEXT"] try: - globals()["PACKET_TEXT"] = original.replace( + unsupported = 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(unsupported, original) + scanner_source = _scanner_module_source(unsupported) + self.assertIn('probe = os.system("gh workflow run ci.yml")', scanner_source) + globals()["PACKET_TEXT"] = unsupported self.assertNotEqual(globals()["PACKET_TEXT"], original) - with self.assertRaises(AssertionError): + with self.assertRaisesRegex(AssertionError, "unsupported top-level assignment"): _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):"), + + scanner_source = _scanner_module_source(original) + module = ast.parse(scanner_source, filename="") + source_statement = _top_level_assignment(module, "source") + source_expression = ast.get_source_segment(scanner_source, source_statement) + self.assertEqual( + source_expression, + 'source = Path("docs/evidence/g01-recovery-packet.md").read_text(encoding="utf-8")', ) + self.assertIsInstance(source_statement, ast.Assign) + self.assertTrue(_validated_scanner_statements(module)) + source_position = original.find(scanner_source) self.assertGreaterEqual(source_position, 0) - globals()["PACKET_TEXT"] = ( - original[:source_position] + self.assertEqual(original.find(scanner_source, source_position + 1), -1) + line_offsets = [0] + for line in scanner_source.splitlines(keepends=True): + line_offsets.append(line_offsets[-1] + len(line)) + start = line_offsets[source_statement.lineno - 1] + source_statement.col_offset + end = line_offsets[source_statement.end_lineno - 1] + source_statement.end_col_offset + mutated_scanner_source = ( + scanner_source[:start] + 'source = os.system("gh workflow run ci.yml")' - + original[source_position + len(source_assignment):] + + scanner_source[end:] ) - with self.assertRaises(AssertionError): + mutated_packet = ( + original[:source_position] + + mutated_scanner_source + + original[source_position + len(scanner_source):] + ) + self.assertEqual(_scanner_module_source(mutated_packet), mutated_scanner_source) + globals()["PACKET_TEXT"] = mutated_packet + with self.assertRaisesRegex(AssertionError, "source assignment is not reviewed"): _scanner_namespace() finally: globals()["PACKET_TEXT"] = original @@ -3103,7 +3124,9 @@ def test_reexported_os_module_does_not_bypass_heredoc_checks(self) -> None: 'lookup(other, member).system("gh workflow run ci.yml")\n' )) self.assertIsNone(self.inspect( - 'class Settings:\n os = "darwin"\nprint(Settings.os)\n' + 'import subprocess\n' + 'class Settings:\n os = "darwin"\n' + 'print(Settings.os)\n' )) def test_command_capable_decorator_cannot_replace_safe_function(self) -> None: @@ -3757,6 +3780,46 @@ def test_markdown_source_paths_cannot_escape_git_list_origin(self) -> None: 'for name in files:\n', 1, ), + "annotated repository-root retarget": body.replace( + 'for name in files:\n', + 'repository_root: Path = Path("synthetic-private").resolve()\n' + 'for name in files:\n', + 1, + ), + "augmented repository-root retarget": body.replace( + 'for name in files:\n', + 'repository_root /= Path("/tmp/synthetic-private")\n' + 'for name in files:\n', + 1, + ), + "function-definition repository-root retarget": body.replace( + 'for name in files:\n', + 'def repository_root():\n' + ' return Path("synthetic-private")\n' + 'for name in files:\n', + 1, + ), + "import-alias repository-root retarget": body.replace( + 'for name in files:\n', + 'import pathlib as repository_root\n' + 'for name in files:\n', + 1, + ), + "source read inside symlink rejection branch": body.replace( + ' if source.is_symlink():\n' + ' errors.append(f"{name}: tracked Markdown path was a symlink")\n' + ' continue\n', + '', + 1, + ).replace( + ' markdown = "\\n".join(markdown_outside_fences(source_path.read_text(encoding="utf-8")))\n', + ' if source.is_symlink():\n' + ' markdown = "\\n".join(markdown_outside_fences(source_path.read_text(encoding="utf-8")))\n' + ' errors.append(f"{name}: tracked Markdown path was a symlink: {markdown}")\n' + ' continue\n' + ' markdown = "\\n".join(markdown_outside_fences(source_path.read_text(encoding="utf-8")))\n', + 1, + ), "loop variable rebound": body.replace( ' source = Path(name)\n', ' name = "experiments/g01-scaleset/.env"\n' @@ -4431,16 +4494,64 @@ def test_boolean_path_provider_results_cannot_patch_trusted_reader(self) -> None def test_historical_compile_sources_require_replacement_suppression(self) -> None: reference = '01764bbed0a387129d2a2abbc9e27a87e073f87e:docs/evidence/g01-recovery-packet.md' - for prefix in ('["git", "show", ', '["git", "-P", "show", '): - with self.subTest(prefix=prefix): - body = 'import os, subprocess\nwrapper = subprocess.check_output(' + prefix + repr(reference) + '], text=True)\n' - self.assertIsNotNone(self.inspect(body + - 'exec(compile(wrapper, "", "exec"), {"os": os})\n')) - for prefix in ('["git", "--no-replace-objects", "show", ', '["git", "--no-replace-objects", "-P", "show", '): - with self.subTest(safe_prefix=prefix): - body = 'import os, subprocess\nwrapper = subprocess.check_output(' + prefix + repr(reference) + '], text=True)\n' - self.assertIsNone(self.inspect(body + - 'exec(compile(wrapper, "", "exec"), {"os": os})\n')) + common = ( + 'import os, subprocess\n' + 'git_environment = {"PATH": "/usr/bin:/bin", ' + '"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", ' + '"GIT_CONFIG_SYSTEM": "/dev/null", "GIT_ATTR_NOSYSTEM": "1"}\n' + ) + prefix = ( + '["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", ' + '"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", ' + '"GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", ' + ) + suffix = ( + '"-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ' + '"show", ' + repr(reference) + '], env=git_environment, text=True)\n' + 'exec(compile(wrapper, "", "exec"), {"os": os})\n' + ) + safe_argv_prefix = prefix + '"--no-replace-objects", "-P", ' + unsafe_argvs = ( + ( + "missing replacement suppression", + prefix + '"-P", ' + suffix, + ), + ( + "missing system config", + (safe_argv_prefix + suffix).replace( + '"GIT_CONFIG_SYSTEM=/dev/null", ', "", 1 + ), + ), + ( + "missing fsmonitor fence", + (safe_argv_prefix + suffix).replace( + '"-c", "core.fsmonitor=false", ', "", 1 + ), + ), + ( + "missing hooks fence", + (safe_argv_prefix + suffix).replace( + '"-c", "core.hooksPath=/dev/null", ', "", 1 + ), + ), + ) + for label, argv in unsafe_argvs: + body = common + 'wrapper = subprocess.check_output(' + argv + with self.subTest(unsafe_control=label): + self.assertIsNotNone(self.inspect(body)) + safe_options = '"--no-replace-objects", "-P", ' + direct = common + 'wrapper = subprocess.check_output(' + prefix + safe_options + suffix + with self.subTest(safe_prefix="canonical isolated direct argv"): + self.assertIsNone(self.inspect(direct)) + tuple_argv = direct.replace( + 'subprocess.check_output([', 'subprocess.check_output((', 1 + ).replace( + '"show", ' + repr(reference) + '], env=git_environment', + '"show", ' + repr(reference) + ') , env=git_environment', + 1, + ) + with self.subTest(safe_prefix="canonical isolated tuple argv"): + self.assertIsNone(self.inspect(tuple_argv)) def test_arbitrary_packet_slices_do_not_acquire_compile_authority(self) -> None: setup = 'import os\nfrom pathlib import Path\npacket = Path("docs/evidence/g01-recovery-packet.md").read_text()\n' From 6a069ab6766880c4d36c758f209c3970d22b1928 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 21:35:31 +0900 Subject: [PATCH 058/123] Track reviewed module OS reexports --- docs/evidence/g01-recovery-packet.md | 98 +++++++++++++++---- .../issue79_regression_test.py | 4 + 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 5673088d..ba86ce59 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14558,6 +14558,13 @@ def python_subprocess_os_reexport_violation(tree): for alias in node.names if alias.name == "subprocess" } + reviewed_module_names = { + alias.asname or alias.name.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + if alias.name in reviewed_python_import_modules + } name_bind_counts = {} for node in ast.walk(tree): if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): @@ -14577,24 +14584,24 @@ def python_subprocess_os_reexport_violation(tree): changed = True while changed: changed = False - def may_refer_to_subprocess(value): + def may_refer_to_module(value, module_names): if isinstance(value, ast.Name): - return value.id in subprocess_names + return value.id in module_names if isinstance(value, (ast.Tuple, ast.List, ast.Set)): - return any(may_refer_to_subprocess(item) for item in value.elts) + return any(may_refer_to_module(item, module_names) for item in value.elts) if isinstance(value, ast.Dict): - return any(may_refer_to_subprocess(item) for item in value.values) + return any(may_refer_to_module(item, module_names) 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) + and may_refer_to_module(value.func.value, module_names) ): 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) + and any(may_refer_to_module(item, module_names) for item in value.args) ): return True if isinstance(value, (ast.BinOp, ast.BoolOp)): @@ -14603,7 +14610,7 @@ def python_subprocess_os_reexport_violation(tree): if isinstance(value, ast.BinOp) else value.values ) - return any(may_refer_to_subprocess(item) for item in operands) + return any(may_refer_to_module(item, module_names) for item in operands) if isinstance(value, ast.Subscript): if ( isinstance(value.value, ast.Name) @@ -14617,37 +14624,48 @@ def python_subprocess_os_reexport_violation(tree): 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 any(may_refer_to_module(item, module_names) for item in matching_values) return False - return may_refer_to_subprocess(value.value) + return may_refer_to_module(value.value, module_names) if isinstance(value, ast.IfExp): - return may_refer_to_subprocess(value.body) or may_refer_to_subprocess(value.orelse) + return ( + may_refer_to_module(value.body, module_names) + or may_refer_to_module(value.orelse, module_names) + ) if isinstance(value, ast.NamedExpr): - return may_refer_to_subprocess(value.value) + return may_refer_to_module(value.value, module_names) return False - def bind_subprocess_target(target, value): + def may_refer_to_subprocess(value): + return may_refer_to_module(value, subprocess_names) + + def may_refer_to_reviewed_module(value): + return may_refer_to_module(value, reviewed_module_names) + + def bind_module_target(target, value, module_names): 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) + if may_refer_to_module(value, module_names) and target.id not in module_names: + module_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): + bind_module_target(element, source, module_names) + elif may_refer_to_module(value, module_names): for element in target.elts: - bind_subprocess_target(element, value) + bind_module_target(element, value, module_names) 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) + bind_module_target(target, node.value, subprocess_names) + bind_module_target(target, node.value, reviewed_module_names) elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): - bind_subprocess_target(node.target, node.value) + bind_module_target(node.target, node.value, subprocess_names) + bind_module_target(node.target, node.value, reviewed_module_names) for node in ast.walk(tree): reviewed_base_names = {"Exception", "ValueError"} if ( @@ -14763,7 +14781,7 @@ def python_subprocess_os_reexport_violation(tree): if ( isinstance(node, ast.Attribute) and node.attr in {"os", "_os"} - and may_refer_to_subprocess(node.value) + and may_refer_to_reviewed_module(node.value) ): return "Python heredoc accesses an unreviewed OS module re-export" if ( @@ -34600,3 +34618,43 @@ scanner ran with `python3 -I -B`. No source revision, compiler-input name or helper allowlist was added. This is focused evidence only: the updated packet selector, full harness, Go/hosted CI and live operations remain coordinator-owned and are not claimed as passed here. + +#### Writer correction for reviewed-module OS re-exports from `e720603` + +The coordinator reported that its 163-test run at `e7206034600adcae9afae2738dd7be690209ea48` +had one failing assertion: `test_os_reexports_from_allowed_modules_are_not_certified` accepted +the inert source `import pathlib; pathlib.os.remove("synthetic-owned")`. The +focused selector reproduced that acceptance as one failure in 0.142s. After +adding import-alias, dictionary-carried-module and reflective `pathlib.os` +controls, the same selector failed two expected-refusal subcases in 0.137s: +the direct and `import pathlib as files` forms. The dictionary-carried and +reflection cases were already rejected by existing checks. + +The OS-re-export check now follows module origins from every directly imported +module in `reviewed_python_import_modules`, not only `subprocess`, through the +existing alias/container propagation. It uses that origin set only for the +`os`/`_os` receiver check; subprocess-specific container mutation and dynamic +re-export checks retain their narrower subprocess origin set. The benign +`Settings.os = "darwin"` case, including a separate `subprocess` import, remains +accepted; direct, aliased and container-carried `pathlib.os` cases and static +reflection remain refused. + +The focused validation passed after the correction: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_reexported_os_module_does_not_bypass_heredoc_checks \ + Issue79RegressionTests.test_os_reexports_from_allowed_modules_are_not_certified \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +Ran 5 tests in 5.292s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +The helper selector preserved the 119 current actual compiler-helper checks and +the loader selector preserved all ten historical-loader controls. No unsafe +specimen, private path, hook or live resource was executed or read. No new +compiler input, helper allowlist or source revision was added. The full harness +and exact packet selector remain coordinator-owned and are not claimed as +passed for this correction. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 6cb88ad6..6dc348e2 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3610,6 +3610,10 @@ def test_os_reexports_from_allowed_modules_are_not_certified(self) -> None: 'import tempfile as fixtures\nfixtures._os.remove("synthetic-owned")\n', 'from tempfile import _os as inherited\ninherited.remove("synthetic-owned")\n', 'import pathlib\npathlib.os.remove("synthetic-owned")\n', + 'import pathlib as files\nfiles.os.remove("synthetic-owned")\n', + 'import pathlib\nmodules = {"pathlib": pathlib}\n' + 'module = modules["pathlib"]\nmodule.os.remove("synthetic-owned")\n', + 'import pathlib\ngetattr(pathlib, "os").remove("synthetic-owned")\n', 'import tempfile\ngetattr(tempfile, "_os").remove("synthetic-owned")\n', 'import tempfile\ngetattr(tempfile, "_" + "os").remove("synthetic-owned")\n', ): From 3e597eb276fd473f788d86e7136f6903700f53f0 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 3 Oct 2026 22:19:06 +0900 Subject: [PATCH 059/123] Harden Issue 79 output and path guards --- docs/evidence/g01-recovery-packet.md | 262 +++++++++++++++++- .../issue79_regression_test.py | 114 ++++++++ 2 files changed, 373 insertions(+), 3 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index ba86ce59..184845a7 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13724,6 +13724,98 @@ def reviewed_python_git_child_environment(node, tree, parents): ) +def reviewed_python_git_sensitive_output(node, tree, parents, seen=None): + """Trace a certified Git argv far enough to guard raw origin-URL output.""" + if seen is None: + seen = set() + if id(node) in seen: + return True + seen = seen | {id(node)} + if isinstance(node, ast.Call): + if python_dotted_name(node.func) not in {"git_command", "git_query"}: + return False + if not reviewed_python_git_builder(node, tree, parents): + return True + arguments = node.args[0] + tokens = [] + for element in arguments.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + tokens.append(element.value) + elif reviewed_python_dynamic_path_value(element, tree, parents): + tokens.append("__g01_reviewed_dynamic_path__") + else: + return True + return git_sensitive_shell_output_violation(["git", *tokens]) is not None + if not isinstance(node, ast.Name): + return True + scope = python_enclosing_scope(node, parents) + assignments = [] + 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 python_enclosing_scope(candidate, parents) is scope and any( + isinstance(target, ast.Name) and target.id == node.id for target in targets + ): + assignments.append(value) + if assignments: + return len(assignments) != 1 or reviewed_python_git_sensitive_output( + assignments[0], tree, parents, seen + ) + if not isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): + return True + parameters = ( + list(scope.args.posonlyargs) + list(scope.args.args) + list(scope.args.kwonlyargs) + ) + parameter_index = next( + (index for index, parameter in enumerate(parameters) if parameter.arg == node.id), + None, + ) + if parameter_index is None or id(scope) in seen: + return True + callers = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == scope.name + ] + if not callers: + return True + for caller in callers: + keywords = [keyword.value for keyword in caller.keywords if keyword.arg == node.id] + if len(keywords) > 1: + return True + if keywords: + argument = keywords[0] + elif parameter_index < len(caller.args) and not any( + isinstance(value, ast.Starred) for value in caller.args + ): + argument = caller.args[parameter_index] + else: + return True + if reviewed_python_git_sensitive_output(argument, tree, parents, seen): + return True + return False + + +def reviewed_python_git_query_capture(node, parents): + """Keep raw Git query output only in the existing bounded capture helper.""" + if ( + enclosing_python_function(node, parents) != "run_bounded_git_query" + or python_dotted_name(node.func) != "subprocess.Popen" + or not reviewed_python_helper_definition(node, parents) + or not reviewed_python_helper_launcher(node, parents) + ): + return False + keywords = {keyword.arg: keyword.value for keyword in node.keywords} + return all( + python_dotted_name(keywords.get(name)) == "subprocess.PIPE" + for name in ("stdout", "stderr") + ) + + def reviewed_python_static_loop_binding(node, tree): """Recognize literal command loops without granting a marker blanket trust.""" if not isinstance(node, ast.Name): @@ -13949,6 +14041,11 @@ def reviewed_python_dynamic_call( python_dotted_name(node.func) in python_command_functions and reviewed_python_git_command_origin(argument, tree, parents) ): + if ( + reviewed_python_git_sensitive_output(argument, tree, parents) + and not reviewed_python_git_query_capture(node, parents) + ): + return False return reviewed_python_git_child_environment(node, tree, parents) if reviewed_python_case_args(argument, tree): return True @@ -14169,11 +14266,15 @@ def reviewed_source_snapshot_path(node, tree, parents, seen=None): python_filesystem_mutating_methods = { "chmod", "chown", + "copy", + "copy_into", "hardlink_to", + "lchmod", "link_to", "mkdir", "makedirs", "move", + "move_into", "open", "rename", "replace", @@ -14832,6 +14933,48 @@ def python_subprocess_os_reexport_violation(tree): return None +python_path_mutator_destination_keywords = { + "copy": "target", + "copy_into": "target_dir", + "move_into": "target_dir", +} + + +def python_path_mutator_destination(call, method): + """Return only a statically visible destination for new Path mutators.""" + keyword_name = python_path_mutator_destination_keywords.get(method) + if keyword_name is None: + return None + if len(call.args) > 1 or any( + isinstance(argument, ast.Starred) for argument in call.args + ): + return None + named_destinations = [ + keyword.value for keyword in call.keywords if keyword.arg == keyword_name + ] + if ( + len(named_destinations) > 1 + or any(keyword.arg is None for keyword in call.keywords) + or (call.args and named_destinations) + ): + return None + for keyword in call.keywords: + if keyword.arg == keyword_name: + continue + if not ( + method in {"copy", "copy_into"} + and keyword.arg == "follow_symlinks" + and isinstance(keyword.value, ast.Constant) + and isinstance(keyword.value.value, bool) + ): + return None + if call.args: + return call.args[0] + if named_destinations: + return named_destinations[0] + 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") @@ -14921,6 +15064,45 @@ def python_filesystem_mutator_alias_violation(tree, parents): "Python heredoc extracts an unowned filesystem mutator " f"{method!r} into alias {target.id!r} on line {node.lineno}" ) + if method in python_path_mutator_destination_keywords: + alias_scope = python_enclosing_scope(node, parents) + alias_calls = [] + for candidate in ast.walk(tree): + if not ( + isinstance(candidate, ast.Name) + and candidate.id == target.id + and isinstance(candidate.ctx, ast.Load) + ): + continue + candidate_scope = python_enclosing_scope(candidate, parents) + if candidate_scope is alias_scope: + parent = parents.get(candidate) + if not ( + isinstance(parent, ast.Call) and parent.func is candidate + ): + return ( + "Python heredoc lets an extracted Path mutator escape " + f"its reviewed call site on line {candidate.lineno}" + ) + alias_calls.append(parent) + elif alias_scope in python_lexical_scope_chain( + candidate_scope, parents + ) and not reviewed_python_git_name_bound_in_scope( + tree, parents, candidate_scope, target.id + ): + return ( + "Python heredoc captures an extracted Path mutator " + f"outside its reviewed scope on line {candidate.lineno}" + ) + for call in alias_calls: + destination = python_path_mutator_destination(call, method) + if destination is None or not temporary_path_expression( + destination, tree, parents + ): + return ( + "Python heredoc calls an extracted Path mutator with an " + f"unreviewed destination on line {call.lineno}" + ) return None @@ -15156,7 +15338,11 @@ def python_filesystem_mutation_violation(tree, parents): continue mutation = True path_arguments = [node.func.value] - if node.func.attr in { + if node.func.attr in python_path_mutator_destination_keywords: + path_arguments.append( + python_path_mutator_destination(node, node.func.attr) + ) + elif node.func.attr in { "hardlink_to", "link_to", "symlink_to", @@ -16713,8 +16899,9 @@ def python_warning_sink_targets(tree): for method in ("warn", "warn_explicit", "showwarning") } targets.update( - name + ".displayhook" + name + "." + method for name in python_assigned_module_names(tree, "sys") + for method in ("displayhook", "excepthook") ) tree._issue79_warning_sink_targets = targets return targets @@ -16732,6 +16919,7 @@ def python_sensitive_output_sink(node, tree=None): "print", "sys.exit", "sys.displayhook", + "sys.excepthook", "warnings.warn", "warnings.warn_explicit", "warnings.showwarning", @@ -16746,6 +16934,7 @@ def python_sensitive_output_sink(node, tree=None): "builtins.print", "sys.exit", "sys.displayhook", + "sys.excepthook", "warnings.warn", "warnings.warn_explicit", "warnings.showwarning", @@ -16775,6 +16964,7 @@ def python_sensitive_output_sink(node, tree=None): "builtins.print", "sys.exit", "sys.displayhook", + "sys.excepthook", "warnings.warn", "warnings.warn_explicit", "warnings.showwarning", @@ -16794,7 +16984,7 @@ def python_sensitive_output_sink(node, tree=None): } imported_functions = { "builtins": {"print"}, - "sys": {"exit", "displayhook"}, + "sys": {"exit", "displayhook", "excepthook"}, "warnings": {"warn", "warn_explicit", "showwarning"}, "traceback": {"print_exc", "print_exception"}, } @@ -34658,3 +34848,69 @@ specimen, private path, hook or live resource was executed or read. No new compiler input, helper allowlist or source revision was added. The full harness and exact packet selector remain coordinator-owned and are not claimed as passed for this correction. + +#### Writer response to three exact-head Codex findings from `6a069ab6` + +At entry the worktree was clean at `6a069ab6766880c4d36c758f209c3970d22b1928`. +The three reported P1 paths were limited to `sys.excepthook` output of an +exception carrying environment values, Python 3.14 `Path.copy`, `copy_into`, +`move_into` and `lchmod` mutations, and an isolated Git builder that sent the +local origin URL query to inherited stdout. The [CPython 3.14 `sys.excepthook` +reference](https://docs.python.org/3.14/library/sys.html#sys.excepthook) states +that the hook prints the exception and traceback to standard error; the +[CPython 3.14 `pathlib` reference](https://docs.python.org/3.14/library/pathlib.html#pathlib.Path.copy) +documents the new copy/move methods and their destination arguments. This +worker did not run the full harness, packet selector, any specimen, or any +live Git query. + +The three new focused methods first ran against the original scanner and failed +in 0.168s with 15 unsafe-acceptance assertions: direct, imported and assigned +`sys.excepthook` sinks (3); all four unowned-receiver pathlib mutators, an +extracted unowned method, and six temporary-source/external-destination forms +including keyword and extracted-call forms (11); and an isolated builder query +whose raw origin URL output was inherited (1). Reflection-based hook lookups +already refused and remained negative controls. All source witnesses were +inert strings parsed by the harness. + +The minimal scanner correction classifies `sys.excepthook` through direct, +module-alias, imported and assigned-callable paths while preserving literal +exception controls. It classifies the four pathlib mutators and requires both +source and destination to stay within the active `TemporaryDirectory`; direct, +keyword and extracted callable destinations are checked, while malformed or +unpacked destinations fail closed. The existing canonical Git argv proof now +also inspects the builder's read-only arguments and refuses the sensitive +`remote.origin.url` query at ordinary child launchers. The existing +`run_bounded_git_query` launcher remains accepted only with its bounded capture +helper and explicit stdout/stderr pipes, and the existing origin comparison +remains intact. No compiler input, source revision, module-wide exemption or +helper allowlist was added. + +The bounded post-fix selection passed all three new finding methods and the +existing displayhook, pathlib-reader/mutator, OS-re-export, isolated-Git, +builder-rebinding and argv-mutation controls, plus the current compiler-helper +and historical-loader controls: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_filesystem_mutator_cannot_hide_in_container_binding \ + Issue79RegressionTests.test_path_filesystem_readers_require_reviewed_paths \ + Issue79RegressionTests.test_os_reexports_from_allowed_modules_are_not_certified \ + Issue79RegressionTests.test_reexported_os_module_does_not_bypass_heredoc_checks \ + Issue79RegressionTests.test_shell_origin_url_query_is_rejected_but_verifier_capture_remains \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +Ran 13 tests in 5.567s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +The run retained all 119 current compiler-helper checks and ten historical +loader controls. This is focused evidence only; the coordinator owns the exact +candidate packet selector, complete harness, final reviews and merge gates. +`git diff --check` passed; the added-line personal-path, credential-token and +private-key scan covered 373 lines with zero matches. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 6dc348e2..e900a650 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2604,6 +2604,42 @@ def test_shell_origin_url_query_is_rejected_but_verifier_capture_remains(self) - '"remote.origin.url"], check=True)\n' ) ) + canonical_builder_prefix = ( + 'import subprocess\n' + 'git_environment = {"PATH": "/usr/bin:/bin", ' + '"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", ' + '"GIT_CONFIG_SYSTEM": "/dev/null", "GIT_ATTR_NOSYSTEM": "1"}\n' + 'def git_command(arguments):\n' + ' return ["/usr/bin/env", "-i", "GIT_CONFIG_NOSYSTEM=1", ' + '"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", ' + '"GIT_ATTR_NOSYSTEM=1", "/usr/bin/git", "--no-replace-objects", ' + '"-P", "-c", "core.fsmonitor=false", "-c", ' + '"core.hooksPath=/dev/null", *arguments]\n' + ) + unsafe_builders = ( + 'subprocess.run(git_command(["config", "--local", "--get-all", ' + '"remote.origin.url"]), env=git_environment)\n', + 'argv = git_command(["config", "--local", "--get-all", ' + '"remote.origin.url"])\n' + 'subprocess.run(argv, env=git_environment)\n', + 'subprocess.run(git_command(["config", "--local", "--get-all", ' + '"remote.origin.url"]), env=git_environment, ' + 'stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n', + ) + for body in unsafe_builders: + with self.subTest(body=body): + self.assertIsNotNone( + self.inspect(canonical_builder_prefix + body), + "an isolated builder must not expose raw origin URL output", + ) + self.assertIsNone( + self.inspect( + canonical_builder_prefix + + 'subprocess.run(git_command(["status", "--short"]), ' + 'env=git_environment)\n' + ), + "a non-sensitive isolated status query remains accepted", + ) origin_capture = _top_level_assignment(self.verification, "origin_result") self.assertEqual( @@ -3635,6 +3671,84 @@ def test_displayhook_aliases_are_sensitive_output_sinks(self) -> None: self.assertIsNone(self.inspect('import sys\nsys.displayhook("offline fixture")\n')) self.assertIsNone(self.inspect('from sys import displayhook as emit\nemit("offline fixture")\n')) + def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: + unsafe = ( + 'import os, sys\n' + 'sys.excepthook(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nemit = sys.excepthook\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nemit = getattr(sys, "excepthook")\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nruntime = sys\nlookup = getattr\n' + 'emit = lookup(runtime, "excepthook")\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\n' + 'getattr(sys, "excepthook")(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nruntime = sys\n' + 'runtime.excepthook(RuntimeError, RuntimeError(os.environ), None)\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import sys\nsys.excepthook(RuntimeError, RuntimeError("offline fixture"), None)\n' + )) + self.assertIsNone(self.inspect( + 'from sys import excepthook as emit\n' + 'emit(RuntimeError, RuntimeError("offline fixture"), None)\n' + )) + + def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: + unsafe = ( + 'from pathlib import Path\nPath("synthetic-owned").copy(Path("synthetic-destination"))\n', + 'from pathlib import Path\nPath("synthetic-owned").copy_into(Path("synthetic-destination"))\n', + 'from pathlib import Path\nPath("synthetic-owned").move_into(Path("synthetic-destination"))\n', + 'from pathlib import Path\nPath("synthetic-owned").lchmod(0o600)\n', + 'from pathlib import Path\n' + 'action = Path("synthetic-owned").move_into\n' + 'action(Path("synthetic-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' Path(td).joinpath("source").copy(Path("synthetic-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' Path(td).joinpath("source").copy(target=Path("synthetic-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' Path(td).joinpath("source").copy_into(target_dir=Path("synthetic-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' Path(td).joinpath("source").move_into(target_dir=Path("synthetic-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td).joinpath("source")\n' + ' action = source.move_into\n' + ' action(Path("synthetic-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + safe = ( + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' copied = Path(td) / "copied"\n' + ' destination_dir = Path(td) / "destination"\n' + ' source.copy(copied)\n' + ' source.copy_into(destination_dir)\n' + ' source.move_into(destination_dir)\n' + ' source.copy(target=copied)\n' + ' action = source.move_into\n' + ' action(destination_dir)\n' + ' source.lchmod(0o600)\n' + ) + self.assertIsNone(self.inspect(safe)) + def test_standalone_git_queries_require_explicit_hook_isolation(self) -> None: unsafe = ( "git status --short", "git -P status --short", From a9d602e5bce88545a8652e9d4286b35cea985fd4 Mon Sep 17 00:00:00 2001 From: "Codex (Orca worker)" Date: Sat, 3 Oct 2026 22:58:58 +0900 Subject: [PATCH 060/123] Issue 79 preserve bounded evidence proofs --- docs/evidence/g01-recovery-packet.md | 518 +++++++++++++++++- .../issue79_regression_test.py | 83 +++ 2 files changed, 597 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 184845a7..a789e245 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -13741,7 +13741,7 @@ def reviewed_python_git_sensitive_output(node, tree, parents, seen=None): for element in arguments.elts: if isinstance(element, ast.Constant) and isinstance(element.value, str): tokens.append(element.value) - elif reviewed_python_dynamic_path_value(element, tree, parents): + elif reviewed_python_dynamic_path_value(element, tree): tokens.append("__g01_reviewed_dynamic_path__") else: return True @@ -13800,13 +13800,237 @@ def reviewed_python_git_sensitive_output(node, tree, parents, seen=None): return False -def reviewed_python_git_query_capture(node, parents): - """Keep raw Git query output only in the existing bounded capture helper.""" +def reviewed_python_git_origin_query_results(tree, parents): + """Find bounded results of the exact sensitive Git origin query.""" + result_bindings = [] + for candidate in ast.walk(tree): + if not isinstance(candidate, ast.Assign) or len(candidate.targets) != 1: + continue + target = candidate.targets[0] + value = candidate.value + if ( + isinstance(target, ast.Name) + and isinstance(value, ast.Call) + and python_dotted_name(value.func) == "run_bounded_git_query" + and value.args + and isinstance(value.args[0], ast.Call) + and python_dotted_name(value.args[0].func) in {"git_query", "git_command"} + and reviewed_python_git_builder(value.args[0], tree, parents) + and value.args[0].args + and isinstance(value.args[0].args[0], (ast.List, ast.Tuple)) + and all( + isinstance(argument, ast.Constant) + and isinstance(argument.value, str) + for argument in value.args[0].args[0].elts + ) + and [ + argument.value for argument in value.args[0].args[0].elts + ] == ["config", "--local", "--get-all", "remote.origin.url"] + ): + result_bindings.append((target.id, candidate)) + return result_bindings + + +def reviewed_python_git_origin_capture_is_compared(tree, parents): + """Certify the one bounded origin query only while its literal check is intact.""" + result_bindings = reviewed_python_git_origin_query_results(tree, parents) + if len(result_bindings) != 1: + return False + result_name, result_assignment = result_bindings[0] + result_target_nodes = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) + and candidate.id == result_name + and isinstance(candidate.ctx, ast.Store) + ] + if ( + len(result_target_nodes) != 1 + or parents.get(result_target_nodes[0]) is not result_assignment + ): + return False + expected_assignments = [ + candidate + for candidate in tree.body + if isinstance(candidate, ast.Assign) + and len(candidate.targets) == 1 + and isinstance(candidate.targets[0], ast.Name) + and candidate.targets[0].id == "expected_origin_url" + and isinstance(candidate.value, ast.Constant) + and isinstance(candidate.value.value, str) + and candidate.value.value == "https://github.com/1XP-AI/gh-runnerd.git" + ] + if len(expected_assignments) != 1: + return False + expected_targets = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) + and candidate.id == "expected_origin_url" + and isinstance(candidate.ctx, ast.Store) + ] + if ( + len(expected_targets) != 1 + or parents.get(expected_targets[0]) is not expected_assignments[0] + ): + return False + status_attributes = [] + stdout_attributes = [] + result_loads = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) + and candidate.id == result_name + and isinstance(candidate.ctx, ast.Load) + ] + for candidate in ast.walk(tree): + if ( + isinstance(candidate, ast.Attribute) + and isinstance(candidate.value, ast.Name) + and candidate.value.id == result_name + ): + if candidate.attr in {"returncode", "stderr"}: + status_attributes.append(candidate) + elif candidate.attr == "stdout": + stdout_attributes.append(candidate) + if len(status_attributes) != 2 or len(stdout_attributes) != 1: + return False + if any( + parents.get(candidate) is not attribute + for candidate in result_loads + for attribute in status_attributes + stdout_attributes + if attribute.value is candidate + ) or len(result_loads) != 3: + return False + stdout = stdout_attributes[0] + decode_attribute = parents.get(stdout) + decode = ( + parents.get(decode_attribute) + if isinstance(decode_attribute, ast.Attribute) + else None + ) + splitlines_attribute = parents.get(decode) + splitlines = ( + parents.get(splitlines_attribute) + if isinstance(splitlines_attribute, ast.Attribute) + else None + ) + origin_assignment = parents.get(splitlines) if isinstance(splitlines, ast.Call) else None + if not ( + isinstance(decode_attribute, ast.Attribute) + and decode_attribute.attr == "decode" + and python_dotted_name(decode_attribute) == result_name + ".stdout.decode" + and isinstance(decode, ast.Call) + and decode.func is decode_attribute + and len(decode.args) == 1 + and isinstance(decode.args[0], ast.Constant) + and decode.args[0].value == "utf-8" + and isinstance(splitlines_attribute, ast.Attribute) + and splitlines_attribute.attr == "splitlines" + and splitlines_attribute.value is decode + and isinstance(splitlines, ast.Call) + and splitlines.func is splitlines_attribute + and isinstance(origin_assignment, ast.Assign) + and origin_assignment in tree.body + and len(origin_assignment.targets) == 1 + and isinstance(origin_assignment.targets[0], ast.Name) + and origin_assignment.targets[0].id == "origin_urls" + ): + return False + status_checks = [ + candidate + for candidate in tree.body + if isinstance(candidate, ast.If) + and isinstance(candidate.test, ast.BoolOp) + and isinstance(candidate.test.op, ast.Or) + and len(candidate.test.values) == 2 + and any( + isinstance(value, ast.Compare) + and isinstance(value.left, ast.Attribute) + and isinstance(value.left.value, ast.Name) + and value.left.value.id == result_name + and value.left.attr == "returncode" + and len(value.ops) == 1 + and isinstance(value.ops[0], ast.NotEq) + and len(value.comparators) == 1 + and isinstance(value.comparators[0], ast.Constant) + and type(value.comparators[0].value) is int + and value.comparators[0].value == 0 + for value in candidate.test.values + ) + and any( + isinstance(value, ast.Attribute) + and isinstance(value.value, ast.Name) + and value.value.id == result_name + and value.attr == "stderr" + for value in candidate.test.values + ) + and any(isinstance(item, ast.Raise) for item in candidate.body) + ] + origin_targets = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) + and candidate.id == "origin_urls" + and isinstance(candidate.ctx, ast.Store) + ] + if ( + len(status_checks) != 1 + or len(origin_targets) != 1 + or parents.get(origin_targets[0]) is not origin_assignment + ): + return False + origin_name_loads = [ + candidate + for candidate in ast.walk(tree) + if isinstance(candidate, ast.Name) + and candidate.id == "origin_urls" + and isinstance(candidate.ctx, ast.Load) + ] + checks = [ + candidate + for candidate in tree.body + if isinstance(candidate, ast.If) + and isinstance(candidate.test, ast.Compare) + and isinstance(candidate.test.left, ast.Name) + and candidate.test.left.id == "origin_urls" + and len(candidate.test.ops) == 1 + and isinstance(candidate.test.ops[0], ast.NotEq) + and len(candidate.test.comparators) == 1 + and isinstance(candidate.test.comparators[0], ast.List) + and len(candidate.test.comparators[0].elts) == 1 + and isinstance(candidate.test.comparators[0].elts[0], ast.Name) + and candidate.test.comparators[0].elts[0].id == "expected_origin_url" + and any(isinstance(item, ast.Raise) for item in candidate.body) + ] + return ( + len(origin_name_loads) == 1 + and len(checks) == 1 + and result_assignment in tree.body + and tree.body.index(result_assignment) < tree.body.index(origin_assignment) + and tree.body.index(result_assignment) < tree.body.index(status_checks[0]) + and tree.body.index(status_checks[0]) < tree.body.index(origin_assignment) + and tree.body.index(origin_assignment) < tree.body.index(checks[0]) + ) + + +def python_git_origin_capture_violation(tree, parents): + """Refuse the bounded origin exception if its output is exposed or unchecked.""" + if not reviewed_python_git_origin_query_results(tree, parents): + return None + if reviewed_python_git_origin_capture_is_compared(tree, parents): + return None + return "Python bounded Git origin output is not compared or escapes its verifier" + + +def reviewed_python_git_query_capture(node, tree, parents): + """Keep raw Git query output bounded and compared before its exception.""" if ( enclosing_python_function(node, parents) != "run_bounded_git_query" or python_dotted_name(node.func) != "subprocess.Popen" or not reviewed_python_helper_definition(node, parents) or not reviewed_python_helper_launcher(node, parents) + or not reviewed_python_git_origin_capture_is_compared(tree, parents) ): return False keywords = {keyword.arg: keyword.value for keyword in node.keywords} @@ -14043,7 +14267,7 @@ def reviewed_python_dynamic_call( ): if ( reviewed_python_git_sensitive_output(argument, tree, parents) - and not reviewed_python_git_query_capture(node, parents) + and not reviewed_python_git_query_capture(node, tree, parents) ): return False return reviewed_python_git_child_environment(node, tree, parents) @@ -14975,6 +15199,196 @@ def python_path_mutator_destination(call, method): return None +def python_known_non_path_set_copy_receiver(node, tree, parents, active=None): + """Prove .copy() receives a set from literals, builtins or set-preserving calls.""" + if active is None: + active = set() + if isinstance(node, ast.Set): + return True + if isinstance(node, ast.Call): + if python_unshadowed_builtin_call(node, {"set"}, tree, parents): + return True + return ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "copy" + and python_known_non_path_set_copy_receiver( + node.func.value, tree, parents, active | {id(node)} + ) + ) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return ( + python_known_non_path_set_copy_receiver( + node.left, tree, parents, active | {id(node)} + ) + and isinstance(node.right, ast.Set) + ) + if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or): + return all( + python_known_non_path_set_copy_receiver( + value, tree, parents, active | {id(node)} + ) + for value in node.values + ) + if not isinstance(node, ast.Name) or id(node) in active: + return False + scope = python_enclosing_scope(node, parents) + key = (id(scope), node.id) + if key in active: + return False + active = active | {key} + assignments = [] + for candidate in ast.walk(scope): + 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.AugAssign): + targets, value = [candidate.target], ast.Constant(None) + else: + continue + if python_enclosing_scope(candidate, parents) is scope and any( + isinstance(target, ast.Name) and target.id == node.id for target in targets + ): + assignments.append(value) + + def preserves_parameter(value, function, parameter_name, seen=None): + if seen is None: + seen = set() + if id(value) in seen: + return False + seen = seen | {id(value)} + if isinstance(value, ast.Set): + return True + if isinstance(value, ast.Name): + return ( + value.id == parameter_name + and python_enclosing_scope(value, parents) is function + ) + if isinstance(value, ast.Call): + if python_unshadowed_builtin_call(value, {"set"}, tree, parents): + return True + return ( + isinstance(value.func, ast.Attribute) + and value.func.attr == "copy" + and preserves_parameter( + value.func.value, function, parameter_name, seen + ) + ) + if isinstance(value, ast.BinOp) and isinstance(value.op, ast.BitOr): + return ( + preserves_parameter(value.left, function, parameter_name, seen) + and isinstance(value.right, ast.Set) + ) + return False + + if not isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): + return bool(assignments) and all( + python_known_non_path_set_copy_receiver( + value, tree, parents, active + ) + for value in assignments + ) + parameters = ( + list(scope.args.posonlyargs) + list(scope.args.args) + list(scope.args.kwonlyargs) + ) + parameter_index = next( + (index for index, parameter in enumerate(parameters) if parameter.arg == node.id), + None, + ) + if parameter_index is None: + return bool(assignments) and all( + python_known_non_path_set_copy_receiver( + value, tree, parents, active + ) + for value in assignments + ) + positional = list(scope.args.posonlyargs) + list(scope.args.args) + positional_default_start = len(positional) - len(scope.args.defaults) + default = None + if parameter_index < len(positional): + if parameter_index >= positional_default_start: + default = scope.args.defaults[parameter_index - positional_default_start] + else: + keyword_index = parameter_index - len(positional) + default = scope.args.kw_defaults[keyword_index] + default_none = isinstance(default, ast.Constant) and default.value is None + set_normalizer = default_none and any( + any( + isinstance(candidate, ast.Call) + and python_unshadowed_builtin_call(candidate, {"set"}, tree, parents) + for candidate in ast.walk(value) + ) + for value in assignments + ) + callers = [ + candidate for candidate in ast.walk(tree) + if isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == scope.name + and scope in python_local_function_candidates( + scope.name, candidate, tree, parents + ) + ] + if not all( + preserves_parameter(value, scope, node.id) + or python_known_non_path_set_copy_receiver(value, tree, parents, active) + or ( + set_normalizer + and isinstance(value, ast.Constant) + and value.value is None + ) + for value in assignments + ): + return False + if not callers: + return False + for candidate in ast.walk(tree): + if not ( + isinstance(candidate, ast.Name) + and candidate.id == scope.name + and isinstance(candidate.ctx, ast.Load) + ): + continue + parent = parents.get(candidate) + if not ( + isinstance(parent, ast.Call) + and parent.func is candidate + and parent in callers + ): + return False + seeded = False + for caller in callers: + keyword_values = [ + keyword.value for keyword in caller.keywords if keyword.arg == node.id + ] + if len(keyword_values) > 1: + return False + if keyword_values: + argument = keyword_values[0] + elif parameter_index < len(caller.args) and not any( + isinstance(value, ast.Starred) for value in caller.args + ): + argument = caller.args[parameter_index] + else: + argument = None + if argument is None or ( + isinstance(argument, ast.Constant) and argument.value is None + ): + if not set_normalizer: + return False + seeded = True + elif python_enclosing_scope(caller, parents) is scope and preserves_parameter( + argument, scope, node.id + ): + continue + elif python_known_non_path_set_copy_receiver( + argument, tree, parents, active + ): + seeded = True + else: + return False + return seeded + + 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") @@ -15059,6 +15473,10 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) if receiver is None: continue + if method == "copy" and python_known_non_path_set_copy_receiver( + receiver, tree, parents + ): + continue if not temporary_path_expression(receiver, tree, parents): return ( "Python heredoc extracts an unowned filesystem mutator " @@ -15331,6 +15749,10 @@ def python_filesystem_mutation_violation(tree, parents): mutation = False path_arguments = [] if isinstance(node.func, ast.Attribute) and node.func.attr in python_filesystem_mutating_methods: + if node.func.attr == "copy" and python_known_non_path_set_copy_receiver( + node.func.value, tree, parents + ): + continue if ( node.func.attr == "replace" and not python_path_receiver_expression(node.func.value, tree, parents) @@ -18944,6 +19366,9 @@ def inspect_python_heredoc(body, safe_marker): for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } + git_origin_capture_violation = python_git_origin_capture_violation(tree, parents) + if git_origin_capture_violation: + return git_origin_capture_violation module_escape_violation = python_module_value_escape_violation(tree, parents, safe_marker) if module_escape_violation: return module_escape_violation @@ -34914,3 +35339,88 @@ loader controls. This is focused evidence only; the coordinator owns the exact candidate packet selector, complete harness, final reviews and merge gates. `git diff --check` passed; the added-line personal-path, credential-token and private-key scan covered 373 lines with zero matches. + +#### Writer compatibility and bounded-origin-output correction from `3e597eb2` + +At entry on 2026-10-03, the clean checkpoint was +`3e597eb276fd473f788d86e7136f6903700f53f0`. The coordinator's exact packet +selector had reported two compatibility violations: five reviewed dynamic +Git path arguments in the current Go wrapper and a normal set `seen.copy()` +classified as a filesystem mutation. The three blocking Codex findings from +that source remain linked here: [`sys.excepthook` output of exception data](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173265743), +[Python 3.14 pathlib copy/move mutators](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173265747), +and [raw `remote.origin.url` output through the isolated Git builder](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173265750). + +The first focused RED command reproduced the two compatibility failures and +the then-unchecked bounded-origin output path: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_current_executable_recipes_keep_git_and_path_provenance \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_shell_origin_url_query_is_rejected_but_verifier_capture_remains +Ran 3 tests in 4.761s — FAILED (3 failures) +``` + +Before changing the scanner, the actual current verification heredoc was also +mutated only as AST/source data: one variant appended +`print(origin_result.stdout)` and another removed the +`origin_urls != [expected_origin_url]` rejection. The focused RED result was: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape +Ran 1 test in 0.847s — FAILED (2 subtest failures) +``` + +The security follow-up also reproduced printing the decoded `origin_urls` +list as accepted at the entry source. The new regression includes raw stdout, +an assigned stdout alias, decoded-list output and removal of the comparison; +each is refused against the current scanner, while the unmodified verified +query stays accepted. + +The Git path-argument check now uses the same fixed-name dynamic-path +recognition already used by the canonical Git-builder proof, preserving the +current wrapper's five reviewed arguments. Filesystem `.copy()` is skipped +only when the receiver is proven to be an ordinary set through literal/set +construction, set-preserving operations, scoped assignments and statically +visible helper callers. Unknown receivers and non-temporary Path receivers +remain refused; temporary-source Path copies still require an owned +destination. The bounded-origin exception now certifies only the exact +`remote.origin.url` query through the canonical builder when its unique result +has the reviewed return-code/stderr rejection, one UTF-8 line decode, and the +literal expected-origin comparison. Extra result/list reads and any rebound +result are outside the certified shape; the bounded child capture, explicit +pipes and exact expected URL stay required. The tests directly exercise raw, +aliased and decoded output plus a removed comparison. + +The post-correction focused verification passed 15 methods in 10.789s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape \ + Issue79RegressionTests.test_current_executable_recipes_keep_git_and_path_provenance \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_filesystem_mutator_cannot_hide_in_container_binding \ + Issue79RegressionTests.test_shell_origin_url_query_is_rejected_but_verifier_capture_remains \ + Issue79RegressionTests.test_python_git_queries_require_isolated_environment_and_known_builder \ + Issue79RegressionTests.test_python_git_builder_rebinding_is_not_certified \ + Issue79RegressionTests.test_python_git_argv_mutation_is_not_certified \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_path_filesystem_readers_require_reviewed_paths \ + Issue79RegressionTests.test_os_reexports_from_allowed_modules_are_not_certified \ + Issue79RegressionTests.test_reexported_os_module_does_not_bypass_heredoc_checks \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance \ + Issue79RegressionTests.test_historic_scanner_loaders_export_only_their_required_helpers +Ran 15 tests in 10.789s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +The selector retained the ten historical loader controls and the existing +`sys.excepthook`, displayhook, pathlib ownership, OS-re-export, Git isolation, +builder-rebinding and argv-mutation denials. Adversarial inputs remained +inert strings/AST mutations; only the reviewed scanner and local offline +harness ran. This worker did not run the full harness, the packet-wide +selector, Go/hosted CI or any live query. The coordinator owns those exact-head +checks and independent reviews; no full-scan or merge approval is implied. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index e900a650..258ba7fa 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2641,6 +2641,82 @@ def test_shell_origin_url_query_is_rejected_but_verifier_capture_remains(self) - "a non-sensitive isolated status query remains accepted", ) + + def test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape(self) -> None: + verification_body, verification_marker = self.packet_heredoc_containing( + "expected_origin_url =", + "origin_result = run_bounded_git_query(", + "if origin_urls != [expected_origin_url]:", + ) + inspect_verification = self.scanner["inspect_python_heredoc"] + self.assertIsNone( + inspect_verification(verification_body, verification_marker), # type: ignore[operator] + "the current bounded, captured-and-compared verifier remains accepted", + ) + comparison_proof = self.scanner["reviewed_python_git_origin_capture_is_compared"] + verification_tree = ast.parse(verification_body) + verification_parents = { + child: parent + for parent in ast.walk(verification_tree) + for child in ast.iter_child_nodes(parent) + } + self.assertTrue( + comparison_proof(verification_tree, verification_parents) # type: ignore[operator] + ) + for label, mutant in ( + ( + "raw stdout output", + verification_body + "\nprint(origin_result.stdout)\n", + ), + ( + "captured stdout alias output", + verification_body + + "\nraw_origin = origin_result.stdout\nprint(raw_origin)\n", + ), + ( + "decoded origin list output", + verification_body + "\nprint(origin_urls)\n", + ), + ( + "origin comparison removed", + self._verification_without_origin_comparison( + verification_body + ), + ), + ): + with self.subTest(mutant=label): + mutant_tree = ast.parse(mutant) + mutant_parents = { + child: parent + for parent in ast.walk(mutant_tree) + for child in ast.iter_child_nodes(parent) + } + self.assertFalse( + comparison_proof(mutant_tree, mutant_parents) # type: ignore[operator] + ) + self.assertIsNotNone( + inspect_verification(mutant, verification_marker), # type: ignore[operator] + label, + ) + + @staticmethod + def _verification_without_origin_comparison(body: str) -> str: + mutated = ast.parse(body, filename="") + comparisons = [ + statement + for statement in mutated.body + if isinstance(statement, ast.If) + and isinstance(statement.test, ast.Compare) + and any( + isinstance(node, ast.Name) and node.id == "origin_urls" + for node in ast.walk(statement.test) + ) + ] + if len(comparisons) != 1: + raise AssertionError("current origin comparison shape changed") + mutated.body.remove(comparisons[0]) + return ast.unparse(mutated) + 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] @@ -3706,6 +3782,8 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'from pathlib import Path\nPath("synthetic-owned").copy_into(Path("synthetic-destination"))\n', 'from pathlib import Path\nPath("synthetic-owned").move_into(Path("synthetic-destination"))\n', 'from pathlib import Path\nPath("synthetic-owned").lchmod(0o600)\n', + 'from pathlib import Path\nseen = Path("synthetic-owned")\n' + 'seen.copy(Path("synthetic-destination"))\n', 'from pathlib import Path\n' 'action = Path("synthetic-owned").move_into\n' 'action(Path("synthetic-destination"))\n', @@ -3729,6 +3807,7 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', + 'unknown_receiver.copy(Path("synthetic-destination"))\n', ) for body in unsafe: with self.subTest(body=body): @@ -3746,6 +3825,10 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' action = source.move_into\n' ' action(destination_dir)\n' ' source.lchmod(0o600)\n' + 'seen = {"ast-node"}\n' + 'seen_copy = seen.copy()\n' + 'seen_alias = seen\n' + 'seen_alias_copy = seen_alias.copy()\n' ) self.assertIsNone(self.inspect(safe)) From dba804f69cafcf2a1f171ad72a3db98288b70ed8 Mon Sep 17 00:00:00 2001 From: jjangg96 Date: Sat, 3 Oct 2026 23:46:20 +0900 Subject: [PATCH 061/123] Fix bounded Git capture proof compatibility --- docs/evidence/g01-recovery-packet.md | 370 +++++++++++++++--- .../issue79_regression_test.py | 221 +++++++++-- 2 files changed, 499 insertions(+), 92 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a789e245..7f2d7799 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9756,8 +9756,8 @@ def python_static_string_values(node, tree): 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()) + left = resolve_literal_string_values(value.left, set(seen)) + right = resolve_literal_string_values(value.right, set(seen)) 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} @@ -9790,14 +9790,14 @@ def python_static_string_values_from_local_calls(node, tree, parents, seen=None) if isinstance(value, ast.Call): values.update( python_static_string_values_from_local_calls( - value, tree, parents, seen.copy() + value, tree, parents, set(seen) ) ) 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() + returned, tree, parents, set(seen) ) ) return values @@ -9888,7 +9888,7 @@ def python_local_call_return_values(call, tree, parents): 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()) + resolve_receiver(returned, set(seen_names), set(seen_nodes)) elif isinstance(value, ast.Name) and value.id not in seen_names: seen_names.add(value.id) if value.id != "self": @@ -9899,7 +9899,7 @@ def python_local_call_return_values(call, tree, parents): for assignment in assignments_by_scope.get( (id(scope), value.id), () ): - resolve_receiver(assignment, seen_names.copy(), seen_nodes.copy()) + resolve_receiver(assignment, set(seen_names), set(seen_nodes)) else: enclosing = call_scope while enclosing is not None and not isinstance(enclosing, ast.ClassDef): @@ -10046,7 +10046,7 @@ def python_local_method_candidates(attribute, call, tree, parents): 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()) + resolve_receiver(returned, set(seen_names), set(seen_nodes)) elif isinstance(value, ast.Name) and value.id not in seen_names: seen_names.add(value.id) if value.id != "self": @@ -10058,7 +10058,7 @@ def python_local_method_candidates(attribute, call, tree, parents): (id(scope), value.id), () ): resolve_receiver( - assignment, seen_names.copy(), seen_nodes.copy() + assignment, set(seen_names), set(seen_nodes) ) else: enclosing = call_scope @@ -10259,7 +10259,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen return any( isinstance(value, (ast.Name, ast.Attribute, ast.Subscript)) and python_sensitive_value_expression( - value, sensitive_names, tree, parents, seen.copy() + value, sensitive_names, tree, parents, set(seen) ) for value in python_join_assignment_index(tree).get(node.id, ()) ) @@ -10271,14 +10271,14 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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() + node, sensitive_names, tree, parents, set(seen) ): return True return python_sensitive_value_expression( - node.value, sensitive_names, tree, parents, seen.copy() + node.value, sensitive_names, tree, parents, set(seen) ) 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() + node, sensitive_names, tree, parents, set(seen) ): return True if isinstance(node, ast.Call): @@ -10286,14 +10286,14 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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() + value, sensitive_names, tree, parents, set(seen) ) 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() + value, sensitive_names, tree, parents, set(seen) ) for value in list(node.args) + [keyword.value for keyword in node.keywords] @@ -10309,7 +10309,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen if local_format_callables: sensitive_return = any( python_sensitive_value_expression( - value, sensitive_names, tree, parents, seen.copy() + value, sensitive_names, tree, parents, set(seen) ) for value in python_local_call_return_values( node, tree, parents @@ -10330,7 +10330,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ): return any( python_sensitive_value_expression( - value, sensitive_names, tree, parents, seen.copy() + value, sensitive_names, tree, parents, set(seen) ) for value in list(node.args) + [keyword.value for keyword in node.keywords] @@ -10376,17 +10376,17 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen 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() + node.func.value, sensitive_names, tree, parents, set(seen) ) or any( python_sensitive_value_expression( - value, sensitive_names, tree, parents, seen.copy() + value, sensitive_names, tree, parents, set(seen) ) 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() + value, sensitive_names, tree, parents, set(seen) ) for value in list(node.args) + [keyword.value for keyword in node.keywords] @@ -10394,7 +10394,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen return True if any( python_sensitive_value_expression( - value, sensitive_names, tree, parents, seen.copy() + value, sensitive_names, tree, parents, set(seen) ) for value in python_local_call_return_values(node, tree, parents) ): @@ -10405,7 +10405,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ): return any( python_sensitive_value_expression( - value, sensitive_names, tree, parents, seen.copy() + value, sensitive_names, tree, parents, set(seen) ) for value in list(node.args) + [keyword.value for keyword in node.keywords] @@ -10416,7 +10416,7 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen }: return any( python_sensitive_value_expression( - argument, sensitive_names, tree, parents, seen.copy() + argument, sensitive_names, tree, parents, set(seen) ) for argument in node.args ) @@ -10426,13 +10426,13 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen }: return any( python_sensitive_value_expression( - argument, sensitive_names, tree, parents, seen.copy() + argument, sensitive_names, tree, parents, set(seen) ) 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() + node.func.value, sensitive_names, tree, parents, set(seen) ) return sensitive_receiver if isinstance(node, (ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp)): @@ -10444,22 +10444,22 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen return False return any( python_sensitive_value_expression( - generator.iter, sensitive_names, tree, parents, seen.copy() + generator.iter, sensitive_names, tree, parents, set(seen) ) 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()) + python_sensitive_value_expression(child, sensitive_names, tree, parents, set(seen)) 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() + node.value, sensitive_names, tree, parents, set(seen) ) if isinstance(node, (ast.List, ast.Tuple, ast.Set, ast.Dict)): return any( - python_sensitive_value_expression(child, sensitive_names, tree, parents, seen.copy()) + python_sensitive_value_expression(child, sensitive_names, tree, parents, set(seen)) for child in ast.iter_child_nodes(node) ) return False @@ -10546,7 +10546,7 @@ def python_sensitive_member_assignment_value( next_seen = seen | {marker} return any( python_sensitive_value_expression( - value, sensitive_names, tree, parents, next_seen.copy() + value, sensitive_names, tree, parents, set(next_seen) ) for value in python_sensitive_member_assignment_index(tree, parents).get(key, ()) ) @@ -10637,7 +10637,7 @@ def python_sensitive_join_argument(node, sensitive_names, tree, parents, seen=No if isinstance(node, ast.Name): return any( python_sensitive_join_argument( - candidate, sensitive_names, tree, parents, seen.copy() + candidate, sensitive_names, tree, parents, set(seen) ) for candidate in python_join_assignment_index(tree).get(node.id, ()) ) @@ -10647,33 +10647,33 @@ def python_sensitive_join_argument(node, sensitive_names, tree, parents, seen=No and node.func.attr in {"values", "items"} ): return python_sensitive_value_expression( - node.func.value, sensitive_names, tree, parents, seen.copy() + node.func.value, sensitive_names, tree, parents, set(seen) ) 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() + argument, sensitive_names, tree, parents, set(seen) ) 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() + argument, sensitive_names, tree, parents, set(seen) ) 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() + generator.iter, sensitive_names, tree, parents, set(seen) ) 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() + element, sensitive_names, tree, parents, set(seen) ) for element in node.elts ) @@ -10917,7 +10917,7 @@ def python_dynamic_execution_bindings(tree): 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] + states = [dynamic_state(element, set(seen)) for element in node.elts] if "dynamic" in states: return "dynamic" if "unresolved" in states: @@ -10927,7 +10927,7 @@ def python_dynamic_execution_bindings(tree): return "safe" if isinstance(node, ast.Dict): states = [ - dynamic_state(element, seen.copy()) + dynamic_state(element, set(seen)) for element in node.values if element is not None ] @@ -10939,7 +10939,7 @@ def python_dynamic_execution_bindings(tree): return "unknown" return "safe" if isinstance(node, ast.Subscript): - source = container_source(node.value, seen.copy()) + source = container_source(node.value, set(seen)) if source is None: return "unknown" key = node.slice @@ -10961,8 +10961,8 @@ def python_dynamic_execution_bindings(tree): selected = value break if selected is not None: - return dynamic_state(selected, seen.copy()) - container_state = dynamic_state(source, seen.copy()) + return dynamic_state(selected, set(seen)) + container_state = dynamic_state(source, set(seen)) if container_state in {"dynamic", "unresolved"}: return "unresolved" return "unknown" @@ -11566,7 +11566,7 @@ def python_compile_provenance(tree): if part.conversion != -1 or part.format_spec is not None: return None part = part.value - resolved = literal_reference(part, seen.copy()) + resolved = literal_reference(part, set(seen)) if resolved is None: return None parts.append(resolved) @@ -13954,7 +13954,8 @@ def reviewed_python_git_origin_capture_is_compared(tree, parents): and isinstance(value.ops[0], ast.NotEq) and len(value.comparators) == 1 and isinstance(value.comparators[0], ast.Constant) - and type(value.comparators[0].value) is int + and isinstance(value.comparators[0].value, int) + and not isinstance(value.comparators[0].value, bool) and value.comparators[0].value == 0 for value in candidate.test.values ) @@ -14023,14 +14024,116 @@ def python_git_origin_capture_violation(tree, parents): return "Python bounded Git origin output is not compared or escapes its verifier" -def reviewed_python_git_query_capture(node, tree, parents): - """Keep raw Git query output bounded and compared before its exception.""" +def reviewed_python_git_query_capture( + node, tree, parents, require_origin_comparison=True +): + """Keep raw Git query output on the bounded collector's active path.""" if ( enclosing_python_function(node, parents) != "run_bounded_git_query" or python_dotted_name(node.func) != "subprocess.Popen" or not reviewed_python_helper_definition(node, parents) or not reviewed_python_helper_launcher(node, parents) - or not reviewed_python_git_origin_capture_is_compared(tree, parents) + or ( + require_origin_comparison + and not reviewed_python_git_origin_capture_is_compared(tree, parents) + ) + ): + return False + 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: + return False + capture_assignments = [ + candidate + for candidate in ast.walk(function_node) + if isinstance(candidate, ast.Assign) + and python_enclosing_scope(candidate, parents) is function_node + and isinstance(candidate.value, ast.Call) + and python_dotted_name(candidate.value.func) == "capture_git_query_output" + ] + if len(capture_assignments) != 1: + return False + capture_assignment = capture_assignments[0] + if ( + len(capture_assignment.targets) != 1 + or not isinstance(capture_assignment.targets[0], ast.Tuple) + or [ + target.id + for target in capture_assignment.targets[0].elts + if isinstance(target, ast.Name) + ] != ["stdout", "stderr"] + or len(capture_assignment.targets[0].elts) != 2 + or not all( + isinstance(target, ast.Name) + for target in capture_assignment.targets[0].elts + ) + or len(capture_assignment.value.args) != 4 + or [python_dotted_name(argument) for argument in capture_assignment.value.args] + != ["process", "command", "output_limit", "input_bytes"] + or capture_assignment.value.keywords + ): + return False + + def on_unconditional_path(statement): + current = statement + while current is not function_node: + parent = parents.get(current) + if parent is None or isinstance( + parent, + ( + ast.If, + ast.IfExp, + ast.While, + ast.For, + ast.AsyncFor, + ast.Match, + ast.ExceptHandler, + ast.TryStar, + ), + ): + return False + if isinstance(parent, ast.Try) and current not in parent.body: + return False + current = parent + return True + + if not on_unconditional_path(capture_assignment): + return False + output_stores = [ + candidate + for candidate in ast.walk(function_node) + if isinstance(candidate, ast.Name) + and python_enclosing_scope(candidate, parents) is function_node + and candidate.id in {"stdout", "stderr"} + and isinstance(candidate.ctx, ast.Store) + ] + if len(output_stores) != 2 or { + candidate.id for candidate in output_stores + } != {"stdout", "stderr"} or any( + parents.get(parents.get(candidate)) is not capture_assignment + for candidate in output_stores + ): + return False + returns = [ + candidate + for candidate in ast.walk(function_node) + if isinstance(candidate, ast.Return) + and python_enclosing_scope(candidate, parents) is function_node + ] + if ( + len(returns) != 1 + or not on_unconditional_path(returns[0]) + or not isinstance(returns[0].value, ast.Call) + or python_dotted_name(returns[0].value.func) != "subprocess.CompletedProcess" + or [python_dotted_name(argument) for argument in returns[0].value.args] + != ["command", "returncode", "stdout", "stderr"] + or returns[0].value.keywords ): return False keywords = {keyword.arg: keyword.value for keyword in node.keywords} @@ -14177,7 +14280,7 @@ def reviewed_python_dynamic_path_value(node, tree=None, parents=None, seen=None) ): assignments.append(candidate.value) return bool(assignments) and all( - reviewed_python_dynamic_path_value(value, tree, parents, seen.copy()) + reviewed_python_dynamic_path_value(value, tree, parents, set(seen)) for value in assignments ) if isinstance(node, ast.JoinedStr): @@ -14186,16 +14289,16 @@ def reviewed_python_dynamic_path_value(node, tree=None, parents=None, seen=None) or ( isinstance(value, ast.FormattedValue) and reviewed_python_dynamic_path_value( - value.value, tree, parents, seen.copy() + value.value, tree, parents, set(seen) ) ) 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() + node.left, tree, parents, set(seen) ) and reviewed_python_dynamic_path_value( - node.right, tree, parents, seen.copy() + node.right, tree, parents, set(seen) ) return False @@ -14261,6 +14364,14 @@ def reviewed_python_dynamic_call( and argument.elts[3].value == "import json; print(json.__file__)" ): return True + if ( + python_dotted_name(node.func) == "subprocess.Popen" + and enclosing_python_function(node, parents) == "run_bounded_git_query" + and not reviewed_python_git_query_capture( + node, tree, parents, require_origin_comparison=False + ) + ): + return False if ( python_dotted_name(node.func) in python_command_functions and reviewed_python_git_command_origin(argument, tree, parents) @@ -14468,7 +14579,7 @@ def reviewed_source_snapshot_path(node, tree, parents, seen=None): ): continue if reviewed_source_snapshot_path( - candidate.value, tree, parents, seen.copy() + candidate.value, tree, parents, set(seen) ): return True return False @@ -15203,6 +15314,7 @@ def python_known_non_path_set_copy_receiver(node, tree, parents, active=None): """Prove .copy() receives a set from literals, builtins or set-preserving calls.""" if active is None: active = set() + if isinstance(node, ast.Set): return True if isinstance(node, ast.Call): @@ -15232,6 +15344,8 @@ def python_known_non_path_set_copy_receiver(node, tree, parents, active=None): if not isinstance(node, ast.Name) or id(node) in active: return False scope = python_enclosing_scope(node, parents) + if scope is None: + return False key = (id(scope), node.id) if key in active: return False @@ -15251,6 +15365,73 @@ def python_known_non_path_set_copy_receiver(node, tree, parents, active=None): ): assignments.append(value) + def target_binds_name(target): + if isinstance(target, ast.Name): + return target.id == node.id + if isinstance(target, ast.Starred): + return target_binds_name(target.value) + if isinstance(target, (ast.Tuple, ast.List)): + return any(target_binds_name(item) for item in target.elts) + return False + + for candidate in ast.walk(scope): + if python_enclosing_scope(candidate, parents) is not scope: + continue + if isinstance(candidate, ast.Assign): + targets = candidate.targets + if any( + target_binds_name(target) + and not (isinstance(target, ast.Name) and target.id == node.id) + for target in targets + ): + return False + elif isinstance(candidate, (ast.For, ast.AsyncFor)): + if target_binds_name(candidate.target): + return False + elif isinstance(candidate, (ast.With, ast.AsyncWith)): + if any( + item.optional_vars is not None + and target_binds_name(item.optional_vars) + for item in candidate.items + ): + return False + elif isinstance(candidate, ast.ExceptHandler) and candidate.name == node.id: + return False + elif isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if ( + candidate.name == node.id + and python_enclosing_scope(parents.get(candidate), parents) is scope + ): + return False + elif isinstance(candidate, ast.Import) and any( + (alias.asname or alias.name.split(".")[0]) == node.id + for alias in candidate.names + ): + return False + elif isinstance(candidate, ast.ImportFrom) and any( + (alias.asname or alias.name) == node.id + for alias in candidate.names + ): + return False + elif isinstance(candidate, (ast.MatchAs, ast.MatchStar)): + if candidate.name == node.id: + return False + elif isinstance(candidate, ast.MatchMapping) and candidate.rest == node.id: + return False + elif isinstance(candidate, (ast.Global, ast.Nonlocal)) and node.id in candidate.names: + return False + + if isinstance(scope, ast.Module) and any( + isinstance(candidate, ast.Global) and node.id in candidate.names + for candidate in ast.walk(tree) + ): + return False + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( + isinstance(candidate, ast.Nonlocal) and node.id in candidate.names + for candidate in ast.walk(scope) + ): + return False + def preserves_parameter(value, function, parameter_name, seen=None): if seen is None: seen = set() @@ -15578,7 +15759,7 @@ def python_path_receiver_expression(node, tree, parents, seen=None): return python_path_receiver_expression(node.func.value, tree, parents, seen) if any( python_path_receiver_expression( - returned, tree, parents, seen.copy() + returned, tree, parents, set(seen) ) for returned in python_local_call_return_values(node, tree, parents) ): @@ -15620,7 +15801,7 @@ def temporary_path_expression(node, tree, parents, seen=None): return False if seen is None: seen = set() - if reviewed_source_snapshot_path(node, tree, parents, seen.copy()): + if reviewed_source_snapshot_path(node, tree, parents, set(seen)): return True binding = temporary_directory_binding(node, parents) if binding is None: @@ -15912,7 +16093,7 @@ def python_environment_mapping_state(node, tree, seen=None, parents=None): for key, value in zip(node.keys, node.values): if key is None: unpacked_state = python_environment_mapping_state( - value, tree, seen.copy(), parents + value, tree, set(seen), parents ) if unpacked_state != "safe": return unpacked_state @@ -15975,7 +16156,7 @@ def python_environment_mapping_state(node, tree, seen=None, parents=None): 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 + candidate.value, tree, set(seen), parents ) ) if "unsafe" in states: @@ -16824,7 +17005,7 @@ def python_reviewed_read_path(node, tree, parents, seen=None): assignments.append(candidate.value) if assignments: return all( - python_reviewed_read_path(value, tree, parents, seen.copy()) + python_reviewed_read_path(value, tree, parents, set(seen)) for value in assignments ) if python_reviewed_read_path_parameter(node, tree, parents): @@ -18376,7 +18557,7 @@ def python_regex_match_receiver(node, tree, seen=None): if isinstance(value, ast.Name): candidates = assignments.get(value.id, ()) return bool(candidates) and all( - compiled_pattern(candidate, visited.copy()) + compiled_pattern(candidate, set(visited)) for candidate in candidates ) return False @@ -18394,7 +18575,7 @@ def python_regex_match_receiver(node, tree, seen=None): return True candidates = assignments.get(value.id, ()) return bool(candidates) and all( - regex_match_callable(candidate, visited.copy()) + regex_match_callable(candidate, set(visited)) for candidate in candidates ) if isinstance(value, ast.Attribute) and value.attr in { @@ -18418,7 +18599,7 @@ def python_regex_match_receiver(node, tree, seen=None): iterable_bindings.get(value.id, ()) ) return bool(candidates) and all( - yields_match(candidate, visited.copy()) + yields_match(candidate, set(visited)) for candidate in candidates ) if isinstance(value, ast.Call): @@ -18429,7 +18610,7 @@ def python_regex_match_receiver(node, tree, seen=None): "sorted", "tuple", }: return bool(value.args) and all( - yields_match(argument, visited.copy()) + yields_match(argument, set(visited)) for argument in value.args ) if isinstance(value.func, ast.Name) and value.func.id == "map": @@ -18437,16 +18618,16 @@ def python_regex_match_receiver(node, tree, seen=None): 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 + yields_match(argument, set(visited)) 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()) + return yields_match(value.elt, set(visited)) and all( + yields_match(generator.iter, set(visited)) 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 + yields_match(element, set(visited)) for element in value.elts ) return False @@ -35424,3 +35605,66 @@ inert strings/AST mutations; only the reviewed scanner and local offline harness ran. This worker did not run the full harness, the packet-wide selector, Go/hosted CI or any live query. The coordinator owns those exact-head checks and independent reviews; no full-scan or merge approval is implied. + +#### Origin-capture self-audit and active-result correction from `a9d602e5` + +At entry, the exact `a9d602e5bce88545a8652e9d4286b35cea985fd4` packet +selector had failed in 23.713s on one scanner self-audit finding: the literal +integer check in `reviewed_python_git_origin_capture_is_compared` used a +runtime `type(...)` construction. Before correcting it, the focused +self-audit regression failed in 10.876s with +`Python heredoc constructs or obtains an unreviewed runtime type`. The proof +now checks an integer AST constant with `isinstance(..., int)` and excludes +booleans explicitly; it introduces no runtime type construction. + +The same audit exposed two provenance gaps. An AST-only mutation moved the +bounded collector under `if False` while making `process.communicate()` the +active output source; its new focused check failed in 0.672s because the +mutated source was accepted. The scanner now requires one unconditional +`capture_git_query_output` assignment, explicit stdout/stderr pipes, and the +single unconditional `CompletedProcess` return to carry those captured +streams. The existing raw-output and origin-comparison checks still apply. +The original `test_shell_origin_url_query_is_rejected_but_verifier_capture_remains` +also now owns its expected URL, bounded pipe capture, and stdout/stderr +accounting assertions again; those assertions had been placed after the +unreachable return in the AST mutation helper. + +The Path mutation regression produced two failures in 0.138s for loop-target +and nested-global rebinding of an ordinary set name to an unowned `Path`; the +third destructuring form was already rejected by the scanner at this source. +The receiver proof now refuses reassignment through those binding forms. A +scanner self-audit run then found its own safe `seen.copy()` set clones as +filesystem `Path.copy()` calls (the combined intermediate run failed after +37.078s, and the focused retry failed after 47.426s). Internal visited-set +clones in the scanner are now written as `set(...)`, preserving the +filesystem-mutator check without a method-name exemption. + +The final focused run passed all five affected selectors in 45.850s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_shell_origin_url_query_is_rejected_but_verifier_capture_remains \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape \ + Issue79RegressionTests.test_origin_capture_checker_remains_self_audit_compatible \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 5 tests in 45.850s — OK +``` + +The active-result regression was then extended with a second inert AST +mutation that moves the collector call below the `CompletedProcess` return. +Its focused rerun passed in 0.741s, confirming the scanner refuses that +unreachable collector placement as well: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path +Ran 1 test in 0.741s — OK +``` + +The earlier 15-method focused result at `3e597eb2` remains the recorded +119-compiler-helper/10-loader evidence; this correction ran the focused +self-audit selector but did not rerun those broader selectors. The adversarial +programs remained source strings and AST mutations. The coordinator owns the +next exact-head packet scan and any full-suite validation; neither is claimed +here. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 258ba7fa..fe029d5e 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2641,6 +2641,172 @@ def test_shell_origin_url_query_is_rejected_but_verifier_capture_remains(self) - "a non-sensitive isolated status query remains accepted", ) + 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)) + expected_origin = _top_level_assignment(self.verification, "expected_origin_url") + self.assertEqual( + _literal_assignment_value(expected_origin), + "https://github.com/1XP-AI/gh-runnerd.git", + ) + 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) + + bounded_helper = _verification_function(self.verification, "run_bounded_git_query") + popen = next( + node + for node in ast.walk(bounded_helper) + if isinstance(node, ast.Call) + and self.scanner["python_dotted_name"](node.func) == "subprocess.Popen" # type: ignore[operator] + ) + popen_keywords = {keyword.arg: keyword.value for keyword in popen.keywords} + for stream in ("stdout", "stderr"): + self.assertEqual( + self.scanner["python_dotted_name"](popen_keywords[stream]), # type: ignore[operator] + "subprocess.PIPE", + ) + + bounded_capture = _verification_function(self.verification, "capture_git_query_output") + budget_guards = [ + node + for node in ast.walk(bounded_capture) + if isinstance(node, ast.Compare) + and len(node.ops) == 1 + and isinstance(node.ops[0], ast.Gt) + and len(node.comparators) == 1 + and isinstance(node.comparators[0], ast.Name) + and node.comparators[0].id == "output_limit" + and isinstance(node.left, ast.BinOp) + and isinstance(node.left.op, ast.Add) + ] + self.assertEqual(1, len(budget_guards)) + bounded_lengths = [ + node + for node in ast.walk(budget_guards[0].left) + if isinstance(node, ast.Call) + and self.scanner["python_dotted_name"](node.func) == "len" # type: ignore[operator] + and len(node.args) == 1 + ] + captured_streams = { + node.args[0].slice.value + for node in bounded_lengths + if isinstance(node.args[0], ast.Subscript) + and isinstance(node.args[0].value, ast.Name) + and node.args[0].value.id == "captures" + and isinstance(node.args[0].slice, ast.Constant) + } + self.assertEqual({"stdout", "stderr"}, captured_streams) + self.assertTrue( + any( + isinstance(node.args[0], ast.Name) + and node.args[0].id == "chunk" + for node in bounded_lengths + ) + ) + + def test_origin_capture_checker_remains_self_audit_compatible(self) -> None: + scanner_body, marker = self.packet_heredoc_containing( + "def reviewed_python_git_origin_capture_is_compared(tree, parents):", + "isinstance(value.comparators[0].value, int)", + "not isinstance(value.comparators[0].value, bool)", + "def python_git_origin_capture_violation(tree, parents):", + ) + self.assertIsNone( + self.scanner["inspect_python_heredoc"](scanner_body, marker), # type: ignore[operator] + "the current origin-capture proof must pass the scanner's own source checks", + ) + + def test_git_query_capture_must_be_on_the_active_result_path(self) -> None: + bounded_body, marker = self.packet_heredoc_containing( + "def run_bounded_git_query(", + "stdout, stderr = capture_git_query_output(", + ) + self.assertIsNone(self.scanner["inspect_python_heredoc"](bounded_body, marker)) # type: ignore[operator] + tree = ast.parse(bounded_body, filename="") + function = _verification_function(tree, "run_bounded_git_query") + capture_assignment = next( + node + for node in ast.walk(function) + if isinstance(node, ast.Assign) + and isinstance(node.value, ast.Call) + and self.scanner["python_dotted_name"](node.value.func) == "capture_git_query_output" # type: ignore[operator] + ) + bounded_call = capture_assignment.value + capture_assignment.value = ast.Call( + func=ast.Attribute( + value=ast.Name(id="process", ctx=ast.Load()), + attr="communicate", + ctx=ast.Load(), + ), + args=[], + keywords=[], + ) + function.body.insert( + 0, + ast.If( + test=ast.Constant(value=False), + body=[ast.Expr(value=bounded_call)], + orelse=[], + ), + ) + ast.fix_missing_locations(tree) + self.assertIsNotNone( + self.scanner["inspect_python_heredoc"](ast.unparse(tree), marker), # type: ignore[operator] + "a dead bounded collector must not certify an unbounded communicate result", + ) + + after_return_tree = ast.parse(bounded_body, filename="") + after_return_function = _verification_function( + after_return_tree, "run_bounded_git_query" + ) + after_return_parents = { + child: parent + for parent in ast.walk(after_return_tree) + for child in ast.iter_child_nodes(parent) + } + late_capture = next( + node + for node in ast.walk(after_return_function) + if isinstance(node, ast.Assign) + and isinstance(node.value, ast.Call) + and self.scanner["python_dotted_name"](node.value.func) # type: ignore[operator] + == "capture_git_query_output" + ) + completed_return = next( + node + for node in ast.walk(after_return_function) + if isinstance(node, ast.Return) + and isinstance(node.value, ast.Call) + and self.scanner["python_dotted_name"](node.value.func) # type: ignore[operator] + == "subprocess.CompletedProcess" + ) + capture_parent = after_return_parents[late_capture] + return_parent = after_return_parents[completed_return] + self.assertIsInstance(capture_parent, ast.Try) + self.assertIsInstance(return_parent, ast.Try) + capture_parent.body.remove(late_capture) # type: ignore[union-attr] + return_parent.body.insert( # type: ignore[union-attr] + return_parent.body.index(completed_return) + 1, late_capture + ) + ast.fix_missing_locations(after_return_tree) + self.assertIsNotNone( + self.scanner["inspect_python_heredoc"]( # type: ignore[operator] + ast.unparse(after_return_tree), marker + ), + "a collector placed after the returned result must not certify its output", + ) + def test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape(self) -> None: verification_body, verification_marker = self.packet_heredoc_containing( @@ -2683,18 +2849,18 @@ def test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_e verification_body ), ), - ): - with self.subTest(mutant=label): - mutant_tree = ast.parse(mutant) - mutant_parents = { - child: parent - for parent in ast.walk(mutant_tree) - for child in ast.iter_child_nodes(parent) - } - self.assertFalse( - comparison_proof(mutant_tree, mutant_parents) # type: ignore[operator] - ) - self.assertIsNotNone( + ): + with self.subTest(mutant=label): + mutant_tree = ast.parse(mutant) + mutant_parents = { + child: parent + for parent in ast.walk(mutant_tree) + for child in ast.iter_child_nodes(parent) + } + self.assertFalse( + comparison_proof(mutant_tree, mutant_parents) # type: ignore[operator] + ) + self.assertIsNotNone( inspect_verification(mutant, verification_marker), # type: ignore[operator] label, ) @@ -2717,23 +2883,6 @@ def _verification_without_origin_comparison(body: str) -> str: mutated.body.remove(comparisons[0]) return ast.unparse(mutated) - 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", @@ -3807,6 +3956,20 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', + 'from pathlib import Path\n' + 'seen = {"node"}\n' + 'for seen in [Path("synthetic-unowned-source")]:\n' + ' seen.copy(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\n' + 'seen, other = (Path("synthetic-unowned-source"), None)\n' + 'seen.copy(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\n' + 'seen = set()\n' + 'def retarget():\n' + ' global seen\n' + ' seen = Path("synthetic-unowned-source")\n' + 'retarget()\n' + 'seen.copy(Path("synthetic-unowned-destination"))\n', 'unknown_receiver.copy(Path("synthetic-destination"))\n', ) for body in unsafe: From 628c3b4d1171fc63c2120e5bf78c9c4f0c891e90 Mon Sep 17 00:00:00 2001 From: jjangg96 Date: Sun, 4 Oct 2026 00:01:05 +0900 Subject: [PATCH 062/123] Correct seeded Path mutator regression evidence --- docs/evidence/g01-recovery-packet.md | 49 ++++++++++++++++--- .../issue79_regression_test.py | 1 + 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 7f2d7799..cf20521d 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -35491,7 +35491,8 @@ helper and explicit stdout/stderr pipes, and the existing origin comparison remains intact. No compiler input, source revision, module-wide exemption or helper allowlist was added. -The bounded post-fix selection passed all three new finding methods and the +At resulting exact source `3e597eb2`, the bounded post-fix selection passed all +three new finding methods and the existing displayhook, pathlib-reader/mutator, OS-re-export, isolated-Git, builder-rebinding and argv-mutation controls, plus the current compiler-helper and historical-loader controls: @@ -35575,7 +35576,10 @@ result are outside the certified shape; the bounded child capture, explicit pipes and exact expected URL stay required. The tests directly exercise raw, aliased and decoded output plus a removed comparison. -The post-correction focused verification passed 15 methods in 10.789s: +At the later exact `a9d602e5` checkpoint, the 15-method focused verification +passed in 10.789s and recorded 119 actual compiler-helper checks plus ten +historical loader controls. This 15-method result belongs to `a9d602e5`, not +`3e597eb2`: ```text python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ @@ -35630,9 +35634,11 @@ accounting assertions again; those assertions had been placed after the unreachable return in the AST mutation helper. The Path mutation regression produced two failures in 0.138s for loop-target -and nested-global rebinding of an ordinary set name to an unowned `Path`; the -third destructuring form was already rejected by the scanner at this source. -The receiver proof now refuses reassignment through those binding forms. A +and nested-global rebinding of an ordinary set name to an unowned `Path`. Its +third destructuring fixture omitted the initial ordinary-set seed and did not +reproduce the coordinator's seeded witness, which was accepted at immutable +`a9d602e5`. The current receiver proof now refuses the seeded form along with +reassignment through the other binding forms. A scanner self-audit run then found its own safe `seen.copy()` set clones as filesystem `Path.copy()` calls (the combined intermediate run failed after 37.078s, and the focused retry failed after 47.426s). Internal visited-set @@ -35662,9 +35668,38 @@ python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ Ran 1 test in 0.741s — OK ``` -The earlier 15-method focused result at `3e597eb2` remains the recorded -119-compiler-helper/10-loader evidence; this correction ran the focused +The separate 13-method focused selector at `3e597eb2` passed in 5.567s and +recorded 119 actual compiler-helper checks plus ten historical loader +controls. The `a9d602e5` result is the 15-method, 10.789s run recorded above; +these results are from distinct checkpoints. This correction ran the focused self-audit selector but did not rerun those broader selectors. The adversarial programs remained source strings and AST mutations. The coordinator owns the next exact-head packet scan and any full-suite validation; neither is claimed here. + +#### Coordinator validation and seeded destructuring control at `dba804f` + +The coordinator reported that the full 168-test harness passed in 160.796s at +exact source `dba804f69cafcf2a1f171ad72a3db98288b70ed8`. At that same exact +source, its current-packet selector reported 261 shell commands and 86 Python +bodies, zero violations, 119 actual compiler-helper calls and ten historical +loader controls. This result is pinned to `dba804f`; it does not certify the +later test and ledger-only delta in this entry. No Go/hosted-CI or live +operation result is claimed. + +The destructuring witness in the Path-mutator regression previously omitted +the ordinary-set seed and therefore did not reproduce the coordinator's +accepted witness at `a9d602e5`: `seen = {"node"}` followed by destructuring +`seen, other = (Path("synthetic-unowned-source"), None)`. The focused fixture +now includes that initial set assignment, so the requested selector exercises +the reported entry state and refuses it at the `dba804f` source: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.176s — OK +``` + +This follow-up changes only the regression source and this ledger; scanner +source and executable compiler recipes are unchanged. The witness remains an +inert Python source string parsed by the harness. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index fe029d5e..d58a4991 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3961,6 +3961,7 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'for seen in [Path("synthetic-unowned-source")]:\n' ' seen.copy(Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\n' + 'seen = {"node"}\n' 'seen, other = (Path("synthetic-unowned-source"), None)\n' 'seen.copy(Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\n' From 062fade5d0b3f5067a9620b9cd91c6ff9de4abb0 Mon Sep 17 00:00:00 2001 From: jjangg96 Date: Sun, 4 Oct 2026 00:48:49 +0900 Subject: [PATCH 063/123] Prove bounded Git query capture provenance --- docs/evidence/g01-recovery-packet.md | 972 +++++++++++++++--- .../issue79_regression_test.py | 139 +++ 2 files changed, 980 insertions(+), 131 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index cf20521d..3d788662 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14024,6 +14024,718 @@ def python_git_origin_capture_violation(tree, parents): return "Python bounded Git origin output is not compared or escapes its verifier" +def reviewed_python_git_query_output_budget(tree, parents, runner): + """Prove the collector limit comes from one of the packet's finite caps.""" + limit_names = { + "git_query_output_max_bytes", + "git_query_packet_blob_output_max_bytes", + } + chunk_name = "git_query_stream_chunk_bytes" + constant_names = limit_names | {chunk_name} + + def positive_integer(node): + if isinstance(node, ast.Constant): + return node.value if isinstance(node.value, int) and not isinstance(node.value, bool) and node.value > 0 else None + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mult)): + left = positive_integer(node.left) + right = positive_integer(node.right) + if left is not None and right is not None: + return left + right if isinstance(node.op, ast.Add) else left * right + return None + + definitions = {} + for statement in tree.body: + if not isinstance(statement, ast.Assign) or len(statement.targets) != 1: + continue + target = statement.targets[0] + if isinstance(target, ast.Name) and target.id in constant_names: + if target.id in definitions: + return False + definitions[target.id] = statement.value + if set(definitions) != constant_names or any( + positive_integer(value) is None for value in definitions.values() + ): + return False + if positive_integer(definitions[chunk_name]) > min( + positive_integer(definitions[name]) for name in limit_names + ): + return False + if any( + isinstance(node, ast.Name) + and node.id in constant_names + and isinstance(node.ctx, ast.Store) + and not ( + isinstance(parents.get(node), ast.Assign) + and parents[node] in tree.body + and parents[node].value is definitions[node.id] + ) + for node in ast.walk(tree) + ): + return False + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if node.name in constant_names: + return False + elif isinstance(node, ast.arg) and node.arg in constant_names: + return False + elif isinstance(node, ast.ExceptHandler) and node.name in constant_names: + return False + elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name in constant_names: + return False + elif isinstance(node, ast.MatchMapping) and node.rest in constant_names: + return False + elif isinstance(node, (ast.Import, ast.ImportFrom)): + aliases = node.names + if any( + (alias.asname or alias.name.split(".")[0]) in constant_names + for alias in aliases + ): + return False + if isinstance(node, ast.ImportFrom) and any( + (alias.asname or alias.name) in constant_names for alias in aliases + ): + return False + elif isinstance(node, (ast.Global, ast.Nonlocal)) and any( + name in constant_names for name in node.names + ): + return False + + parameters = runner.args.kwonlyargs + output_parameter = next( + (index for index, parameter in enumerate(parameters) if parameter.arg == "output_limit"), + None, + ) + if output_parameter is None: + return False + if any( + isinstance(node, ast.Name) + and node.id == "output_limit" + and isinstance(node.ctx, ast.Store) + and python_enclosing_scope(node, parents) is runner + for node in ast.walk(runner) + ): + return False + if any( + node is not runner + and python_enclosing_scope(node, parents) is runner + and ( + isinstance(node, (ast.Import, ast.ImportFrom, ast.Match, ast.Global, ast.Nonlocal)) + or isinstance(node, ast.ExceptHandler) and node.name is not None + or isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)) + ) + for node in ast.walk(runner) + ): + return False + default = runner.args.kw_defaults[output_parameter] + if not ( + isinstance(default, ast.Name) + and default.id == "git_query_output_max_bytes" + ): + return False + guards = [ + node + for node in ast.walk(runner) + if isinstance(node, ast.If) + and python_enclosing_scope(node, parents) is runner + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Name) + and node.test.left.id == "output_limit" + and len(node.test.ops) == 1 + and isinstance(node.test.ops[0], ast.NotIn) + and len(node.test.comparators) == 1 + and isinstance(node.test.comparators[0], ast.Set) + and { + element.id for element in node.test.comparators[0].elts + if isinstance(element, ast.Name) + } == limit_names + and len(node.test.comparators[0].elts) == 2 + and len(node.body) == 1 + and isinstance(node.body[0], ast.Raise) + and isinstance(node.body[0].exc, ast.Call) + and python_dotted_name(node.body[0].exc.func) == "SystemExit" + ] + if len(guards) != 1: + return False + + calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "run_bounded_git_query" + ] + if not calls: + return False + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Name) + and node.id == "run_bounded_git_query" + and isinstance(node.ctx, ast.Load) + ): + continue + parent = parents.get(node) + if not isinstance(parent, ast.Call) or parent.func is not node or parent not in calls: + return False + for call in calls: + if len(call.args) != 1 or any(keyword.arg is None for keyword in call.keywords): + return False + supplied_limits = [keyword.value for keyword in call.keywords if keyword.arg == "output_limit"] + if len(supplied_limits) > 1 or ( + supplied_limits + and not ( + isinstance(supplied_limits[0], ast.Name) + and supplied_limits[0].id in limit_names + ) + ): + return False + return True + + +def reviewed_python_git_query_collector(tree, parents): + """Prove bounded output bytes come only from the registered process pipes.""" + collectors = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and node.name == "capture_git_query_output" + ] + runners = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and node.name == "run_bounded_git_query" + ] + if len(collectors) != 1 or len(runners) != 1: + return False + function = collectors[0] + runner = runners[0] + if not reviewed_python_git_query_output_budget(tree, parents, runner): + return False + if any( + node is not function + and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)) + and python_enclosing_scope(node, parents) is function + for node in ast.walk(function) + ): + return False + if ( + function.args.posonlyargs + or [argument.arg for argument in function.args.args] + != ["process", "git_command", "output_limit", "input_bytes"] + or function.args.kwonlyargs + or function.args.vararg is not None + or function.args.kwarg is not None + or len(function.args.defaults) != 1 + or not isinstance(function.args.defaults[0], ast.Constant) + or function.args.defaults[0].value is not None + ): + return False + + local_nodes = [ + node + for node in ast.walk(function) + if python_enclosing_scope(node, parents) is function + ] + local_calls = [node for node in local_nodes if isinstance(node, ast.Call)] + if any( + isinstance(node, (ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Match)) + or isinstance(node, ast.ExceptHandler) and node.name is not None + or isinstance(node, ast.Delete) + or isinstance(node, (ast.Attribute, ast.Subscript)) + and isinstance(node.ctx, (ast.Store, ast.Del)) + for node in local_nodes + ): + return False + store_names = sorted( + node.id + for node in local_nodes + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) + ) + if store_names != sorted( + [ + "captures", "pending_input", "pending_input", "selector", "name", + "deadline", "remaining", "remaining", "stream", "stream", "stream", + "ready", "key", "_", "chunk", "written", + ] + ): + return False + call_names = sorted(ast.unparse(node.func) for node in local_calls) + if call_names != sorted( + [ + "selectors.DefaultSelector", "SystemExit", "SystemExit", "SystemExit", + "SystemExit", "bytearray", "bytearray", "memoryview", "selector.get_map", + "process.wait", "selector.close", "getattr", "getattr", "os.set_blocking", + "os.set_blocking", "len", "len", "len", "len", "time.monotonic", + "time.monotonic", "time.monotonic", "selector.select", + "subprocess.TimeoutExpired", "subprocess.TimeoutExpired", + "subprocess.TimeoutExpired", "bytes", "bytes", "selector.register", + "selector.register", "stream.fileno", "stream.fileno", "stream.fileno", + "stream.fileno", "stream.close", "stream.close", "stream.close", + "captures[key.data].extend", "os.read", "selector.unregister", + "selector.unregister", "os.write", + ] + ): + return False + + def is_name(node, name): + return isinstance(node, ast.Name) and node.id == name + + def is_buffer(node, key): + return ( + isinstance(node, ast.Subscript) + and is_name(node.value, "captures") + and isinstance(node.slice, ast.Constant) + and node.slice.value == key + and isinstance(node.ctx, ast.Load) + ) + + def is_len(node, argument): + return ( + isinstance(node, ast.Call) + and is_name(node.func, "len") + and len(node.args) == 1 + and not node.keywords + and node.args[0] is argument + and python_unshadowed_builtin_call(node, {"len"}, tree, parents) + ) + + def budget_test(node): + if not ( + isinstance(node, ast.Compare) + and len(node.ops) == 1 + and isinstance(node.ops[0], ast.Gt) + and len(node.comparators) == 1 + and is_name(node.comparators[0], "output_limit") + and isinstance(node.left, ast.BinOp) + and isinstance(node.left.op, ast.Add) + and isinstance(node.left.left, ast.BinOp) + and isinstance(node.left.left.op, ast.Add) + ): + return False + first, second, third = ( + node.left.left.left, + node.left.left.right, + node.left.right, + ) + return ( + isinstance(first, ast.Call) + and len(first.args) == 1 + and is_buffer(first.args[0], "stdout") + and is_len(first, first.args[0]) + and isinstance(second, ast.Call) + and len(second.args) == 1 + and is_buffer(second.args[0], "stderr") + and is_len(second, second.args[0]) + and isinstance(third, ast.Call) + and len(third.args) == 1 + and is_name(third.args[0], "chunk") + and is_len(third, third.args[0]) + ) + + guards = [ + node for node in local_nodes + if isinstance(node, ast.If) and budget_test(node.test) + ] + if ( + len(guards) != 1 + or len(guards[0].body) != 1 + or not isinstance(guards[0].body[0], ast.Raise) + or not isinstance(guards[0].body[0].exc, ast.Call) + or python_dotted_name(guards[0].body[0].exc.func) != "SystemExit" + or guards[0].orelse + ): + return False + budget_guard = guards[0] + initial_limits = [ + node for node in function.body + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and is_name(node.test.left, "output_limit") + and len(node.test.ops) == 1 + and isinstance(node.test.ops[0], ast.LtE) + and len(node.test.comparators) == 1 + and isinstance(node.test.comparators[0], ast.Constant) + and node.test.comparators[0].value == 0 + and len(node.body) == 1 + and isinstance(node.body[0], ast.Raise) + ] + if len(initial_limits) != 1: + return False + + captures_assignments = [ + node for node in local_nodes + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and is_name(node.targets[0], "captures") + ] + if len(captures_assignments) != 1 or parents.get(captures_assignments[0]) is not function: + return False + captures_assignment = captures_assignments[0] + captures = captures_assignment.value + if not isinstance(captures, ast.Dict) or [ + key.value if isinstance(key, ast.Constant) else None for key in captures.keys + ] != ["stdout", "stderr"]: + return False + + selector_assignments = [ + node for node in local_nodes + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and is_name(node.targets[0], "selector") + and isinstance(node.value, ast.Call) + and python_dotted_name(node.value.func) == "selectors.DefaultSelector" + and not node.value.args + and not node.value.keywords + ] + if ( + len(selector_assignments) != 1 + or parents.get(selector_assignments[0]) is not function + ): + return False + bytearrays = [ + node for node in local_calls + if is_name(node.func, "bytearray") + ] + if len(bytearrays) != 2 or any( + node.args or node.keywords + or not python_unshadowed_builtin_call(node, {"bytearray"}, tree, parents) + for node in bytearrays + ) or [ + value for value in captures.values if isinstance(value, ast.Call) + ] != bytearrays: + return False + + returns = [node for node in local_nodes if isinstance(node, ast.Return)] + if len(returns) != 1 or not isinstance(returns[0].value, ast.Tuple) or len(returns[0].value.elts) != 2: + return False + return_value = returns[0].value + returned_buffers = [] + byte_calls = [] + for expected, item in zip(("stdout", "stderr"), return_value.elts): + if not ( + isinstance(item, ast.Call) + and is_name(item.func, "bytes") + and len(item.args) == 1 + and not item.keywords + and is_buffer(item.args[0], expected) + and python_unshadowed_builtin_call(item, {"bytes"}, tree, parents) + ): + return False + returned_buffers.append(item.args[0]) + byte_calls.append(item) + if [node for node in local_calls if is_name(node.func, "bytes")] != byte_calls: + return False + + extensions = [ + node for node in local_calls + if isinstance(node.func, ast.Attribute) and node.func.attr == "extend" + ] + if ( + len(extensions) != 1 + or len(extensions[0].args) != 1 + or extensions[0].keywords + or not is_name(extensions[0].args[0], "chunk") + or not isinstance(extensions[0].func.value, ast.Subscript) + or not is_name(extensions[0].func.value.value, "captures") + or not isinstance(extensions[0].func.value.slice, ast.Attribute) + or not is_name(extensions[0].func.value.slice.value, "key") + or extensions[0].func.value.slice.attr != "data" + ): + return False + extension = extensions[0] + extension_statement = parents.get(extension) + if not isinstance(extension_statement, ast.Expr): + return False + + read_calls = [ + node for node in local_calls + if python_dotted_name(node.func) == "os.read" + ] + if ( + len(read_calls) != 1 + or len(read_calls[0].args) != 2 + or read_calls[0].keywords + or not isinstance(read_calls[0].args[0], ast.Call) + or python_dotted_name(read_calls[0].args[0].func) != "stream.fileno" + or read_calls[0].args[0].args + or read_calls[0].args[0].keywords + or not is_name(read_calls[0].args[1], "git_query_stream_chunk_bytes") + ): + return False + read_assignment = parents.get(read_calls[0]) + if not ( + isinstance(read_assignment, ast.Assign) + and len(read_assignment.targets) == 1 + and is_name(read_assignment.targets[0], "chunk") + and read_assignment.value is read_calls[0] + ): + return False + read_try = parents.get(read_assignment) + if not ( + isinstance(read_try, ast.Try) + and read_assignment in read_try.body + and len(read_try.handlers) == 1 + and isinstance(read_try.handlers[0].type, ast.Name) + and read_try.handlers[0].type.id == "BlockingIOError" + and len(read_try.handlers[0].body) == 1 + and isinstance(read_try.handlers[0].body[0], ast.Continue) + ): + return False + + ready_assignments = [ + node for node in local_nodes + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and is_name(node.targets[0], "ready") + and isinstance(node.value, ast.Call) + and python_dotted_name(node.value.func) == "selector.select" + and [python_dotted_name(argument) for argument in node.value.args] == ["remaining"] + and not node.value.keywords + ] + if len(ready_assignments) != 1: + return False + ready_assignment = ready_assignments[0] + ready_loops = [ + node for node in local_nodes + if isinstance(node, ast.For) + and isinstance(node.target, ast.Tuple) + and len(node.target.elts) == 2 + and is_name(node.target.elts[0], "key") + and is_name(node.target.elts[1], "_") + and is_name(node.iter, "ready") + ] + if len(ready_loops) != 1: + return False + ready_loop = ready_loops[0] + while_node = parents.get(ready_loop) + if not ( + isinstance(while_node, ast.While) + and isinstance(while_node.test, ast.Call) + and python_dotted_name(while_node.test.func) == "selector.get_map" + and not while_node.test.args + and not while_node.test.keywords + and not while_node.orelse + and ready_loop in while_node.body + and ready_assignment in while_node.body + and while_node.body.index(ready_assignment) < while_node.body.index(ready_loop) + ): + return False + wait_calls = [ + node for node in local_calls + if python_dotted_name(node.func) == "process.wait" + ] + if ( + len(wait_calls) != 1 + or wait_calls[0].args + or len(wait_calls[0].keywords) != 1 + or wait_calls[0].keywords[0].arg != "timeout" + or not is_name(wait_calls[0].keywords[0].value, "remaining") + ): + return False + outer_try = parents.get(while_node) + wait_statement = parents.get(wait_calls[0]) + if not isinstance(wait_statement, ast.Expr): + return False + return_statement = returns[0] + if not ( + isinstance(outer_try, ast.Try) + and while_node in outer_try.body + and wait_statement in outer_try.body + and return_statement in outer_try.body + and outer_try.body.index(while_node) + < outer_try.body.index(wait_statement) + < outer_try.body.index(return_statement) + ): + return False + read_loop = next( + ( + parent for parent in _python_parent_chain(read_assignment, parents) + if isinstance(parent, ast.For) + ), + None, + ) + if read_loop is not ready_loop or read_try not in ready_loop.body: + return False + guard_statement = budget_guard + if ( + parents.get(guard_statement) is not ready_loop + or parents.get(extension_statement) is not ready_loop + or ready_loop.body.index(guard_statement) >= ready_loop.body.index(extension_statement) + ): + return False + + read_stream_assignments = [ + node for node in local_nodes + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and is_name(node.targets[0], "stream") + and isinstance(node.value, ast.Attribute) + and is_name(node.value.value, "key") + and node.value.attr == "fileobj" + ] + if len(read_stream_assignments) != 1 or parents.get(read_stream_assignments[0]) is not ready_loop: + return False + output_loops = [ + node for node in local_nodes + if isinstance(node, ast.For) + and is_name(node.target, "name") + and is_name(node.iter, "captures") + ] + if len(output_loops) != 1: + return False + output_loop = output_loops[0] + process_getattrs = [ + node for node in local_calls + if is_name(node.func, "getattr") + and len(node.args) == 3 + and is_name(node.args[0], "process") + and isinstance(node.args[2], ast.Constant) + and node.args[2].value is None + and not node.keywords + and python_unshadowed_builtin_call(node, {"getattr"}, tree, parents) + ] + stream_getattrs = [ + node for node in process_getattrs if is_name(node.args[1], "name") + ] + stdin_getattrs = [ + node for node in process_getattrs + if isinstance(node.args[1], ast.Constant) and node.args[1].value == "stdin" + ] + if len(stream_getattrs) != 1 or len(stdin_getattrs) != 1: + return False + if any( + not ( + isinstance(parents.get(node), ast.Assign) + and len(parents[node].targets) == 1 + and is_name(parents[node].targets[0], "stream") + and parents[node].value is node + ) + for node in process_getattrs + ) or parents[stream_getattrs[0]] not in output_loop.body: + return False + process_loads = [ + node for node in local_nodes + if is_name(node, "process") and isinstance(node.ctx, ast.Load) + ] + process_read_nodes = [call.args[0] for call in process_getattrs] + if wait_calls: + process_read_nodes.append(wait_calls[0].func.value) + if len(process_loads) != 3 or any( + all(node is not expected for expected in process_read_nodes) + for node in process_loads + ): + return False + read_registrations = [ + node for node in local_calls + if python_dotted_name(node.func) == "selector.register" + and len(node.args) == 3 + and python_dotted_name(node.args[1]) == "selectors.EVENT_READ" + and is_name(node.args[0], "stream") + and is_name(node.args[2], "name") + and not node.keywords + ] + write_registrations = [ + node for node in local_calls + if python_dotted_name(node.func) == "selector.register" + and len(node.args) == 3 + and python_dotted_name(node.args[1]) == "selectors.EVENT_WRITE" + and is_name(node.args[0], "stream") + and isinstance(node.args[2], ast.Constant) + and node.args[2].value == "stdin" + and not node.keywords + ] + if ( + len(read_registrations) != 1 + or len(write_registrations) != 1 + or parents.get(read_registrations[0]) not in ast.walk(output_loop) + ): + return False + read_registration_statement = parents[read_registrations[0]] + read_registration_guard = parents.get(read_registration_statement) + if not ( + isinstance(read_registration_guard, ast.If) + and isinstance(read_registration_guard.test, ast.Compare) + and is_name(read_registration_guard.test.left, "stream") + and len(read_registration_guard.test.ops) == 1 + and isinstance(read_registration_guard.test.ops[0], ast.IsNot) + and len(read_registration_guard.test.comparators) == 1 + and isinstance(read_registration_guard.test.comparators[0], ast.Constant) + and read_registration_guard.test.comparators[0].value is None + and read_registration_statement in read_registration_guard.body + ): + return False + + capture_subscripts = [ + node for node in local_nodes + if isinstance(node, ast.Subscript) and is_name(node.value, "captures") + ] + allowed_capture_subscripts = set(returned_buffers) + allowed_capture_subscripts.update( + node + for node in ast.walk(budget_guard.test) + if isinstance(node, ast.Subscript) and is_name(node.value, "captures") + ) + allowed_capture_subscripts.add(extensions[0].func.value) + if set(capture_subscripts) != allowed_capture_subscripts: + return False + capture_stores = [ + node for node in local_nodes + if is_name(node, "captures") and isinstance(node.ctx, ast.Store) + ] + if len(capture_stores) != 1 or capture_stores[0] is not captures_assignment.targets[0]: + return False + capture_loads = [ + node for node in local_nodes + if is_name(node, "captures") and isinstance(node.ctx, ast.Load) + ] + if len(capture_loads) != len(allowed_capture_subscripts) + 1 or any( + parents.get(node) is not output_loop and parents.get(node) not in allowed_capture_subscripts + for node in capture_loads + ): + return False + chunk_stores = [ + node for node in local_nodes + if is_name(node, "chunk") and isinstance(node.ctx, ast.Store) + ] + chunk_loads = [ + node for node in local_nodes + if is_name(node, "chunk") and isinstance(node.ctx, ast.Load) + ] + if ( + len(chunk_stores) != 1 + or chunk_stores[0] is not read_assignment.targets[0] + or len(chunk_loads) != 3 + or not any( + isinstance(parents.get(node), ast.UnaryOp) + and isinstance(parents[node].op, ast.Not) + and parents[node].operand is node + and isinstance(parents.get(parents[node]), ast.If) + and parents[parents[node]] in ready_loop.body + and not parents[parents[node]].orelse + and any( + isinstance(statement, ast.Continue) + for statement in parents[parents[node]].body + ) + for node in chunk_loads + ) + or not any( + isinstance(parents.get(node), ast.Call) + and python_dotted_name(parents[node].func) == "len" + and parents[node] in ast.walk(budget_guard.test) + and len(parents[node].args) == 1 + and parents[node].args[0] is node + for node in chunk_loads + ) + or not any(parents.get(node) is extension for node in chunk_loads) + ): + return False + + bytes_calls = [node for node in local_calls if is_name(node.func, "bytes")] + if len(bytes_calls) != 2 or bytearrays != [value for value in captures.values if isinstance(value, ast.Call)]: + return False + return True + + def reviewed_python_git_query_capture( node, tree, parents, require_origin_comparison=True ): @@ -14033,6 +14745,7 @@ def reviewed_python_git_query_capture( or python_dotted_name(node.func) != "subprocess.Popen" or not reviewed_python_helper_definition(node, parents) or not reviewed_python_helper_launcher(node, parents) + or not reviewed_python_git_query_collector(tree, parents) or ( require_origin_comparison and not reviewed_python_git_origin_capture_is_compared(tree, parents) @@ -14049,6 +14762,40 @@ def reviewed_python_git_query_capture( ) if function_node is None: return False + process_assignments = [ + candidate + for candidate in ast.walk(function_node) + if isinstance(candidate, ast.Assign) + and python_enclosing_scope(candidate, parents) is function_node + and len(candidate.targets) == 1 + and isinstance(candidate.targets[0], ast.Name) + and candidate.targets[0].id == "process" + ] + process_stores = [ + candidate + for candidate in ast.walk(function_node) + if isinstance(candidate, ast.Name) + and candidate.id == "process" + and isinstance(candidate.ctx, ast.Store) + and python_enclosing_scope(candidate, parents) is function_node + ] + process_creation = parents.get(node) + if ( + len(process_stores) != 5 + or len(process_assignments) != 5 + or any(parents.get(store) not in process_assignments for store in process_stores) + or sum(assignment.value is node for assignment in process_assignments) != 1 + or process_creation not in process_assignments + or any( + assignment is not process_creation + and not ( + isinstance(assignment.value, ast.Constant) + and assignment.value.value is None + ) + for assignment in process_assignments + ) + ): + return False capture_assignments = [ candidate for candidate in ast.walk(function_node) @@ -14079,6 +14826,29 @@ def reviewed_python_git_query_capture( or capture_assignment.value.keywords ): return False + if ( + sum( + assignment.lineno < process_creation.lineno + for assignment in process_assignments + if assignment is not process_creation + ) != 1 + or sum( + assignment.lineno > capture_assignment.lineno + for assignment in process_assignments + if assignment is not process_creation + ) != 3 + or any( + process_creation.lineno < assignment.lineno < capture_assignment.lineno + for assignment in process_assignments + if assignment is not process_creation + ) + or any( + isinstance(candidate, ast.ExceptHandler) + and candidate.name == "process" + for candidate in ast.walk(function_node) + ) + ): + return False def on_unconditional_path(statement): current = statement @@ -14103,7 +14873,9 @@ def reviewed_python_git_query_capture( current = parent return True - if not on_unconditional_path(capture_assignment): + if not on_unconditional_path(process_creation) or not on_unconditional_path(capture_assignment): + return False + if process_creation.lineno >= capture_assignment.lineno: return False output_stores = [ candidate @@ -15432,142 +16204,19 @@ def python_known_non_path_set_copy_receiver(node, tree, parents, active=None): ): return False - def preserves_parameter(value, function, parameter_name, seen=None): - if seen is None: - seen = set() - if id(value) in seen: - return False - seen = seen | {id(value)} - if isinstance(value, ast.Set): - return True - if isinstance(value, ast.Name): - return ( - value.id == parameter_name - and python_enclosing_scope(value, parents) is function - ) - if isinstance(value, ast.Call): - if python_unshadowed_builtin_call(value, {"set"}, tree, parents): - return True - return ( - isinstance(value.func, ast.Attribute) - and value.func.attr == "copy" - and preserves_parameter( - value.func.value, function, parameter_name, seen - ) - ) - if isinstance(value, ast.BinOp) and isinstance(value.op, ast.BitOr): - return ( - preserves_parameter(value.left, function, parameter_name, seen) - and isinstance(value.right, ast.Set) - ) - return False - - if not isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): - return bool(assignments) and all( - python_known_non_path_set_copy_receiver( - value, tree, parents, active - ) - for value in assignments - ) parameters = ( list(scope.args.posonlyargs) + list(scope.args.args) + list(scope.args.kwonlyargs) + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)) + else [] ) - parameter_index = next( - (index for index, parameter in enumerate(parameters) if parameter.arg == node.id), - None, - ) - if parameter_index is None: - return bool(assignments) and all( - python_known_non_path_set_copy_receiver( - value, tree, parents, active - ) - for value in assignments - ) - positional = list(scope.args.posonlyargs) + list(scope.args.args) - positional_default_start = len(positional) - len(scope.args.defaults) - default = None - if parameter_index < len(positional): - if parameter_index >= positional_default_start: - default = scope.args.defaults[parameter_index - positional_default_start] - else: - keyword_index = parameter_index - len(positional) - default = scope.args.kw_defaults[keyword_index] - default_none = isinstance(default, ast.Constant) and default.value is None - set_normalizer = default_none and any( - any( - isinstance(candidate, ast.Call) - and python_unshadowed_builtin_call(candidate, {"set"}, tree, parents) - for candidate in ast.walk(value) + if any(parameter.arg == node.id for parameter in parameters): + return False + return bool(assignments) and all( + python_known_non_path_set_copy_receiver( + value, tree, parents, active ) for value in assignments ) - callers = [ - candidate for candidate in ast.walk(tree) - if isinstance(candidate, ast.Call) - and python_dotted_name(candidate.func) == scope.name - and scope in python_local_function_candidates( - scope.name, candidate, tree, parents - ) - ] - if not all( - preserves_parameter(value, scope, node.id) - or python_known_non_path_set_copy_receiver(value, tree, parents, active) - or ( - set_normalizer - and isinstance(value, ast.Constant) - and value.value is None - ) - for value in assignments - ): - return False - if not callers: - return False - for candidate in ast.walk(tree): - if not ( - isinstance(candidate, ast.Name) - and candidate.id == scope.name - and isinstance(candidate.ctx, ast.Load) - ): - continue - parent = parents.get(candidate) - if not ( - isinstance(parent, ast.Call) - and parent.func is candidate - and parent in callers - ): - return False - seeded = False - for caller in callers: - keyword_values = [ - keyword.value for keyword in caller.keywords if keyword.arg == node.id - ] - if len(keyword_values) > 1: - return False - if keyword_values: - argument = keyword_values[0] - elif parameter_index < len(caller.args) and not any( - isinstance(value, ast.Starred) for value in caller.args - ): - argument = caller.args[parameter_index] - else: - argument = None - if argument is None or ( - isinstance(argument, ast.Constant) and argument.value is None - ): - if not set_normalizer: - return False - seeded = True - elif python_enclosing_scope(caller, parents) is scope and preserves_parameter( - argument, scope, node.id - ): - continue - elif python_known_non_path_set_copy_receiver( - argument, tree, parents, active - ): - seeded = True - else: - return False - return seeded def python_filesystem_mutator_alias_violation(tree, parents): @@ -35703,3 +36352,64 @@ Ran 1 test in 0.176s — OK This follow-up changes only the regression source and this ledger; scanner source and executable compiler recipes are unchanged. The witness remains an inert Python source string parsed by the harness. + +#### Bounded Git output provenance and unknown Path receiver correction + +The prior coordinator full harness result remains pinned to +dba804f69cafcf2a1f171ad72a3db98288b70ed8: 168 tests passed in 160.796s, +with 261 shell commands, 86 Python bodies, zero packet violations, 119 actual +compiler-helper calls and ten historical-loader controls. It predates this +correction and is not claimed for the current source. + +At the 628c3b4d1171fc63c2120e5bf78c9c4f0c891e90 source checkpoint, the +collector selector failed in 0.839s: return process.communicate() was +accepted. Its second fabricated-output variant was also accepted, but that +initial fixture contained the two literal bytes backslash and n instead of a +newline and is not counted as a faithful reproduction of the origin-comparison +P1. Before this batch, the coordinator independently confirmed against the +immutable dba804f Git objects that a true newline in +b"https://github.com/1XP-AI/gh-runnerd.git\n" was accepted alongside the +communicate variant. The final focused selector now uses that correct inert +newline-byte constant and refuses it. The seeded Path keyword-forwarding +witness in +test_new_path_mutators_require_owned_sources_and_destinations failed in +0.176s because copier(**{"seen": Path("synthetic-unowned-source")}) could +reach a .copy(Path("synthetic-unowned-destination")) call through an +optional parameter whose default branch creates a set. + +The collector proof now checks the finite numeric output and chunk caps, +explicit stdout/stderr pipes on the real Popen, empty bytearray buffers, +selector registration from the process streams, os.read(stream.fileno(), …) +chunk provenance, and the reachable cumulative stdout + stderr + chunk guard +that raises before extension. It then requires only those same buffers to be +converted and returned after the read loop and process wait. Exact local store +and call shapes reject added aliases, rebindings, and unreviewed consumers; +malformed budget operands, a deletion, and a returned-buffer alias are inert +AST regression controls. No source hash, compiler input, or helper allowlist +was added. The Path receiver proof no longer infers safety for an unknown +parameter from a visible caller or default assignment; literal/local set +controls remain in the focused method. + +Two intermediate combined selector attempts failed in 0.463s and 0.483s +because the new structural proof initially misread the nested budget +expression and omitted the canonical EOF chunk use. After correcting those +false negatives, a later five-selector attempt failed in 5.081s because the +runner definition itself was counted as a nested binding. That scope check was +narrowed to nested definitions, and the fabricated-output fixture was +corrected to use an actual newline byte. The final bounded verification passed: + + python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_git_query_collector_must_return_bounded_pipe_bytes \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance + Ran 5 tests in 7.367s — OK + packet compile-source boundary: 119 actual helper calls checked + +The final selection retained the real collector, active-result, bounded +origin-comparison, and owned Path controls while refusing both new inert +P1 witnesses and the malformed/alias mutations. Only the five listed focused +methods ran at this checkpoint; the full harness, packet-wide selector, Go +checks, hosted CI, and live operations were not run and are not claimed. +The coordinator owns final exact-head integration validation and review. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index d58a4991..93819341 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2807,6 +2807,139 @@ def test_git_query_capture_must_be_on_the_active_result_path(self) -> None: "a collector placed after the returned result must not certify its output", ) + def test_git_query_collector_must_return_bounded_pipe_bytes(self) -> None: + collector_body, marker = self.packet_heredoc_containing( + "def capture_git_query_output(process, git_command, output_limit, input_bytes=None):", + 'return bytes(captures["stdout"]), bytes(captures["stderr"])', + "def run_bounded_git_query(", + ) + inspect = self.scanner["inspect_python_heredoc"] + self.assertIsNone( + inspect(collector_body, marker), # type: ignore[operator] + "the canonical bounded pipe collector remains accepted", + ) + mutations = ( + ( + "process.communicate() output", + [ + ast.Return( + value=ast.Call( + func=ast.Attribute( + value=ast.Name(id="process", ctx=ast.Load()), + attr="communicate", + ctx=ast.Load(), + ), + args=[], + keywords=[], + ) + ) + ], + ), + ( + "fabricated origin bytes after wait", + [ + ast.Expr( + value=ast.Call( + func=ast.Attribute( + value=ast.Name(id="process", ctx=ast.Load()), + attr="wait", + ctx=ast.Load(), + ), + args=[], + keywords=[], + ) + ), + ast.Return( + value=ast.Tuple( + elts=[ + ast.Constant( + value=b"https://github.com/1XP-AI/gh-runnerd.git\n" + ), + ast.Constant(value=b""), + ], + ctx=ast.Load(), + ) + ), + ], + ), + ) + for label, replacement in mutations: + with self.subTest(mutant=label): + tree = ast.parse(collector_body, filename="") + collector = _verification_function(tree, "capture_git_query_output") + collector.body = replacement + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + label, + ) + for label, change in ( + ( + "empty budget len operand", + lambda guard: guard.test.left.left.left.args.clear(), + ), + ( + "non-call budget operand", + lambda guard: setattr( + guard.test.left.left, + "left", + ast.Constant(value=0), + ), + ), + ): + with self.subTest(mutant=label): + tree = ast.parse(collector_body, filename="") + collector = _verification_function(tree, "capture_git_query_output") + guard = next( + node + for node in ast.walk(collector) + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.comparators[0], ast.Name) + and node.test.comparators[0].id == "output_limit" + ) + change(guard) + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + label, + ) + tree = ast.parse(collector_body, filename="") + collector = _verification_function(tree, "capture_git_query_output") + collector.body.insert( + 0, + ast.Delete( + targets=[ast.Name(id="captures", ctx=ast.Del())], + ), + ) + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + "an unexpected local deletion shape must be rejected without crashing", + ) + tree = ast.parse(collector_body, filename="") + collector = _verification_function(tree, "capture_git_query_output") + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + returned = next(node for node in ast.walk(collector) if isinstance(node, ast.Return)) + returned.value.elts[0].args[0].value.id = "capture_alias" + return_parent = parents[returned] + return_parent.body.insert( + return_parent.body.index(returned), + ast.Assign( + targets=[ast.Name(id="capture_alias", ctx=ast.Store())], + value=ast.Name(id="captures", ctx=ast.Load()), + ), + ) + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + "an output-buffer alias must not inherit the reviewed capture proof", + ) + def test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape(self) -> None: verification_body, verification_marker = self.packet_heredoc_containing( @@ -3965,6 +4098,12 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'seen, other = (Path("synthetic-unowned-source"), None)\n' 'seen.copy(Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\n' + 'def copier(seen=None):\n' + ' if seen is None:\n' + ' seen = set()\n' + ' seen.copy(Path("synthetic-unowned-destination"))\n' + 'copier(**{"seen": Path("synthetic-unowned-source")})\n', + 'from pathlib import Path\n' 'seen = set()\n' 'def retarget():\n' ' global seen\n' From 034c131329a00f315b81bd11956e1fd0744037ee Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 01:22:46 +0900 Subject: [PATCH 064/123] Close Git collector control-flow escapes --- docs/evidence/g01-recovery-packet.md | 134 ++++++++++++++++++ .../issue79_regression_test.py | 78 ++++++++++ 2 files changed, 212 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 3d788662..ee090158 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14236,6 +14236,11 @@ def reviewed_python_git_query_collector(tree, parents): for node in ast.walk(function) if python_enclosing_scope(node, parents) is function ] + if any( + isinstance(node, (ast.Break, ast.Yield, ast.YieldFrom)) + for node in local_nodes + ): + return False local_calls = [node for node in local_nodes if isinstance(node, ast.Call)] if any( isinstance(node, (ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Match)) @@ -14729,10 +14734,86 @@ def reviewed_python_git_query_collector(tree, parents): or not any(parents.get(node) is extension for node in chunk_loads) ): return False + continue_nodes = [node for node in local_nodes if isinstance(node, ast.Continue)] + blocking_handlers = [ + node for node in local_nodes + if isinstance(node, ast.ExceptHandler) + and isinstance(node.type, ast.Name) + and node.type.id == "BlockingIOError" + ] + empty_chunk_ifs = [ + node for node in local_nodes + if isinstance(node, ast.If) + and isinstance(node.test, ast.UnaryOp) + and isinstance(node.test.op, ast.Not) + and is_name(node.test.operand, "chunk") + and parents.get(node) is ready_loop + ] + stdin_branches = [ + node for node in local_nodes + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and python_dotted_name(node.test.left) == "key.data" + and len(node.test.ops) == 1 + and isinstance(node.test.ops[0], ast.Eq) + and len(node.test.comparators) == 1 + and isinstance(node.test.comparators[0], ast.Constant) + and node.test.comparators[0].value == "stdin" + and parents.get(node) is ready_loop + ] + if ( + len(continue_nodes) != 4 + or len(blocking_handlers) != 2 + or any( + len(handler.body) != 1 + or not isinstance(handler.body[0], ast.Continue) + or ready_loop not in _python_parent_chain(handler, parents) + for handler in blocking_handlers + ) + or len(empty_chunk_ifs) != 1 + or empty_chunk_ifs[0].orelse + or not empty_chunk_ifs[0].body + or not isinstance(empty_chunk_ifs[0].body[-1], ast.Continue) + or len(stdin_branches) != 1 + or stdin_branches[0].orelse + or not stdin_branches[0].body + or not isinstance(stdin_branches[0].body[-1], ast.Continue) + or set(continue_nodes) + != {handler.body[0] for handler in blocking_handlers} + | {empty_chunk_ifs[0].body[-1]} + | {stdin_branches[0].body[-1]} + ): + return False + write_handlers = [ + handler for handler in blocking_handlers + if handler not in read_try.handlers + ] + write_tries = [parents.get(handler) for handler in write_handlers] + write_calls = [ + node for node in local_calls + if python_dotted_name(node.func) == "os.write" + ] + if ( + len(write_handlers) != 1 + or len(write_tries) != 1 + or not isinstance(write_tries[0], ast.Try) + or len(stdin_branches) != 1 + or parents.get(write_tries[0]) is not stdin_branches[0] + or write_tries[0] not in stdin_branches[0].body + or len(write_calls) != 1 + or write_tries[0] not in _python_parent_chain(write_calls[0], parents) + ): + return False bytes_calls = [node for node in local_calls if is_name(node.func, "bytes")] if len(bytes_calls) != 2 or bytearrays != [value for value in captures.values if isinstance(value, ast.Call)]: return False + loop_nodes = [ + node for node in local_nodes + if isinstance(node, (ast.For, ast.AsyncFor, ast.While, ast.comprehension)) + ] + if len(loop_nodes) != 3 or set(loop_nodes) != {while_node, ready_loop, output_loop}: + return False return True @@ -36413,3 +36494,56 @@ P1 witnesses and the malformed/alias mutations. Only the five listed focused methods ran at this checkpoint; the full harness, packet-wide selector, Go checks, hosted CI, and live operations were not run and are not claimed. The coordinator owns final exact-head integration validation and review. + +#### Collector control-flow closure after the `062fade5` review + +The pinned `062fade5d0b3f5067a9620b9cd91c6ff9de4abb0` checkpoint had a +coordinator-reported 169-test full-suite pass in 166.716s, 119 actual +compiler-helper checks, and a packet scan of 261 shell commands and 86 Python +bodies with zero violations. The independent contract and security reviews +still held that checkpoint on collector control-flow mutations; the historical +pass does not resolve those findings or certify this correction. + +The first focused run with new inert AST cases failed in 3.079s because the +scanner accepted a conditional `break` for `status`, an early `continue`, and +an additional nested loop. Its yield subcase also failed, but that fixture +extended a temporary slice and did not mutate the AST, so it is not counted +as a valid reproduction. A corrected fixture inserting two +`ast.Yield` nodes was checked against the scanner loaded from the immutable +`062fade5` packet blob; the old scanner accepted the fabricated origin bytes. +The current regression now inserts those actual nodes and requires refusal. + +The collector proof rejects any `break`, `yield` or `yield from`, and requires +exactly the existing three loops: output stream registration, selector drain, +and ready-event handling. It allows only the four canonical continues: the two +`BlockingIOError` handlers, the empty-read EOF branch, and the stdin-write +branch. The stdin handler is tied to its `os.write` try block. This closes an +early status break before pipe draining and the fabricated two-yield result +without a status-string exception or new source, compiler-input, or helper +allowlist. + +An intermediate focused attempt failed in 0.440s because its first proof +version counted only three continues and refused the canonical stdin-write +continue. After correcting the shape to allow exactly four, the fixture repair +then exposed the unmodified yield case; the corrected collector selector +passed in 3.107s. The first four-selector bounded verification passed in +8.330s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_git_query_collector_must_return_bounded_pipe_bytes \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance +Ran 4 tests in 8.330s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +After the final check also required no alternate stdin branch and counted +comprehensions among loops, the same four selectors passed again in 8.301s, +with 119 actual compiler-helper calls checked. + +The probes inspected source and AST only; no verifier recipe, Git query, or +adversarial specimen was run. This correction did not rerun the full harness, +packet-wide selector, Go checks, hosted CI, or live operations. The coordinator +owns the next exact-head integration scan and independent reviews. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 93819341..a8835a76 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2904,6 +2904,84 @@ def test_git_query_collector_must_return_bounded_pipe_bytes(self) -> None: inspect(ast.unparse(tree), marker), # type: ignore[operator] label, ) + + def add_fabricated_yields( + collector: ast.FunctionDef, _drain: ast.While + ) -> None: + collector.body[0:0] = [ + ast.Expr( + value=ast.Yield( + value=ast.Constant( + value=b"https://github.com/1XP-AI/gh-runnerd.git\n" + ) + ) + ), + ast.Expr(value=ast.Yield(value=ast.Constant(value=b""))), + ] + + control_flow_mutations = ( + ( + "conditional status break before draining output", + lambda collector, drain: drain.body.insert( + 0, + ast.If( + test=ast.Compare( + left=ast.Constant(value="status"), + ops=[ast.In()], + comparators=[ast.Name(id="git_command", ctx=ast.Load())], + ), + body=[ast.Break()], + orelse=[], + ), + ), + ), + ( + "early continue before deadline and output drain", + lambda collector, drain: drain.body.insert( + 0, + ast.If( + test=ast.Compare( + left=ast.Name(id="output_limit", ctx=ast.Load()), + ops=[ast.Gt()], + comparators=[ast.Constant(value=0)], + ), + body=[ast.Continue()], + orelse=[], + ), + ), + ), + ( + "additional nested loop", + lambda collector, drain: drain.body.insert( + 0, + ast.While( + test=ast.Constant(value=True), + body=[ast.Pass()], + orelse=[], + ), + ), + ), + ( + "fabricated origin bytes yielded before collection", + add_fabricated_yields, + ), + ) + for label, mutate in control_flow_mutations: + with self.subTest(mutant=label): + tree = ast.parse(collector_body, filename="") + collector = _verification_function(tree, "capture_git_query_output") + drain = next( + node + for node in ast.walk(collector) + if isinstance(node, ast.While) + and ast.unparse(node.test) == "selector.get_map()" + ) + mutate(collector, drain) + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + label, + ) tree = ast.parse(collector_body, filename="") collector = _verification_function(tree, "capture_git_query_output") collector.body.insert( From 82fe7ba3619595f7cd92a337bacf12c8f81877b6 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 10:05:52 +0900 Subject: [PATCH 065/123] Harden Issue 79 evidence scanner provenance --- docs/evidence/g01-recovery-packet.md | 294 ++++++++++++++++++ .../issue79_regression_test.py | 261 ++++++++++++++++ 2 files changed, 555 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index ee090158..e48f643b 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14207,6 +14207,8 @@ def reviewed_python_git_query_collector(tree, parents): ] if len(collectors) != 1 or len(runners) != 1: return False + if not reviewed_python_git_query_runtime_dependencies_stable(tree, parents): + return False function = collectors[0] runner = runners[0] if not reviewed_python_git_query_output_budget(tree, parents, runner): @@ -15590,6 +15592,200 @@ def python_assigned_module_names(tree, module): return names +def reviewed_python_git_query_runtime_dependencies_stable(tree, parents): + """Keep the bounded Git capture's imported providers on stable bindings.""" + exported_members = { + "os": {"environ", "killpg", "read", "set_blocking", "write"}, + "selectors": {"DefaultSelector", "EVENT_READ", "EVENT_WRITE"}, + "signal": {"SIGKILL", "SIGTERM"}, + "subprocess": { + "CompletedProcess", "DEVNULL", "PIPE", "Popen", "TimeoutExpired" + }, + "time": {"monotonic", "sleep"}, + } + import_counts = {module: 0 for module in exported_members} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in exported_members: + if alias.asname is not None or parents.get(node) is not tree: + return False + import_counts[alias.name] += 1 + if alias.name == "builtins": + return False + elif isinstance(node, ast.ImportFrom): + if node.module in exported_members or node.module == "builtins": + return False + if any(count != 1 for count in import_counts.values()): + return False + + provider_names = set(exported_members) + for node in ast.walk(tree): + if isinstance(node, ast.Name) and node.id in provider_names: + if isinstance(node.ctx, (ast.Store, ast.Del)): + return False + if isinstance(node.ctx, ast.Load): + parent = parents.get(node) + if not isinstance(parent, ast.Attribute) or parent.value is not node: + return False + elif isinstance(node, ast.arg) and node.arg in provider_names: + return False + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name in provider_names: + return False + elif isinstance(node, ast.ExceptHandler) and node.name in provider_names: + return False + elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name in provider_names: + return False + elif isinstance(node, ast.MatchMapping) and node.rest in provider_names: + return False + elif isinstance(node, ast.Name) and node.id == "__builtins__": + return False + + callable_members = set() + references = [] + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in exported_members + ): + continue + member = (node.value.id, node.attr) + if node.attr not in exported_members[node.value.id]: + return False + if isinstance(node.ctx, (ast.Store, ast.Del)): + return False + parent = parents.get(node) + if isinstance(parent, ast.Call) and parent.func is node: + callable_members.add(member) + elif isinstance(parent, ast.ExceptHandler) and parent.type is node: + callable_members.add(member) + references.append((node, member)) + + def passive_value_reference(node): + parent = parents.get(node) + if isinstance(parent, ast.Call) and node in parent.args: + return True + if isinstance(parent, ast.keyword) and parent.value is node: + return True + if isinstance(parent, ast.IfExp) and node in {parent.body, parent.orelse}: + keyword = parents.get(parent) + return isinstance(keyword, ast.keyword) and keyword.value is parent + if node.attr == "environ" and node.value.id == "os": + if isinstance(parent, ast.Compare): + return node in parent.comparators or parent.left is node + if isinstance(parent, ast.Subscript): + return parent.value is node and isinstance(parent.ctx, ast.Load) + if isinstance(parent, ast.comprehension): + return parent.iter is node + return False + + for node, member in references: + if member in callable_members: + parent = parents.get(node) + if not ( + isinstance(parent, ast.Call) and parent.func is node + ) and not ( + isinstance(parent, ast.ExceptHandler) and parent.type is node + ): + return False + elif not passive_value_reference(node): + return False + + builtin_names = { + "BaseException", "BlockingIOError", "BrokenPipeError", "OSError", + "ProcessLookupError", "SystemExit", "bytearray", "bytes", "getattr", + "len", "max", "memoryview", "min", + } + for node in ast.walk(tree): + if ( + isinstance(node, ast.Name) + and node.id in builtin_names + and isinstance(node.ctx, ast.Load) + and not python_unshadowed_builtin_reference( + node, {node.id}, tree, parents + ) + ): + return False + return True + + +def python_working_directory_change_violation(tree, parents): + """Reject cwd-changing APIs through module, imported, or container aliases.""" + sensitive_modules = { + "contextlib": python_assigned_module_names(tree, "contextlib"), + "os": python_assigned_module_names(tree, "os"), + } + cwd_methods = {"chdir", "fchdir"} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module in sensitive_modules: + if any(alias.name == "*" or alias.name in cwd_methods for alias in node.names): + return "Python heredoc imports an unreviewed working-directory change API" + if ( + isinstance(node, ast.Name) + and isinstance(node.ctx, ast.Load) + and any(node.id in names for names in sensitive_modules.values()) + ): + parent = parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue + if ( + isinstance(parent, ast.Assign) + and parent.value is node + and all(isinstance(target, ast.Name) for target in parent.targets) + ): + continue + return "Python heredoc lets a working-directory module escape its reviewed attribute path" + if isinstance(node, ast.Attribute) and node.attr in cwd_methods: + if ( + isinstance(node.value, ast.Name) + and any(node.value.id in names for names in sensitive_modules.values()) + ): + return "Python heredoc accesses an unreviewed working-directory change API" + if not isinstance(node, ast.Call): + continue + if 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) + ): + module_argument = node.args[0] if node.args else None + if isinstance(module_argument, ast.Name) and any( + module_argument.id in names for names in sensitive_modules.values() + ): + attributes = ( + python_static_string_values(node.args[1], tree) + if node.func.id != "vars" and len(node.args) > 1 + else set() + ) + if not attributes or any(attribute in cwd_methods for attribute in attributes): + return "Python heredoc reflects over working-directory module APIs" + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "__getattribute__" + ): + if ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in {"object", "type"} + and node.args + ): + module_argument = node.args[0] + attribute_argument = node.args[1] if len(node.args) > 1 else None + else: + module_argument = node.func.value + attribute_argument = node.args[0] if node.args else None + if isinstance(module_argument, ast.Name) and any( + module_argument.id in names for names in sensitive_modules.values() + ): + attributes = ( + python_static_string_values(attribute_argument, tree) + if attribute_argument is not None else set() + ) + if not attributes or any(attribute in cwd_methods for attribute in attributes): + return "Python heredoc reflects over working-directory module APIs" + return None + + 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") @@ -16331,6 +16527,33 @@ def python_filesystem_mutator_alias_violation(tree, parents): for alias in node.names ): return "Python heredoc imports an unreviewed filesystem mutator" + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Attribute) + and node.attr in python_filesystem_mutating_methods + ): + continue + parent = parents.get(node) + if isinstance(parent, ast.Call) and parent.func is node: + continue + if ( + isinstance(parent, ast.Assign) + and parent.value is node + and all(isinstance(target, ast.Name) for target in parent.targets) + ) or ( + isinstance(parent, (ast.AnnAssign, ast.NamedExpr)) + and parent.value is node + and isinstance(parent.target, ast.Name) + ): + continue + if node.attr == "copy" and python_known_non_path_set_copy_receiver( + node.value, tree, parents + ): + continue + return ( + "Python heredoc stores or passes a filesystem mutator in an " + f"unreviewed holder on line {node.lineno}" + ) for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) @@ -17800,6 +18023,8 @@ python_sensitive_sink_methods = { "info", "debug", "log", + "displayhook", + "excepthook", } @@ -20277,6 +20502,11 @@ def inspect_python_heredoc(body, safe_marker): for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } + working_directory_violation = python_working_directory_change_violation( + tree, parents + ) + if working_directory_violation: + return working_directory_violation git_origin_capture_violation = python_git_origin_capture_violation(tree, parents) if git_origin_capture_violation: return git_origin_capture_violation @@ -36547,3 +36777,67 @@ The probes inspected source and AST only; no verifier recipe, Git query, or adversarial specimen was run. This correction did not rerun the full harness, packet-wide selector, Go checks, hosted CI, or live operations. The coordinator owns the next exact-head integration scan and independent reviews. + +#### Four exact-head Codex P1 corrections from `034c1313` + +At immutable base +`034c131329a00f315b81bd11956e1fd0744037ee`, the four reported AST witnesses +were accepted by the packet and harness scanners: + +- [Stored exception hook](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622): a `sys.excepthook` bound method stored in a list, tuple, or dictionary could later receive `RuntimeError(os.environ)`. The scanner now classifies `excepthook` and `displayhook` as sensitive output methods; the controls retain direct offline callbacks with literal data. +- [Stored Path mutator](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966624): bound `copy`, `copy_into`, `move_into`, and related mutators could escape through a container before source and destination ownership was checked. Stored methods in unreviewed containers are refused; direct calls and simple aliases still use the existing owned-source and owned-destination proof, and a proven ordinary set `.copy()` remains accepted. +- [Patched Git capture providers](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966626): saving, replacing, and restoring `selectors.DefaultSelector` and `os.read` around the real query let a synthetic reader supply the expected newline-terminated origin bytes. The collector now requires its standard-library providers to be imported once under their canonical bindings, rejects module/callable escapes and provider writes, and proves the used selector, OS, signal, subprocess, and time attributes remain stable. Relevant byte and guard builtins must also remain unshadowed; inert controls cover direct patching, module aliases, and a shadowed `SystemExit`. +- [Changed working directory](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966627): `contextlib.chdir` and imported `os.chdir` forms could move a later repository-relative read outside the reviewed root. The scanner now refuses `chdir`/`fchdir` imports and module attributes, including name and container aliases and dynamic module reflection; the `contextlib.nullcontext()` control remains accepted. + +Before scanner changes, the four focused regression methods failed in 0.820s +with 15 assertions showing the unsafe forms were accepted. After adding +inert module-alias and builtin-shadow variants to the provider test, its +standalone rerun against the unchanged scanner failed in 0.762s on the +saved-and-restored provider mutation. The fake selector and reader were +inserted into the verifier as AST data; the newline byte was asserted from +the literal value. No fixture or verifier specimen was executed. + +The focused correction selectors passed: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture \ + Issue79RegressionTests.test_contextlib_chdir_is_rejected_through_aliases_and_containers \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path \ + Issue79RegressionTests.test_git_query_collector_must_return_bounded_pipe_bytes \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape +Ran 7 tests in 5.849s — OK + +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance +Ran 1 test in 4.122s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +These focused runs retain the direct and aliased hook, filesystem ownership, +collector active-result, bounded-byte and origin-comparison controls, plus +the actual 119 helper calls. The full harness, packet-wide selector, Go or +hosted CI, and live operations were not run in this correction; the +coordinator owns the final exact-head integration scan and independent +reviews. + +The provider regression was extended with a `globals()` lookup mutation and +still passed: the existing module-reflection guard refuses that route before +it can replace the imported providers. After that addition and the final cwd +module-escape tightening, the exact final focused selection passed: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture \ + Issue79RegressionTests.test_contextlib_chdir_is_rejected_through_aliases_and_containers \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path \ + Issue79RegressionTests.test_git_query_collector_must_return_bounded_pipe_bytes \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance +Ran 8 tests in 9.892s — OK +packet compile-source boundary: 119 actual helper calls checked +``` diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index a8835a76..0436dcbb 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3076,6 +3076,212 @@ def test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_e label, ) + def test_git_query_runtime_dependencies_cannot_be_patched_around_capture(self) -> None: + verification_body, marker = self.packet_heredoc_containing( + "expected_origin_url =", + "origin_result = run_bounded_git_query(", + "if origin_urls != [expected_origin_url]:", + ) + inspect_verification = self.scanner["inspect_python_heredoc"] + self.assertIsNone( + inspect_verification(verification_body, marker), # type: ignore[operator] + "the original imported collector dependencies remain accepted", + ) + origin_bytes = b"https://github.com/1XP-AI/gh-runnerd.git\n" + self.assertTrue(origin_bytes.endswith(b"\n")) + synthetic_code = ''' +class SyntheticSelectorKey: + def __init__(self, stream, data): + self.fileobj = stream + self.data = data + +class SyntheticSelector: + def __init__(self): + self.entries = [] + def register(self, stream, _event, data): + self.entries.append(SyntheticSelectorKey(stream, data)) + def get_map(self): + return self.entries + def select(self, _timeout): + return [(self.entries[0], 1)] if self.entries else [] + def unregister(self, stream): + self.entries = [entry for entry in self.entries if entry.fileobj is not stream] + def close(self): + self.entries = [] + +synthetic_origin = __ORIGIN_BYTES__ +synthetic_chunks = iter((synthetic_origin, b"")) +def synthetic_read(_fd, _size): + return next(synthetic_chunks, b"") +''' + synthetic_tree = ast.parse(synthetic_code, filename="") + placeholders = [ + node for node in ast.walk(synthetic_tree) + if isinstance(node, ast.Name) + and node.id == "__ORIGIN_BYTES__" + and isinstance(node.ctx, ast.Load) + ] + self.assertEqual(1, len(placeholders)) + synthetic_origin_assignment = next( + node for node in synthetic_tree.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "synthetic_origin" + for target in node.targets + ) + ) + synthetic_origin_assignment.value = ast.Constant(value=origin_bytes) + self.assertEqual( + origin_bytes, + synthetic_origin_assignment.value.value, + "the inert replacement reader must yield the actual newline-terminated origin bytes", + ) + tree = ast.parse(verification_body, filename="") + origin_query = next( + statement for statement in tree.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + and isinstance(statement.value, ast.Call) + and ast.unparse(statement.value.func) == "run_bounded_git_query" + ) + comparison = next( + node for node in ast.walk(tree) + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and any( + isinstance(part, ast.Name) and part.id == "origin_urls" + for part in ast.walk(node.test) + ) + ) + query_index = tree.body.index(origin_query) + tree.body[query_index:query_index] = synthetic_tree.body + query_index = tree.body.index(origin_query) + patch = ast.parse( + "saved_default_selector = selectors.DefaultSelector\n" + "saved_os_read = os.read\n" + "selectors.DefaultSelector = SyntheticSelector\n" + "os.read = synthetic_read\n" + ).body + tree.body[query_index:query_index] = patch + comparison_index = tree.body.index(comparison) + restore = ast.parse( + "selectors.DefaultSelector = saved_default_selector\n" + "os.read = saved_os_read\n" + ).body + tree.body[comparison_index + 1:comparison_index + 1] = restore + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect_verification(ast.unparse(tree), marker), # type: ignore[operator] + "saved, patched, and restored selector/read callables must not certify Git output", + ) + + aliased = ast.parse(verification_body, filename="") + alias_query = next( + statement for statement in aliased.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + ) + alias_comparison = next( + node for node in ast.walk(aliased) + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and any( + isinstance(part, ast.Name) and part.id == "origin_urls" + for part in ast.walk(node.test) + ) + ) + alias_query_index = aliased.body.index(alias_query) + aliased.body[alias_query_index:alias_query_index] = ast.parse( + "selector_provider = selectors\n" + "reader_provider = os\n" + "process_provider = subprocess\n" + "saved_selector = selector_provider.DefaultSelector\n" + "saved_reader = reader_provider.read\n" + "saved_popen = process_provider.Popen\n" + "selector_provider.DefaultSelector = SyntheticSelector\n" + "reader_provider.read = synthetic_read\n" + "process_provider.Popen = replacement_popen\n" + ).body + alias_comparison_index = aliased.body.index(alias_comparison) + aliased.body[alias_comparison_index + 1:alias_comparison_index + 1] = ast.parse( + "selector_provider.DefaultSelector = saved_selector\n" + "reader_provider.read = saved_reader\n" + "process_provider.Popen = saved_popen\n" + ).body + ast.fix_missing_locations(aliased) + self.assertIsNotNone( + inspect_verification(ast.unparse(aliased), marker), # type: ignore[operator] + "module aliases and saved launcher callables must not certify Git output", + ) + + reflected = ast.parse( + verification_body, filename="" + ) + reflected_query = next( + statement for statement in reflected.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + ) + reflected_comparison = next( + node for node in ast.walk(reflected) + if isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and any( + isinstance(part, ast.Name) and part.id == "origin_urls" + for part in ast.walk(node.test) + ) + ) + reflected_index = reflected.body.index(reflected_query) + reflected.body[reflected_index:reflected_index] = ast.parse( + "provider_namespace = globals()\n" + "selector_provider = provider_namespace['selectors']\n" + "reader_provider = provider_namespace['os']\n" + "saved_selector = selector_provider.DefaultSelector\n" + "saved_reader = reader_provider.read\n" + "selector_provider.DefaultSelector = SyntheticSelector\n" + "reader_provider.read = synthetic_read\n" + ).body + reflected_comparison_index = reflected.body.index(reflected_comparison) + reflected.body[reflected_comparison_index + 1:reflected_comparison_index + 1] = ast.parse( + "selector_provider.DefaultSelector = saved_selector\n" + "reader_provider.read = saved_reader\n" + ).body + ast.fix_missing_locations(reflected) + self.assertIsNotNone( + inspect_verification(ast.unparse(reflected), marker), # type: ignore[operator] + "global provider lookup must not bypass stable imported-module bindings", + ) + + shadowed_builtin = ast.parse( + verification_body, filename="" + ) + builtin_query = next( + statement for statement in shadowed_builtin.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + ) + shadowed_builtin.body.insert( + shadowed_builtin.body.index(builtin_query), + ast.parse("SystemExit = RuntimeError").body[0], + ) + ast.fix_missing_locations(shadowed_builtin) + self.assertIsNotNone( + inspect_verification(ast.unparse(shadowed_builtin), marker), # type: ignore[operator] + "rebinding the budget guard's builtin must not certify Git output", + ) + @staticmethod def _verification_without_origin_comparison(body: str) -> str: mutated = ast.parse(body, filename="") @@ -3815,6 +4021,40 @@ def test_current_directory_path_aliases_are_not_disclosed(self) -> None: with self.subTest(body=body): self.assertIsNone(self.inspect(body)) + def test_contextlib_chdir_is_rejected_through_aliases_and_containers(self) -> None: + unsafe = ( + 'import contextlib\n' + 'with contextlib.chdir("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', + 'from contextlib import chdir as switch_root\n' + 'with switch_root("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', + 'import contextlib as cwd_tools\n' + 'switch_root = cwd_tools.chdir\n' + 'with switch_root("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', + 'import contextlib as cwd_tools\n' + 'actions = {"root": cwd_tools.chdir}\n' + 'with actions["root"]("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', + 'import contextlib as cwd_tools\n' + 'actions = [cwd_tools.chdir]\nalias = actions\n' + 'with alias[0]("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', + 'import contextlib\nmodules = {"context": contextlib}\n' + 'with modules["context"].chdir("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', + 'from os import chdir as switch_root\n' + 'with switch_root("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', + ) + for body in unsafe: + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import contextlib\nwith contextlib.nullcontext():\n pass\n' + )) + def test_path_method_alias_chains_respect_lexical_shadowing(self) -> None: unsafe = ( 'from pathlib import Path\n' @@ -4124,6 +4364,14 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'getattr(sys, "excepthook")(RuntimeError, RuntimeError(os.environ), None)\n', 'import os, sys\nruntime = sys\n' 'runtime.excepthook(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nhooks = [sys.excepthook]\n' + 'hooks[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nhooks = (sys.excepthook,)\n' + 'hooks[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nhooks = {"error": sys.excepthook}\n' + 'hooks["error"](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nhooks = [sys.excepthook]\nhandlers = hooks\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', ) for body in unsafe: with self.subTest(body=body): @@ -4135,6 +4383,10 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'from sys import excepthook as emit\n' 'emit(RuntimeError, RuntimeError("offline fixture"), None)\n' )) + self.assertIsNone(self.inspect( + 'def callback(*args):\n return None\n' + 'callbacks = [callback]\ncallbacks[0]("offline fixture")\n' + )) def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: unsafe = ( @@ -4147,6 +4399,15 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'from pathlib import Path\n' 'action = Path("synthetic-owned").move_into\n' 'action(Path("synthetic-destination"))\n', + 'from pathlib import Path\n' + 'actions = {"move": Path("synthetic-unowned-source").move_into}\n' + 'actions["move"](Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\n' + 'actions = (Path("synthetic-unowned-source").move_into,)\n' + 'actions[0](Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\n' + 'actions = [Path("synthetic-unowned-source").move_into]\n' + 'alias = actions\nalias[0](Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(Path("synthetic-destination"))\n', From 6574b875297a70aa1a644415433c7d797e726c97 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 10:30:32 +0900 Subject: [PATCH 066/123] Harden issue 79 evidence AST boundaries --- docs/evidence/g01-recovery-packet.md | 147 +++++++++++++++++- .../issue79_regression_test.py | 130 ++++++++++++++++ 2 files changed, 274 insertions(+), 3 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index e48f643b..9d19f536 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14879,6 +14879,49 @@ def reviewed_python_git_query_capture( ) ): return False + process_reads = [ + candidate + for candidate in ast.walk(function_node) + if isinstance(candidate, ast.Name) + and candidate.id == "process" + and isinstance(candidate.ctx, ast.Load) + and python_enclosing_scope(candidate, parents) is function_node + ] + process_read_kinds = {"capture": 0, "terminate": 0, "returncode": 0, "cleanup": 0} + for reference in process_reads: + parent = parents.get(reference) + if isinstance(parent, ast.Attribute) and parent.value is reference: + if parent.attr != "returncode" or not isinstance(parent.ctx, ast.Load): + return False + process_read_kinds["returncode"] += 1 + continue + if isinstance(parent, ast.Call) and parent.args and parent.args[0] is reference: + if parent.keywords: + return False + callee = python_dotted_name(parent.func) + if callee == "capture_git_query_output": + process_read_kinds["capture"] += 1 + elif callee == "terminate_git_query_group": + process_read_kinds["terminate"] += 1 + else: + return False + continue + if ( + isinstance(parent, ast.Compare) + and parent.left is reference + and len(parent.ops) == 1 + and isinstance(parent.ops[0], (ast.Is, ast.IsNot)) + and len(parent.comparators) == 1 + and isinstance(parent.comparators[0], ast.Constant) + and parent.comparators[0].value is None + ): + process_read_kinds["cleanup"] += 1 + continue + return False + if process_read_kinds != { + "capture": 1, "terminate": 4, "returncode": 1, "cleanup": 1 + }: + return False capture_assignments = [ candidate for candidate in ast.walk(function_node) @@ -15712,6 +15755,9 @@ def reviewed_python_git_query_runtime_dependencies_stable(tree, parents): def python_working_directory_change_violation(tree, parents): """Reject cwd-changing APIs through module, imported, or container aliases.""" + reviewed_module_storage = getattr( + tree, "_issue79_reviewed_module_value_storage", set() + ) sensitive_modules = { "contextlib": python_assigned_module_names(tree, "contextlib"), "os": python_assigned_module_names(tree, "os"), @@ -15726,6 +15772,8 @@ def python_working_directory_change_violation(tree, parents): and isinstance(node.ctx, ast.Load) and any(node.id in names for names in sensitive_modules.values()) ): + if id(node) in reviewed_module_storage: + continue parent = parents.get(node) if isinstance(parent, ast.Attribute) and parent.value is node: continue @@ -15999,6 +16047,7 @@ def python_module_value_escape_violation(tree, parents, safe_marker): for value in dictionary.values if isinstance(value, ast.Name) and value.id in names } + tree._issue79_reviewed_module_value_storage = safe_storage for node in ast.walk(tree): if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in names: if node.attr.startswith("_"): @@ -18050,6 +18099,24 @@ def python_sensitive_sink_storage_violation(tree, parents): ): continue return f"Python heredoc stores an output sink method in an unreviewed holder on line {node.lineno}" + for node in ast.walk(tree): + if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load): + continue + probe = ast.Call(func=node, args=[], keywords=[]) + if not python_sensitive_output_sink(probe, tree): + continue + parent = parents.get(node) + if isinstance(parent, ast.Call) and parent.func is node: + continue + if isinstance(parent, ast.Assign) and parent.value is node and len( + parent.targets + ) == 1 and isinstance(parent.targets[0], ast.Name): + continue + 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 imported output sink in an unreviewed holder on line {node.lineno}" return None @@ -20502,6 +20569,9 @@ def inspect_python_heredoc(body, safe_marker): for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } + module_escape_violation = python_module_value_escape_violation(tree, parents, safe_marker) + if module_escape_violation: + return module_escape_violation working_directory_violation = python_working_directory_change_violation( tree, parents ) @@ -20510,9 +20580,6 @@ def inspect_python_heredoc(body, safe_marker): git_origin_capture_violation = python_git_origin_capture_violation(tree, parents) if git_origin_capture_violation: return git_origin_capture_violation - module_escape_violation = python_module_value_escape_violation(tree, parents, safe_marker) - if module_escape_violation: - return module_escape_violation sink_storage_violation = python_sensitive_sink_storage_violation(tree, parents) if sink_storage_violation: return sink_storage_violation @@ -36841,3 +36908,77 @@ python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ Ran 8 tests in 9.892s — OK packet compile-source boundary: 119 actual helper calls checked ``` + +#### Three follow-up corrections from immutable `82fe7ba3` + +At immutable parent `82fe7ba3619595f7cd92a337bacf12c8f81877b6`, the +coordinator and independent contract review confirmed that a bounded Git +query could lose its actual `Popen` pipe before capture, and that an imported +`sys.excepthook` callable could leave the reviewed call path through a +container. The exact exception-hook form is also recorded in +[Codex finding 4173966622](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622). +The coordinator and independent security review separately confirmed that +the cwd guard refused the packet's canonical static-scanner heredoc after +the earlier cwd tightening; this positive-recipe regression had no separate +GitHub finding URL. + +The new data-only AST regressions were run before the scanner change. The +combined run of `test_git_query_process_stream_authority_cannot_be_replaced`, +`test_excepthook_aliases_are_sensitive_error_sinks`, and +`test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards` reported +six failures in 1.079s: both stream mutations and all three imported-hook +container forms were accepted, while the first version of the canonical-body +fixture selector found no match. The selector was corrected to choose the +first current heredoc containing the canonical `scanner` mapping; its +standalone rerun failed in 0.174s because the scanner refused that actual +heredoc with “working-directory module escape.” The selector failure is not +counted as a reproduced scanner defect. + +The bounded capture proof now requires every `process` reference in +`run_bounded_git_query` to be one of the canonical collector, four group +termination, return-code read, or final `None` cleanup uses. This rejects +stream replacement, saved stream aliases, `setattr`, and other process-object +escapes while retaining the actual Popen-to-pipe collector and cleanup path. +Sensitive imported sink callables are now checked at their loaded-name uses: +direct calls and simple local callable aliases remain reviewable, while +list, tuple, dictionary, argument, and return escapes fail closed. For cwd +checking, the inspector now runs the existing module-value proof first and +shares only its proven literal reviewed-namespace value nodes with the cwd +guard; direct/imported cwd-change APIs and unreviewed module escapes remain +rejected. No source hash, helper-name trust, compiler-input allowlist, or +marker exemption was added. + +The first post-change focused attempt reported one false negative in 0.624s: +the new exact process-reference count expected three termination calls, but +the canonical runner has four. The count was corrected to the observed +canonical shape, and the focused verification passed: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_git_query_process_stream_authority_cannot_be_replaced \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_contextlib_chdir_is_rejected_through_aliases_and_containers \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path \ + Issue79RegressionTests.test_git_query_collector_must_return_bounded_pipe_bytes \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_warning_sink_and_absolute_path_do_not_disclose_local_data \ + Issue79RegressionTests.test_warning_and_absolute_path_aliases_preserve_sensitive_values +Ran 12 tests in 7.354s — OK + +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_actual_packet_compile_helpers_retain_provenance +Ran 1 test in 4.097s — OK +packet compile-source boundary: 119 actual helper calls checked +``` + +These focused checks covered the three new boundaries, the canonical +reviewed scanner body, the prior hooks, Path ownership, cwd/provider/ +active-result/bounded-output, warning/absolute-path and origin-comparison +controls, and 119 actual compile-helper calls. The +full harness, packet-wide static scan, Go checks, hosted CI, and live +operations were not run for this correction; the coordinator owns final +exact-head integration validation and subsequent independent review. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 0436dcbb..377dcd2c 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2807,6 +2807,106 @@ def test_git_query_capture_must_be_on_the_active_result_path(self) -> None: "a collector placed after the returned result must not certify its output", ) + def test_git_query_process_stream_authority_cannot_be_replaced(self) -> None: + bounded_body, marker = self.packet_heredoc_containing( + "def run_bounded_git_query(", + "stdout, stderr = capture_git_query_output(", + ) + inspect = self.scanner["inspect_python_heredoc"] + self.assertIsNone(inspect(bounded_body, marker)) # type: ignore[operator] + mutations = ( + ( + "drop stdout before bounded capture", + [ + ast.If( + test=ast.Compare( + left=ast.Constant(value="status"), + ops=[ast.In()], + comparators=[ast.Name(id="command", ctx=ast.Load())], + ), + body=[ + ast.Assign( + targets=[ + ast.Attribute( + value=ast.Name(id="process", ctx=ast.Load()), + attr="stdout", + ctx=ast.Store(), + ) + ], + value=ast.Constant(value=None), + ) + ], + orelse=[], + ) + ], + ), + ( + "save then replace stdout before bounded capture", + [ + ast.Assign( + targets=[ast.Name(id="saved_stdout", ctx=ast.Store())], + value=ast.Attribute( + value=ast.Name(id="process", ctx=ast.Load()), + attr="stdout", + ctx=ast.Load(), + ), + ), + ast.Assign( + targets=[ + ast.Attribute( + value=ast.Name(id="process", ctx=ast.Load()), + attr="stdout", + ctx=ast.Store(), + ) + ], + value=ast.Constant(value=None), + ), + ], + ), + ( + "replace stdout through setattr before bounded capture", + [ + ast.Expr( + value=ast.Call( + func=ast.Name(id="setattr", ctx=ast.Load()), + args=[ + ast.Name(id="process", ctx=ast.Load()), + ast.Constant(value="stdout"), + ast.Constant(value=None), + ], + keywords=[], + ) + ) + ], + ), + ) + for label, statements in mutations: + with self.subTest(mutation=label): + tree = ast.parse(bounded_body, filename="") + function = _verification_function(tree, "run_bounded_git_query") + creation = next( + node + for node in ast.walk(function) + if isinstance(node, ast.Assign) + and isinstance(node.value, ast.Call) + and self.scanner["python_dotted_name"](node.value.func) # type: ignore[operator] + == "subprocess.Popen" + ) + parents = { + child: parent + for parent in ast.walk(function) + for child in ast.iter_child_nodes(parent) + } + block = parents[creation] + self.assertIsInstance(block, ast.Try) + insertion = block.body.index(creation) + 1 # type: ignore[union-attr] + block.body[insertion:insertion] = statements # type: ignore[union-attr] + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + f"mutable Popen stream authority was certified: {label}", + ) + def test_git_query_collector_must_return_bounded_pipe_bytes(self) -> None: collector_body, marker = self.packet_heredoc_containing( "def capture_git_query_output(process, git_command, output_limit, input_bytes=None):", @@ -4372,6 +4472,20 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'hooks["error"](RuntimeError, RuntimeError(os.environ), None)\n', 'import os, sys\nhooks = [sys.excepthook]\nhandlers = hooks\n' 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'handlers = [emit]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'handlers = (emit,)\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'handlers = {"error": emit}\n' + 'handlers["error"](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nemit = sys.excepthook\nhandlers = [emit]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os, sys\nemit = getattr(sys, "excepthook")\n' + 'handlers = [emit]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', ) for body in unsafe: with self.subTest(body=body): @@ -4388,6 +4502,22 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'callbacks = [callback]\ncallbacks[0]("offline fixture")\n' )) + def test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards(self) -> None: + scanner_body_matches = [ + (body, marker) + for _number, body, marker, _invocation in self.scanner[ + "python_heredoc_bodies" + ](PACKET_TEXT) # type: ignore[operator] + if 'scanner = {"Path": Path, "ast": ast, "os": os, "re": re, "shlex": shlex}' + in body + ] + self.assertTrue(scanner_body_matches, "expected the canonical scanner heredoc") + scanner_body, marker = scanner_body_matches[0] + self.assertIsNone( + self.scanner["inspect_python_heredoc"](scanner_body, marker), # type: ignore[operator] + "the canonical reviewed scanner namespace should pass the complete body checks", + ) + def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: unsafe = ( 'from pathlib import Path\nPath("synthetic-owned").copy(Path("synthetic-destination"))\n', From 86f4f8fcc4c54782079b8502b6f2359414a82203 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 12:01:56 +0900 Subject: [PATCH 067/123] Reject nested Git process stream mutation --- docs/evidence/g01-recovery-packet.md | 41 +++++++- .../issue79_regression_test.py | 98 +++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 9d19f536..4eed1c18 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -14860,7 +14860,6 @@ def reviewed_python_git_query_capture( if isinstance(candidate, ast.Name) and candidate.id == "process" and isinstance(candidate.ctx, ast.Store) - and python_enclosing_scope(candidate, parents) is function_node ] process_creation = parents.get(node) if ( @@ -14885,7 +14884,6 @@ def reviewed_python_git_query_capture( if isinstance(candidate, ast.Name) and candidate.id == "process" and isinstance(candidate.ctx, ast.Load) - and python_enclosing_scope(candidate, parents) is function_node ] process_read_kinds = {"capture": 0, "terminate": 0, "returncode": 0, "cleanup": 0} for reference in process_reads: @@ -36982,3 +36980,42 @@ controls, and 119 actual compile-helper calls. The full harness, packet-wide static scan, Go checks, hosted CI, and live operations were not run for this correction; the coordinator owns final exact-head integration validation and subsequent independent review. + +#### Captured Popen reference follow-up from immutable `6574b875` + +After commit `6574b875297a70aa1a644415433c7d797e726c97`, the coordinator +confirmed two remaining AST-only variants of the mutable-stream P1: a nested +function capturing `process` and a lambda calling `setattr(process, ...)` +were both accepted, although direct field assignment, a saved stream alias, +and direct `setattr` were refused. These are the same bounded Git pipe +authority finding recorded above; no separate public review URL was supplied. + +Before the follow-up scanner edit, the focused +`test_git_query_process_stream_authority_cannot_be_replaced` run failed its +two nested-function/lambda subcases in 1.951s, reproducing the accepted +mutations without executing either AST specimen. The capture proof now counts +all `process` name reads and stores in the runner subtree, including nested +functions, lambdas, classes, and definition defaults. Any nested use must +therefore match the same finite collector, termination, return-code, or +cleanup shapes; the canonical runner has no nested Popen references and its +four cleanup calls and real pipe capture remain required. + +The changed-boundary focused verification passed: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_git_query_process_stream_authority_cannot_be_replaced \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_contextlib_chdir_is_rejected_through_aliases_and_containers \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture \ + Issue79RegressionTests.test_git_query_capture_must_be_on_the_active_result_path \ + Issue79RegressionTests.test_git_query_collector_must_return_bounded_pipe_bytes \ + Issue79RegressionTests.test_bounded_git_origin_capture_requires_reviewed_comparison_and_no_output_escape +Ran 8 tests in 7.855s — OK +``` + +The compiler-source provenance code did not change, so the 119-helper +selector was not repeated. No full harness, packet-wide scan, Go/hosted CI, +or live operation was run; those integration and exact-head review gates +remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 377dcd2c..55e90df0 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -2879,6 +2879,104 @@ def test_git_query_process_stream_authority_cannot_be_replaced(self) -> None: ) ], ), + ( + "nested closure replaces stdout before bounded capture", + [ + ast.FunctionDef( + name="hide_status_output", + args=ast.arguments( + posonlyargs=[], + args=[], + vararg=None, + kwonlyargs=[], + kw_defaults=[], + kwarg=None, + defaults=[], + ), + body=[ + ast.If( + test=ast.Compare( + left=ast.Constant(value="status"), + ops=[ast.In()], + comparators=[ + ast.Name(id="command", ctx=ast.Load()) + ], + ), + body=[ + ast.Assign( + targets=[ + ast.Attribute( + value=ast.Name( + id="process", ctx=ast.Load() + ), + attr="stdout", + ctx=ast.Store(), + ) + ], + value=ast.Constant(value=None), + ) + ], + orelse=[], + ) + ], + decorator_list=[], + ), + ast.Expr( + value=ast.Call( + func=ast.Name( + id="hide_status_output", ctx=ast.Load() + ), + args=[], + keywords=[], + ) + ), + ], + ), + ( + "lambda closure replaces stdout before bounded capture", + [ + ast.If( + test=ast.Compare( + left=ast.Constant(value="status"), + ops=[ast.In()], + comparators=[ast.Name(id="command", ctx=ast.Load())], + ), + body=[ + ast.Expr( + value=ast.Call( + func=ast.Lambda( + args=ast.arguments( + posonlyargs=[], + args=[], + vararg=None, + kwonlyargs=[], + kw_defaults=[], + kwarg=None, + defaults=[], + ), + body=ast.Call( + func=ast.Name( + id="setattr", ctx=ast.Load() + ), + args=[ + ast.Name( + id="process", ctx=ast.Load() + ), + ast.Constant(value="stdout"), + ast.Constant(value=None), + ], + keywords=[], + ), + ), + args=[], + keywords=[], + ) + ) + ], + orelse=[], + ) + ], + ), ) for label, statements in mutations: with self.subTest(mutation=label): From 746c1c148c882dede9ea9e12fc47bbec69855097 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 12:52:03 +0900 Subject: [PATCH 068/123] Close issue 79 hook and git cleanup proofs --- docs/evidence/g01-recovery-packet.md | 491 +++++++++++++++--- .../issue79_regression_test.py | 113 ++++ 2 files changed, 534 insertions(+), 70 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 4eed1c18..c2cdeebe 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -9643,7 +9643,7 @@ def python_lexical_scope_chain(scope, parents): return chain -def python_local_function_candidates(name, call, tree, parents): +def python_local_function_candidates(name, call_node, 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: @@ -9672,7 +9672,7 @@ def python_local_function_candidates(name, call, tree, parents): 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) + python_lexical_scope_chain(python_enclosing_scope(call_node, parents), parents) ) names = {name} for _ in range(len(aliases_by_scope) + 1): @@ -9694,10 +9694,10 @@ def python_local_function_candidates(name, call, tree, parents): ] -def python_local_lambda_candidates(name, call, tree, parents): +def python_local_lambda_candidates(name, call_node, tree, parents): """Resolve assigned lambdas visible from a local call site.""" visible_scopes = set( - python_lexical_scope_chain(python_enclosing_scope(call, parents), parents) + python_lexical_scope_chain(python_enclosing_scope(call_node, parents), parents) ) assignments_by_scope = {} for candidate in ast.walk(tree): @@ -9803,11 +9803,11 @@ def python_static_string_values_from_local_calls(node, tree, parents, seen=None) return values -def python_local_call_return_values(call, tree, parents): +def python_local_call_return_values(call_node, tree, parents): """Resolve returned expressions from visible local functions, methods, and lambdas.""" - if not isinstance(call, ast.Call): + if not isinstance(call_node, ast.Call): return [] - call_scope = python_enclosing_scope(call, parents) + call_scope = python_enclosing_scope(call_node, parents) visible_scopes = set(python_lexical_scope_chain(call_scope, parents)) index = getattr(tree, "_issue79_local_return_index", None) if index is None: @@ -9946,7 +9946,7 @@ def python_local_call_return_values(call, tree, parents): values.extend( result for candidate in python_local_function_candidates( - fallback.id, call, tree, parents + fallback.id, call_node, tree, parents ) for result in returned_values(candidate) ) @@ -9954,14 +9954,14 @@ def python_local_call_return_values(call, tree, parents): values.extend(bound_method_bindings(value.id, set(seen))) return values - function = call.func + function = call_node.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 + function.id, call_node, tree, parents ) for value in returned_values(candidate) ] @@ -10011,18 +10011,18 @@ def python_local_class_alias_names(name, method_name, scopes, methods_by_scope, return found -def python_local_method_candidates(attribute, call, tree, parents): +def python_local_method_candidates(attribute, call_node, 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) + python_local_call_return_values(call_node, 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) + call_scope = python_enclosing_scope(call_node, parents) visible_scopes = set(python_lexical_scope_chain(call_scope, parents)) if not any( binding_scope_id == id(scope) and method_name == attribute.attr @@ -10080,15 +10080,15 @@ def python_local_method_candidates(attribute, call, tree, parents): ] -def python_local_bound_method_candidates(name, call, tree, parents): +def python_local_bound_method_candidates(name, call_node, 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) + python_local_call_return_values(call_node, 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 + python_enclosing_scope(call_node, parents), parents )) def methods(candidate_name, seen): @@ -10100,7 +10100,7 @@ def python_local_bound_method_candidates(name, call, tree, parents): 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 + value, call_node, tree, parents )) elif ( isinstance(value, ast.Call) @@ -10112,13 +10112,13 @@ def python_local_bound_method_candidates(name, call, tree, parents): ast.Attribute( value=value.args[0], attr=attribute, ctx=ast.Load(), - ), call, tree, parents + ), call_node, 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 + value.args[2], call_node, tree, parents )) elif isinstance(value, ast.Name): found.extend(methods(value.id, set(seen))) @@ -10128,7 +10128,7 @@ def python_local_bound_method_candidates(name, call, tree, parents): ): if isinstance(returned, ast.Attribute): found.extend(python_local_method_candidates( - returned, call, tree, parents + returned, call_node, tree, parents )) return found @@ -10718,7 +10718,7 @@ def python_sensitive_value_names(tree, parents): positional = list(function.args.posonlyargs) + list(function.args.args) return positional + list(function.args.kwonlyargs) - def call_arguments(call, function, bound_method=False): + def call_arguments(call_node, function, bound_method=False): positional_parameters = list(function.args.posonlyargs) + list( function.args.args ) @@ -10736,7 +10736,7 @@ def python_sensitive_value_names(tree, parents): positional_parameters = positional_parameters[1:] parameters = positional_parameters + list(function.args.kwonlyargs) bound = [] - for index, argument in enumerate(call.args): + for index, argument in enumerate(call_node.args): if isinstance(argument, ast.Starred): for parameter in positional_parameters[index:]: bound.append((parameter.arg, argument.value)) @@ -10745,7 +10745,7 @@ def python_sensitive_value_names(tree, parents): 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: + for keyword in call_node.keywords: if keyword.arg in parameter_by_name: bound.append((keyword.arg, keyword.value)) if keyword.arg is None: @@ -11954,17 +11954,17 @@ def reviewed_python_compile_source(node, provenance=None, tree=None): return True -def reviewed_python_exec_call(call, safe_marker, tree=None): +def reviewed_python_exec_call(call_node, 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": + if not isinstance(call_node.func, ast.Name) or call_node.func.id != "exec": return False if tree is not None and any( python_compile_primitive_is_shadowed(name, tree) for name in ("exec", "compile") ): return False - if not call.args or not isinstance(call.args[0], ast.Call): + if not call_node.args or not isinstance(call_node.args[0], ast.Call): return False - compiler = call.args[0] + compiler = call_node.args[0] if not isinstance(compiler.func, ast.Name) or compiler.func.id != "compile": return False if len(compiler.args) != 3 or compiler.keywords: @@ -12011,13 +12011,13 @@ def python_indirect_execution_violation(tree, parents, safe_marker): return None -def reviewed_python_import_call(call, safe_marker): +def reviewed_python_import_call(call_node, safe_marker): """Allow only literal imports used by packet-only probes.""" - if not isinstance(call.func, ast.Name) or call.func.id != "__import__": + if not isinstance(call_node.func, ast.Name) or call_node.func.id != "__import__": return False - if not call.args or not isinstance(call.args[0], ast.Constant): + if not call_node.args or not isinstance(call_node.args[0], ast.Constant): return False - module = call.args[0].value + module = call_node.args[0].value return isinstance(module, str) and module in reviewed_python_import_modules @@ -12955,10 +12955,10 @@ def python_guarded_child_kwargs_violation(tree, modules, functions): return None -def python_command_argument(call): - if call.args: - return call.args[0] - for keyword in call.keywords: +def python_command_argument(call_node): + if call_node.args: + return call_node.args[0] + for keyword in call_node.keywords: if keyword.arg in {"args", "cmd", "command"}: return keyword.value return None @@ -14176,10 +14176,10 @@ def reviewed_python_git_query_output_budget(tree, parents, runner): parent = parents.get(node) if not isinstance(parent, ast.Call) or parent.func is not node or parent not in calls: return False - for call in calls: - if len(call.args) != 1 or any(keyword.arg is None for keyword in call.keywords): + for call_node in calls: + if len(call_node.args) != 1 or any(keyword.arg is None for keyword in call_node.keywords): return False - supplied_limits = [keyword.value for keyword in call.keywords if keyword.arg == "output_limit"] + supplied_limits = [keyword.value for keyword in call_node.keywords if keyword.arg == "output_limit"] if len(supplied_limits) > 1 or ( supplied_limits and not ( @@ -14624,7 +14624,7 @@ def reviewed_python_git_query_collector(tree, parents): node for node in local_nodes if is_name(node, "process") and isinstance(node.ctx, ast.Load) ] - process_read_nodes = [call.args[0] for call in process_getattrs] + process_read_nodes = [call_node.args[0] for call_node in process_getattrs] if wait_calls: process_read_nodes.append(wait_calls[0].func.value) if len(process_loads) != 3 or any( @@ -14885,6 +14885,29 @@ def reviewed_python_git_query_capture( and candidate.id == "process" and isinstance(candidate.ctx, ast.Load) ] + + def in_runner_lexical_scope(node): + current = parents.get(node) + while current is not None and current is not function_node: + if isinstance( + current, + ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ast.Lambda, + ast.ListComp, + ast.SetComp, + ast.DictComp, + ast.GeneratorExp, + ), + ): + return False + current = parents.get(current) + return current is function_node + + if any(not in_runner_lexical_scope(reference) for reference in process_reads): + return False process_read_kinds = {"capture": 0, "terminate": 0, "returncode": 0, "cleanup": 0} for reference in process_reads: parent = parents.get(reference) @@ -15001,6 +15024,137 @@ def reviewed_python_git_query_capture( return False if process_creation.lineno >= capture_assignment.lineno: return False + capture_try = parents.get(capture_assignment) + outer_try = parents.get(process_creation) + termination_calls = [ + candidate + for candidate in ast.walk(function_node) + if isinstance(candidate, ast.Call) + and python_dotted_name(candidate.func) == "terminate_git_query_group" + ] + if ( + not isinstance(capture_try, ast.Try) + or not isinstance(outer_try, ast.Try) + or capture_try is outer_try + or capture_assignment not in capture_try.body + or len(termination_calls) != 4 + or any(not in_runner_lexical_scope(candidate) for candidate in termination_calls) + ): + return False + + def termination_call(statement): + return ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Call) + and python_dotted_name(statement.value.func) + == "terminate_git_query_group" + and len(statement.value.args) == 1 + and isinstance(statement.value.args[0], ast.Name) + and statement.value.args[0].id == "process" + and not statement.value.keywords + ) + + expected_exception_types = {"subprocess.TimeoutExpired", "BaseException"} + if ( + len(capture_try.handlers) != 2 + or {python_dotted_name(handler.type) for handler in capture_try.handlers} + != expected_exception_types + ): + return False + exception_terminations = set() + for handler in capture_try.handlers: + expected_exit = ( + "SystemExit" + if python_dotted_name(handler.type) == "subprocess.TimeoutExpired" + else None + ) + if ( + len(handler.body) != 3 + or not termination_call(handler.body[0]) + or not isinstance(handler.body[1], ast.Assign) + or len(handler.body[1].targets) != 1 + or not isinstance(handler.body[1].targets[0], ast.Name) + or handler.body[1].targets[0].id != "process" + or not isinstance(handler.body[1].value, ast.Constant) + or handler.body[1].value.value is not None + or not isinstance(handler.body[2], ast.Raise) + or ( + expected_exit == "SystemExit" + and ( + not isinstance(handler.body[2].exc, ast.Call) + or python_dotted_name(handler.body[2].exc.func) != expected_exit + ) + ) + or (expected_exit is None and handler.body[2].exc is not None) + ): + return False + exception_terminations.add(handler.body[0].value) + + direct_capture_attempts = [ + statement + for statement in outer_try.body + if statement is capture_try + ] + returncode_assignments = [ + statement + for statement in outer_try.body + if isinstance(statement, ast.Assign) + and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name) + and statement.targets[0].id == "returncode" + and isinstance(statement.value, ast.Attribute) + and python_dotted_name(statement.value) == "process.returncode" + ] + normal_termination_statements = [ + statement + for statement in outer_try.body + if termination_call(statement) + ] + normal_process_clears = [ + statement + for statement in outer_try.body + if isinstance(statement, ast.Assign) + and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name) + and statement.targets[0].id == "process" + and isinstance(statement.value, ast.Constant) + and statement.value.value is None + ] + if ( + len(direct_capture_attempts) != 1 + or len(returncode_assignments) != 1 + or len(normal_termination_statements) != 1 + or len(normal_process_clears) != 1 + or not ( + outer_try.body.index(capture_try) + < outer_try.body.index(returncode_assignments[0]) + < outer_try.body.index(normal_termination_statements[0]) + < outer_try.body.index(normal_process_clears[0]) + ) + ): + return False + + if ( + len(outer_try.finalbody) != 1 + or not isinstance(outer_try.finalbody[0], ast.If) + or not isinstance(outer_try.finalbody[0].test, ast.Compare) + or python_dotted_name(outer_try.finalbody[0].test.left) != "process" + or len(outer_try.finalbody[0].test.ops) != 1 + or not isinstance(outer_try.finalbody[0].test.ops[0], ast.IsNot) + or len(outer_try.finalbody[0].test.comparators) != 1 + or not isinstance(outer_try.finalbody[0].test.comparators[0], ast.Constant) + or outer_try.finalbody[0].test.comparators[0].value is not None + or outer_try.finalbody[0].orelse + or len(outer_try.finalbody[0].body) != 1 + or not termination_call(outer_try.finalbody[0].body[0]) + ): + return False + expected_terminations = exception_terminations | { + normal_termination_statements[0].value, + outer_try.finalbody[0].body[0].value, + } + if len(expected_terminations) != 4 or set(termination_calls) != expected_terminations: + return False output_stores = [ candidate for candidate in ast.walk(function_node) @@ -16371,25 +16525,25 @@ python_path_mutator_destination_keywords = { } -def python_path_mutator_destination(call, method): +def python_path_mutator_destination(call_node, method): """Return only a statically visible destination for new Path mutators.""" keyword_name = python_path_mutator_destination_keywords.get(method) if keyword_name is None: return None - if len(call.args) > 1 or any( - isinstance(argument, ast.Starred) for argument in call.args + if len(call_node.args) > 1 or any( + isinstance(argument, ast.Starred) for argument in call_node.args ): return None named_destinations = [ - keyword.value for keyword in call.keywords if keyword.arg == keyword_name + keyword.value for keyword in call_node.keywords if keyword.arg == keyword_name ] if ( len(named_destinations) > 1 - or any(keyword.arg is None for keyword in call.keywords) - or (call.args and named_destinations) + or any(keyword.arg is None for keyword in call_node.keywords) + or (call_node.args and named_destinations) ): return None - for keyword in call.keywords: + for keyword in call_node.keywords: if keyword.arg == keyword_name: continue if not ( @@ -16399,8 +16553,8 @@ def python_path_mutator_destination(call, method): and isinstance(keyword.value.value, bool) ): return None - if call.args: - return call.args[0] + if call_node.args: + return call_node.args[0] if named_destinations: return named_destinations[0] return None @@ -16693,14 +16847,14 @@ def python_filesystem_mutator_alias_violation(tree, parents): "Python heredoc captures an extracted Path mutator " f"outside its reviewed scope on line {candidate.lineno}" ) - for call in alias_calls: - destination = python_path_mutator_destination(call, method) + for call_node in alias_calls: + destination = python_path_mutator_destination(call_node, method) if destination is None or not temporary_path_expression( destination, tree, parents ): return ( "Python heredoc calls an extracted Path mutator with an " - f"unreviewed destination on line {call.lineno}" + f"unreviewed destination on line {call_node.lineno}" ) return None @@ -18576,6 +18730,7 @@ def python_sensitive_output_sink(node, tree=None): for target in named_targets ) or python_assigned_sink_method_alias(named_value.id, tree) + or python_sensitive_imported_function_alias(named_value, tree) ) ): return True @@ -18605,28 +18760,11 @@ def python_sensitive_output_sink(node, tree=None): ) or python_assigned_sink_method_alias(name, tree) } - imported_functions = { - "builtins": {"print"}, - "sys": {"exit", "displayhook", "excepthook"}, - "warnings": {"warn", "warn_explicit", "showwarning"}, - "traceback": {"print_exc", "print_exception"}, - } - for candidate in ast.walk(tree): - if not isinstance(candidate, ast.ImportFrom): - continue - 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 + if python_sensitive_imported_function_alias(node.func, tree): + return True return ( isinstance(node.func, ast.Attribute) and node.func.attr.casefold() in python_sensitive_sink_methods @@ -19458,6 +19596,191 @@ def python_imported_function_alias_is_stable(name, module, function, tree): return imported +def python_sensitive_alias_scope(node, parents): + """Return the nearest function, class, comprehension, or module scope.""" + current = node + while current is not None: + if isinstance( + current, + ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.Lambda, + ast.ClassDef, + ast.ListComp, + ast.SetComp, + ast.DictComp, + ast.GeneratorExp, + ast.Module, + ), + ): + return current + current = parents.get(current) + return None + + +def python_sensitive_alias_scope_chain(node, parents): + """Resolve lexical scopes for sink aliases without counting unrelated locals.""" + scope = python_sensitive_alias_scope(node, parents) + 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 + parent = parents.get(scope) + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + if isinstance(parent, ast.ClassDef): + parent = parents.get(parent) + scope = python_sensitive_alias_scope(parent, parents) + return chain + + +def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None): + """Resolve sensitive imported callables in the reference's binding scope.""" + if not isinstance(node, ast.Name) or tree is None: + return False + if parents is None: + parents = getattr(tree, "_issue79_sensitive_alias_parents", None) + if parents is None: + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + tree._issue79_sensitive_alias_parents = parents + if seen is None: + seen = set() + scopes = python_sensitive_alias_scope_chain(node, parents) + binding_index = getattr(tree, "_issue79_sensitive_imported_alias_bindings", None) + if binding_index is None: + binding_index = {} + + def add(scope, name, kind, binding): + binding_index.setdefault((id(scope), name), []).append((kind, binding)) + + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store): + scope = python_sensitive_alias_scope(candidate, parents) + target = parents.get(candidate) + if ( + isinstance(target, ast.Assign) + and len(target.targets) == 1 + and target.targets[0] is candidate + ) or ( + isinstance(target, (ast.AnnAssign, ast.NamedExpr)) + and target.target is candidate + ): + add(scope, candidate.id, "assignment", target) + else: + add(scope, candidate.id, "unknown", candidate) + elif isinstance(candidate, ast.arg): + add( + python_sensitive_alias_scope(candidate, parents), + candidate.arg, + "unknown", + candidate, + ) + elif isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + add( + python_sensitive_alias_scope(parents.get(candidate), parents), + candidate.name, + "unknown", + candidate, + ) + elif isinstance(candidate, ast.Import): + scope = python_sensitive_alias_scope(candidate, parents) + for alias in candidate.names: + add( + scope, + alias.asname or alias.name.split(".", 1)[0], + "unknown", + candidate, + ) + elif isinstance(candidate, ast.ImportFrom): + scope = python_sensitive_alias_scope(candidate, parents) + for alias in candidate.names: + add( + scope, + alias.asname or alias.name, + "import", + (candidate, alias), + ) + elif isinstance(candidate, ast.Global): + scope = python_sensitive_alias_scope(candidate, parents) + for name in candidate.names: + add(scope, name, "global", candidate) + elif isinstance(candidate, ast.Nonlocal): + scope = python_sensitive_alias_scope(candidate, parents) + for name in candidate.names: + add(scope, name, "nonlocal", candidate) + elif isinstance(candidate, ast.ExceptHandler) and candidate.name: + add( + python_sensitive_alias_scope(candidate, parents), + candidate.name, + "unknown", + candidate, + ) + elif isinstance(candidate, (ast.MatchAs, ast.MatchStar)) and candidate.name: + add( + python_sensitive_alias_scope(candidate, parents), + candidate.name, + "unknown", + candidate, + ) + elif isinstance(candidate, ast.MatchMapping) and candidate.rest: + add( + python_sensitive_alias_scope(candidate, parents), + candidate.rest, + "unknown", + candidate, + ) + tree._issue79_sensitive_imported_alias_bindings = binding_index + + def resolves(reference, visited): + if not isinstance(reference, ast.Name): + return False + scope_chain = python_sensitive_alias_scope_chain(reference, parents) + for scope in scope_chain: + key = (id(scope), reference.id) + if key in visited: + return False + bindings = binding_index.get(key, ()) + if not bindings: + continue + if len(bindings) != 1: + return False + kind, binding = bindings[0] + if kind == "global": + continue + if kind == "import": + imported, alias = binding + return ( + imported.level == 0 + and imported.module in { + "builtins", + "sys", + "warnings", + "traceback", + } + and alias.name + in { + "builtins": {"print"}, + "sys": {"exit", "displayhook", "excepthook"}, + "warnings": {"warn", "warn_explicit", "showwarning"}, + "traceback": {"print_exc", "print_exception"}, + }[imported.module] + ) + if kind == "assignment" and isinstance(binding.value, ast.Name): + visited.add(key) + return resolves(binding.value, visited) + return False + return False + + return resolves(node, set(seen)) + + 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 @@ -37019,3 +37342,31 @@ The compiler-source provenance code did not change, so the 119-helper selector was not repeated. No full harness, packet-wide scan, Go/hosted CI, or live operation was run; those integration and exact-head review gates remain with the coordinator. + +#### Scope-sensitive hook sinks, reachable Git cleanup and scanner self-audit from `86f4f8fc` + +At entry on 2026-10-04 the worktree was clean at immutable `86f4f8fcc4c54782079b8502b6f2359414a82203`. The coordinator's exact packet selector at that source had one canonical compiler-body self-audit failure in 50.995s; the two independent read-only reviews retained HOLDs, including the newly reproduced process-cleanup P1. This correction did not rerun the packet selector or full suite. + +Three concrete boundaries were covered in one focused batch. First, the imported `sys.excepthook` alias remained a sensitive sink after unrelated nested function-parameter, function-definition, or class-definition shadows; direct calls and list-held hook references with `os.environ` now fail closed, while a parameter-shadowed local callback with a literal argument remains accepted. This closes the same sink-storage family as [Codex finding 4173966622](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622); no additional external review URL was provided for the lexical-shadow variants. + +Second, timeout and process-group cleanup calls must occupy the direct timeout, general-exception, normal-result and outer-finally paths before the process reference is cleared or an exception is raised. The proof refuses process reads inside nested functions, lambdas, classes or comprehensions and matches each cleanup call to its owning path; it does not infer safety from four matching call spellings. The coordinator and contract reviewer independently accepted, at immutable `86f4f8fc`, a timeout cleanup moved into an uncalled nested function, and the coordinator separately accepted each of the four original cleanup calls wrapped in `if False`; these were static AST-data probes only. No process was spawned, terminated, or deliberately leaked. + +Third, the current canonical scanner/compiler heredoc passed its own callback audit after renaming three local AST call-node bindings (`call` to `call_node`) in the comprehension and loop targets identified by the contract review. No callback classification rule, compiler-source proof, source hash, marker exemption, or helper allowlist was relaxed or added; an existing unresolved launcher-alias refusal remains covered. + +The pre-correction bounded command produced meaningful RED evidence: four newly added hook-shadow/direct-call subcases, one deferred-timeout-cleanup mutation, and the exact current canonical scanner-body selector failed—six assertions across three methods in 38.495s. The coordinator's immutable-source probes also established RED for all four false-condition cleanup wrappers. An initial implementation pass exposed a canonical-positive regression because it checked the normal cleanup inside the inner capture `try`; a three-method focused rerun failed that one positive in 42.680s. The proof was corrected to check the capture attempt and result/cleanup ordering across the containing runner `try`, including the existing bare re-raise on `BaseException`. + +The final bounded focused command passed seven methods in 49.059s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_git_query_timeout_cleanup_must_remain_reachable \ + Issue79RegressionTests.test_git_query_process_stream_authority_cannot_be_replaced \ + Issue79RegressionTests.test_current_scanner_compiler_body_remains_self_audit_compatible \ + Issue79RegressionTests.test_current_scanner_self_audit_ast_parameters_are_not_callbacks \ + Issue79RegressionTests.test_launcher_aliases_returned_by_local_helpers_are_rejected \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards +Ran 7 tests in 49.059s — OK +``` + +The tests retained the canonical bounded collector, all four direct cleanup paths, the existing process-stream authority negatives, the canonical scanner namespace, local callback safety and unresolved command-alias refusal. The new AST examples were parsed and inspected only; no verifier body, hook, Git child or adversarial recipe was executed. Compiler-source provenance logic was unchanged, so the 119-helper selector was not repeated. No full harness, packet scan, Go/hosted CI or live operation is claimed; exact-head packet validation and both new-head independent reviews remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 55e90df0..69f053a3 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -4584,6 +4584,21 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'import os, sys\nemit = getattr(sys, "excepthook")\n' 'handlers = [emit]\n' 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'def unrelated(emit):\n pass\n' + 'handlers = [emit]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'def unrelated():\n def emit():\n pass\n' + 'handlers = [emit]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'def unrelated():\n class emit:\n pass\n' + 'handlers = [emit]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'def unrelated(emit):\n pass\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', ) for body in unsafe: with self.subTest(body=body): @@ -4599,6 +4614,104 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'def callback(*args):\n return None\n' 'callbacks = [callback]\ncallbacks[0]("offline fixture")\n' )) + self.assertIsNone(self.inspect( + 'from sys import excepthook as emit\n' + 'def local_callback(emit):\n' + ' handlers = [emit]\n handlers[0]("offline fixture")\n' + )) + + def test_git_query_timeout_cleanup_must_remain_reachable(self) -> None: + bounded_body, marker = self.packet_heredoc_containing( + "def run_bounded_git_query(", + "stdout, stderr = capture_git_query_output(", + ) + inspect = self.scanner["inspect_python_heredoc"] + self.assertIsNone(inspect(bounded_body, marker)) # type: ignore[operator] + tree = ast.parse(bounded_body, filename="") + runner = _verification_function(tree, "run_bounded_git_query") + timeout_handler = next( + node + for node in ast.walk(runner) + if isinstance(node, ast.ExceptHandler) + and isinstance(node.type, ast.Attribute) + and self.scanner["python_dotted_name"](node.type) # type: ignore[operator] + == "subprocess.TimeoutExpired" + ) + self.assertTrue(timeout_handler.body) + cleanup = timeout_handler.body[0] + self.assertIsInstance(cleanup, ast.Expr) + self.assertIsInstance(cleanup.value, ast.Call) # type: ignore[union-attr] + timeout_handler.body[0] = ast.FunctionDef( + name="deferred_timeout_cleanup", + args=ast.arguments( + posonlyargs=[], + args=[], + vararg=None, + kwonlyargs=[], + kw_defaults=[], + kwarg=None, + defaults=[], + ), + body=[cleanup], + decorator_list=[], + ) + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + "unreachable nested timeout termination must not certify process cleanup", + ) + + for cleanup_index in range(4): + with self.subTest(unreachable_cleanup=cleanup_index): + tree = ast.parse( + bounded_body, + filename="", + ) + runner = _verification_function(tree, "run_bounded_git_query") + parents = { + child: parent + for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent) + } + cleanup_calls = [ + node + for node in ast.walk(runner) + if isinstance(node, ast.Call) + and self.scanner["python_dotted_name"](node.func) # type: ignore[operator] + == "terminate_git_query_group" + ] + self.assertEqual(4, len(cleanup_calls)) + cleanup_statement = parents[cleanup_calls[cleanup_index]] + self.assertIsInstance(cleanup_statement, ast.Expr) + container = parents[cleanup_statement] + list_field = next( + field + for field in ("body", "orelse", "finalbody") + if isinstance(getattr(container, field, None), list) + and cleanup_statement in getattr(container, field) + ) + statements = getattr(container, list_field) + statement_index = statements.index(cleanup_statement) + statements[statement_index] = ast.If( + test=ast.Constant(value=False), + body=[cleanup_statement], + orelse=[], + ) + ast.fix_missing_locations(tree) + self.assertIsNotNone( + inspect(ast.unparse(tree), marker), # type: ignore[operator] + "a cleanup hidden behind a false condition must not certify its path", + ) + + def test_current_scanner_compiler_body_remains_self_audit_compatible(self) -> None: + scanner_body, marker = self.packet_heredoc_containing( + "def python_import_bindings(tree):", + "def python_local_call_return_values(call_node, tree, parents):", + ) + self.assertIsNone( + self.scanner["inspect_python_heredoc"](scanner_body, marker), # type: ignore[operator] + "the canonical scanner/compiler body must pass its own AST audit", + ) def test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards(self) -> None: scanner_body_matches = [ From 8d9bd7f0b28ba6423dc8fe1237b0c244a2ed5ca8 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 13:10:28 +0900 Subject: [PATCH 069/123] fix evidence hook alias scope resolution --- docs/evidence/g01-recovery-packet.md | 90 +++++++++++++------ .../issue79_regression_test.py | 30 +++++++ 2 files changed, 93 insertions(+), 27 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index c2cdeebe..b66d242c 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -19738,10 +19738,11 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None ) tree._issue79_sensitive_imported_alias_bindings = binding_index - def resolves(reference, visited): + def resolves(reference, visited, scope_chain=None): if not isinstance(reference, ast.Name): return False - scope_chain = python_sensitive_alias_scope_chain(reference, parents) + if scope_chain is None: + scope_chain = python_sensitive_alias_scope_chain(reference, parents) for scope in scope_chain: key = (id(scope), reference.id) if key in visited: @@ -19749,32 +19750,48 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None bindings = binding_index.get(key, ()) if not bindings: continue - if len(bindings) != 1: - return False - kind, binding = bindings[0] - if kind == "global": - continue - if kind == "import": - imported, alias = binding - return ( - imported.level == 0 - and imported.module in { - "builtins", - "sys", - "warnings", - "traceback", - } - and alias.name - in { - "builtins": {"print"}, - "sys": {"exit", "displayhook", "excepthook"}, - "warnings": {"warn", "warn_explicit", "showwarning"}, - "traceback": {"print_exc", "print_exception"}, - }[imported.module] + kinds = {kind for kind, _ in bindings} + if "global" in kinds: + module_scope = next( + (candidate for candidate in reversed(scope_chain) + if isinstance(candidate, ast.Module)), + None, + ) + if module_scope is None or module_scope is scope: + return False + return resolves( + reference, + visited | {key}, + [module_scope], ) - if kind == "assignment" and isinstance(binding.value, ast.Name): - visited.add(key) - return resolves(binding.value, visited) + if "nonlocal" in kinds: + continue + for kind, binding in bindings: + if kind == "import": + imported, alias = binding + if ( + imported.level == 0 + and imported.module in { + "builtins", + "sys", + "warnings", + "traceback", + } + and alias.name + in { + "builtins": {"print"}, + "sys": {"exit", "displayhook", "excepthook"}, + "warnings": {"warn", "warn_explicit", "showwarning"}, + "traceback": {"print_exc", "print_exception"}, + }[imported.module] + ): + return True + elif ( + kind == "assignment" + and isinstance(binding.value, ast.Name) + and resolves(binding.value, visited | {key}) + ): + return True return False return False @@ -37370,3 +37387,22 @@ Ran 7 tests in 49.059s — OK ``` The tests retained the canonical bounded collector, all four direct cleanup paths, the existing process-stream authority negatives, the canonical scanner namespace, local callback safety and unresolved command-alias refusal. The new AST examples were parsed and inspected only; no verifier body, hook, Git child or adversarial recipe was executed. Compiler-source provenance logic was unchanged, so the 119-helper selector was not repeated. No full harness, packet scan, Go/hosted CI or live operation is claimed; exact-head packet validation and both new-head independent reviews remain with the coordinator. + +#### Scope-sensitive imported-hook source flow from `746c1c14` + +At entry on 2026-10-04 the worktree was clean at `746c1c148c882dede9ea9e12fc47bbec69855097`. The coordinator and both independent read-only reviews found the previous hook pass incomplete: a `nonlocal` reference did not resolve to the nearest enclosing imported binding, a saved alias lost its imported-hook source after the original name was rebound, and `global` lookup could stop at an intervening outer local definition. The security review and coordinator separately reproduced the first two cases; the coordinator also reproduced the global-routing case. These findings extend the stored imported `sys.excepthook` callable family recorded at [Codex finding 4173966622](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622); no separate external review URL was supplied for the lexical binding variants. + +Four inert source cases were added to `test_excepthook_aliases_are_sensitive_error_sinks`: a direct call through a `nonlocal` import, a container-held `nonlocal` import, a saved imported alias after a later `None` rebinding, and a `global` reference with an intervening outer local function of the same name. Before the scanner change, the focused hook selector failed all four new assertions in 0.184s. The resolver now follows `nonlocal` references through enclosing lexical scopes, sends `global` references directly to the module binding, and checks each candidate source in an ambiguous binding set so an earlier proven imported sink is retained for copied aliases. Existing local parameter callbacks with literal arguments remain accepted. + +The post-change focused command passed four methods in 0.200s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_imported_exit_alias_and_system_exit_preserve_sensitive_taint \ + Issue79RegressionTests.test_output_sink_method_alias_keeps_sensitive_taint +Ran 4 tests in 0.200s — OK +``` + +These cases were parsed as AST data; no hook or specimen was executed. The change is confined to sensitive imported-callable reference resolution and its focused regression cases; compiler-source provenance was not changed, so the 119-helper selector was not repeated. No full suite, packet selector, whole scanner self-audit, Go/hosted CI or live operation is claimed; final packet integration and exact-head independent reviews remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 69f053a3..5d6e24af 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -4599,6 +4599,36 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'import os\nfrom sys import excepthook as emit\n' 'def unrelated(emit):\n pass\n' 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\n' + 'def outer():\n' + ' from sys import excepthook as emit\n' + ' def inner():\n' + ' nonlocal emit\n' + ' emit(RuntimeError, RuntimeError(os.environ), None)\n' + ' inner()\n' + 'outer()\n', + 'import os\n' + 'def outer():\n' + ' from sys import excepthook as emit\n' + ' def inner():\n' + ' nonlocal emit\n' + ' handlers = [emit]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + ' inner()\n' + 'outer()\n', + 'import os\nfrom sys import excepthook as emit\n' + 'saved = emit\nemit = None\nhandlers = [saved]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as emit\n' + 'def outer():\n' + ' def emit(*args):\n' + ' pass\n' + ' def inner():\n' + ' global emit\n' + ' handlers = [emit]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + ' inner()\n' + 'outer()\n', ) for body in unsafe: with self.subTest(body=body): From 1377bad93401b8b437d6b5b1b009db55e65d417b Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 13:19:13 +0900 Subject: [PATCH 070/123] preserve module hook aliases through global declaration --- docs/evidence/g01-recovery-packet.md | 21 +++++++++++++++++-- .../issue79_regression_test.py | 7 +++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index b66d242c..42df8e67 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -19751,13 +19751,13 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None if not bindings: continue kinds = {kind for kind, _ in bindings} - if "global" in kinds: + if "global" in kinds and not isinstance(scope, ast.Module): module_scope = next( (candidate for candidate in reversed(scope_chain) if isinstance(candidate, ast.Module)), None, ) - if module_scope is None or module_scope is scope: + if module_scope is None: return False return resolves( reference, @@ -37406,3 +37406,20 @@ Ran 4 tests in 0.200s — OK ``` These cases were parsed as AST data; no hook or specimen was executed. The change is confined to sensitive imported-callable reference resolution and its focused regression cases; compiler-source provenance was not changed, so the 119-helper selector was not repeated. No full suite, packet selector, whole scanner self-audit, Go/hosted CI or live operation is claimed; final packet integration and exact-head independent reviews remain with the coordinator. + +#### Module-level `global` keeps imported-hook provenance from `8d9bd7f0` + +At entry on 2026-10-04 the worktree was clean at `8d9bd7f0b28ba6423dc8fe1237b0c244a2ed5ca8`. The coordinator's prior two focused selectors passed in 59.373s, including 119 actual compiler-helper checks and the packet scan of 261 shell commands and 86 Python bodies with zero violations; this was not a full-suite result and did not cover the newly identified module-scope `global` case. A module-level `global emit` declaration before `from sys import excepthook as emit` is a legal no-op, but the resolver treated it as a routing directive and discarded the imported sink. Both direct and list-held `emit` calls with `RuntimeError(os.environ)` were accepted by the 8d9 scanner. This remains part of the imported-hook callable family linked to [Codex finding 4173966622](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622); no separate external review URL was supplied for this variant. + +The two module-level source forms were added to `test_excepthook_aliases_are_sensitive_error_sinks` and failed as two assertions in 0.182s before the correction. The resolver now redirects `global` references to module scope only when resolving a function-scope binding; at module scope it continues examining the imported and copied callable sources. The final post-ledger focused hook, displayhook, imported-exit and output-alias selectors passed together in 0.203s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_imported_exit_alias_and_system_exit_preserve_sensitive_taint \ + Issue79RegressionTests.test_output_sink_method_alias_keeps_sensitive_taint +Ran 4 tests in 0.203s — OK +``` + +The module-level and prior nested-hook examples are inert AST source strings; no hook, environment value or specimen was executed. Compiler-source provenance is unchanged, so the 119-helper selector was not repeated. No full suite, packet selector, Go/hosted CI or live operation is claimed; final exact-head packet integration and reviews remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 5d6e24af..da7eaf1b 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -4599,6 +4599,13 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'import os\nfrom sys import excepthook as emit\n' 'def unrelated(emit):\n pass\n' 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nglobal emit\n' + 'from sys import excepthook as emit\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nglobal emit\n' + 'from sys import excepthook as emit\n' + 'handlers = [emit]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', 'import os\n' 'def outer():\n' ' from sys import excepthook as emit\n' From 8f24ad697700fe3b31d7240f472d2ba044bbe7cb Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 13:40:12 +0900 Subject: [PATCH 071/123] route hook alias stores through declared scopes --- docs/evidence/g01-recovery-packet.md | 94 ++++++++++++++++--- .../issue79_regression_test.py | 49 ++++++++++ 2 files changed, 131 insertions(+), 12 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 42df8e67..d0b5e410 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -19660,6 +19660,45 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None def add(scope, name, kind, binding): binding_index.setdefault((id(scope), name), []).append((kind, binding)) + declarations = {} + for candidate in ast.walk(tree): + if isinstance(candidate, (ast.Global, ast.Nonlocal)): + scope = python_sensitive_alias_scope(candidate, parents) + kind = "global" if isinstance(candidate, ast.Global) else "nonlocal" + for name in candidate.names: + declarations.setdefault((id(scope), name), set()).add(kind) + + def binding_owner(scope, name, context): + kinds = declarations.get((id(scope), name), ()) + if "global" in kinds and not isinstance(scope, ast.Module): + return next( + (candidate for candidate in reversed( + python_sensitive_alias_scope_chain(context, parents) + ) if isinstance(candidate, ast.Module)), + None, + ) + if "nonlocal" in kinds: + scopes = python_sensitive_alias_scope_chain(context, parents) + passed_current = False + for candidate in scopes: + if candidate is scope: + passed_current = True + continue + if passed_current and isinstance( + candidate, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda), + ): + return candidate + return None + return scope + + def add_owned(scope, name, kind, binding, context): + owner = binding_owner(scope, name, context) + if owner is None: + add(scope, name, "unknown", binding) + else: + add(owner, name, kind, binding) + for candidate in ast.walk(tree): if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store): scope = python_sensitive_alias_scope(candidate, parents) @@ -19672,9 +19711,9 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None isinstance(target, (ast.AnnAssign, ast.NamedExpr)) and target.target is candidate ): - add(scope, candidate.id, "assignment", target) + add_owned(scope, candidate.id, "assignment", target, candidate) else: - add(scope, candidate.id, "unknown", candidate) + add_owned(scope, candidate.id, "unknown", candidate, candidate) elif isinstance(candidate, ast.arg): add( python_sensitive_alias_scope(candidate, parents), @@ -19683,29 +19722,34 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None candidate, ) elif isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - add( - python_sensitive_alias_scope(parents.get(candidate), parents), + context = parents.get(candidate) + scope = python_sensitive_alias_scope(context, parents) + add_owned( + scope, candidate.name, "unknown", candidate, + context, ) elif isinstance(candidate, ast.Import): scope = python_sensitive_alias_scope(candidate, parents) for alias in candidate.names: - add( + add_owned( scope, alias.asname or alias.name.split(".", 1)[0], "unknown", candidate, + candidate, ) elif isinstance(candidate, ast.ImportFrom): scope = python_sensitive_alias_scope(candidate, parents) for alias in candidate.names: - add( + add_owned( scope, alias.asname or alias.name, "import", (candidate, alias), + candidate, ) elif isinstance(candidate, ast.Global): scope = python_sensitive_alias_scope(candidate, parents) @@ -19716,25 +19760,31 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None for name in candidate.names: add(scope, name, "nonlocal", candidate) elif isinstance(candidate, ast.ExceptHandler) and candidate.name: - add( - python_sensitive_alias_scope(candidate, parents), + scope = python_sensitive_alias_scope(candidate, parents) + add_owned( + scope, candidate.name, "unknown", candidate, + candidate, ) elif isinstance(candidate, (ast.MatchAs, ast.MatchStar)) and candidate.name: - add( - python_sensitive_alias_scope(candidate, parents), + scope = python_sensitive_alias_scope(candidate, parents) + add_owned( + scope, candidate.name, "unknown", candidate, + candidate, ) elif isinstance(candidate, ast.MatchMapping) and candidate.rest: - add( - python_sensitive_alias_scope(candidate, parents), + scope = python_sensitive_alias_scope(candidate, parents) + add_owned( + scope, candidate.rest, "unknown", candidate, + candidate, ) tree._issue79_sensitive_imported_alias_bindings = binding_index @@ -37423,3 +37473,23 @@ Ran 4 tests in 0.203s — OK ``` The module-level and prior nested-hook examples are inert AST source strings; no hook, environment value or specimen was executed. Compiler-source provenance is unchanged, so the 119-helper selector was not repeated. No full suite, packet selector, Go/hosted CI or live operation is claimed; final exact-head packet integration and reviews remain with the coordinator. + +#### Declaration-owned imported-hook stores from `1377bad9` + +At entry on 2026-10-04 the worktree was clean at `1377bad93401b8b437d6b5b1b009db55e65d417b`. The prior coordinator packet selector at that exact source passed one selector in 54.918s over 261 shell commands and 86 Python bodies with zero violations, and the scoped hook review passed; the security review nevertheless held on imported-hook stores whose Python binding owner differs from their lexical AST scope. The coordinator and security reviewer confirmed these AST-only forms were accepted: an imported hook stored by a function-scope `global` import, the same import through `nonlocal`, a `global saved` alias assignment, and a `nonlocal saved` alias assignment. Direct and container-held calls were both covered for the import-store cases; no separate external review URL was supplied for these declaration-owned-store variants, which extend [Codex finding 4173966622](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622). + +Six inert source cases were added to `test_excepthook_aliases_are_sensitive_error_sinks`. Before the scanner correction, the focused hook selector failed six assertions in 0.195s. The scanner now gathers `global` and `nonlocal` declarations before indexing stores, then attributes import, assignment, definition and other named stores to the declared module or nearest enclosing function scope; the declarations themselves remain in the lexical scope for reference routing. A module-level `global` remains a no-op, unresolved ownership stays unknown, and existing local literal-callback controls remain accepted. + +The final post-ledger changed-boundary focused command passed five methods in 0.211s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_imported_exit_alias_and_system_exit_preserve_sensitive_taint \ + Issue79RegressionTests.test_output_sink_method_alias_keeps_sensitive_taint \ + Issue79RegressionTests.test_launcher_aliases_returned_by_local_helpers_are_rejected +Ran 5 tests in 0.211s — OK +``` + +The examples were parsed and inspected as AST data only; no specimen, hook or environment value was executed or read. No symbol-table/compiler helper, compiler-source allowlist, Git/Popen/path/cwd proof or other provenance boundary changed. The 119-helper selector, packet selector, full suite, Go/hosted CI and live operations were not repeated; the 1377 packet result remains historical evidence, and final packet integration plus exact-head reviews remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index da7eaf1b..a37387ce 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -4607,6 +4607,55 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'handlers = [emit]\n' 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', 'import os\n' + 'def install():\n' + ' global emit\n' + ' from sys import excepthook as emit\n' + ' emit(RuntimeError, RuntimeError(os.environ), None)\n' + 'install()\n', + 'import os\n' + 'def install():\n' + ' global emit\n' + ' from sys import excepthook as emit\n' + ' handlers = [emit]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + 'install()\n', + 'import os\n' + 'def outer():\n' + ' emit = None\n' + ' def install():\n' + ' nonlocal emit\n' + ' from sys import excepthook as emit\n' + ' emit(RuntimeError, RuntimeError(os.environ), None)\n' + ' install()\n' + 'outer()\n', + 'import os\n' + 'def outer():\n' + ' emit = None\n' + ' def install():\n' + ' nonlocal emit\n' + ' from sys import excepthook as emit\n' + ' handlers = [emit]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + ' install()\n' + 'outer()\n', + 'import os\nfrom sys import excepthook as source\n' + 'def install():\n' + ' global saved\n' + ' saved = source\n' + 'install()\n' + 'handlers = [saved]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as source\n' + 'def outer():\n' + ' saved = None\n' + ' def install():\n' + ' nonlocal saved\n' + ' saved = source\n' + ' install()\n' + ' handlers = [saved]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + 'outer()\n', + 'import os\n' 'def outer():\n' ' from sys import excepthook as emit\n' ' def inner():\n' From 734ef68b6036f86f5a6ecc68014e9888e07de80f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 13:53:15 +0900 Subject: [PATCH 072/123] resolve nonlocal hook stores to actual owner --- docs/evidence/g01-recovery-packet.md | 88 ++++++++++++++++++- .../issue79_regression_test.py | 24 +++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index d0b5e410..9437f17e 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -19668,6 +19668,58 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None for name in candidate.names: declarations.setdefault((id(scope), name), set()).add(kind) + local_names = {} + + def record_local(scope, name): + if scope is None: + return + kinds = declarations.get((id(scope), name), set()) + if not isinstance(scope, ast.Module) and kinds.intersection( + {"global", "nonlocal"} + ): + return + local_names.setdefault(id(scope), set()).add(name) + + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store): + record_local( + python_sensitive_alias_scope(candidate, parents), + candidate.id, + ) + elif isinstance(candidate, ast.arg): + record_local( + python_sensitive_alias_scope(candidate, parents), + candidate.arg, + ) + elif isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + record_local( + python_sensitive_alias_scope(parents.get(candidate), parents), + candidate.name, + ) + elif isinstance(candidate, ast.Import): + scope = python_sensitive_alias_scope(candidate, parents) + for alias in candidate.names: + record_local(scope, alias.asname or alias.name.split(".", 1)[0]) + elif isinstance(candidate, ast.ImportFrom): + scope = python_sensitive_alias_scope(candidate, parents) + for alias in candidate.names: + record_local(scope, alias.asname or alias.name) + elif isinstance(candidate, ast.ExceptHandler) and candidate.name: + record_local( + python_sensitive_alias_scope(candidate, parents), + candidate.name, + ) + elif isinstance(candidate, (ast.MatchAs, ast.MatchStar)) and candidate.name: + record_local( + python_sensitive_alias_scope(candidate, parents), + candidate.name, + ) + elif isinstance(candidate, ast.MatchMapping) and candidate.rest: + record_local( + python_sensitive_alias_scope(candidate, parents), + candidate.rest, + ) + def binding_owner(scope, name, context): kinds = declarations.get((id(scope), name), ()) if "global" in kinds and not isinstance(scope, ast.Module): @@ -19684,9 +19736,13 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None if candidate is scope: passed_current = True continue - if passed_current and isinstance( - candidate, - (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda), + if ( + passed_current + and isinstance( + candidate, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda), + ) + and name in local_names.get(id(candidate), ()) ): return candidate return None @@ -19695,7 +19751,7 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None def add_owned(scope, name, kind, binding, context): owner = binding_owner(scope, name, context) if owner is None: - add(scope, name, "unknown", binding) + add(scope, name, "unresolved", binding) else: add(owner, name, kind, binding) @@ -19802,6 +19858,8 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None continue kinds = {kind for kind, _ in bindings} if "global" in kinds and not isinstance(scope, ast.Module): + if "unresolved" in kinds: + return True module_scope = next( (candidate for candidate in reversed(scope_chain) if isinstance(candidate, ast.Module)), @@ -19815,6 +19873,8 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None [module_scope], ) if "nonlocal" in kinds: + if "unresolved" in kinds: + return True continue for kind, binding in bindings: if kind == "import": @@ -37493,3 +37553,23 @@ Ran 5 tests in 0.211s — OK ``` The examples were parsed and inspected as AST data only; no specimen, hook or environment value was executed or read. No symbol-table/compiler helper, compiler-source allowlist, Git/Popen/path/cwd proof or other provenance boundary changed. The 119-helper selector, packet selector, full suite, Go/hosted CI and live operations were not repeated; the 1377 packet result remains historical evidence, and final packet integration plus exact-head reviews remain with the coordinator. + +#### `nonlocal` store resolves through unbound intermediate functions from `8f24ad69` + +At entry on 2026-10-04 the worktree was clean at `8f24ad697700fe3b31d7240f472d2ba044bbe7cb`. The 1377 packet result of one selector passing in 54.918s remains historical; the 8f24 hook and adjacent focused run passed five methods in 0.210s but did not include a transparent intermediate function. The coordinator reproduced two accepted AST-only P1 variants at 8f24: a nested `nonlocal emit` import and a nested `nonlocal saved = source` assignment both skipped an intermediate function with no such local binding, so the scanner attributed the store to that intermediate scope and missed the later outer-cell read. These extend the imported `sys.excepthook` callable family at [Codex finding 4173966622](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622); no new external finding URL was provided. + +The two transparent-middle source cases were added to `test_excepthook_aliases_are_sensitive_error_sinks`. Before correction, the focused hook selector failed two assertions in 0.195s. The scanner now gathers AST-local binding names as well as `global`/`nonlocal` declarations; each `nonlocal` store skips enclosing function scopes that do not bind the referenced name and targets the nearest actual enclosing function cell. Imported callables and alias assignments use the same owner routing, while unresolved ownership remains explicitly unresolved and refuses rather than being treated as a safe local callback. One implementation attempt raised 14 focused errors because the declaration lookup used a tuple default where the new set operation required a set; the default was corrected and the focused hook selector then passed. + +The final post-ledger changed-boundary command passed five methods in 0.219s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_imported_exit_alias_and_system_exit_preserve_sensitive_taint \ + Issue79RegressionTests.test_output_sink_method_alias_keeps_sensitive_taint \ + Issue79RegressionTests.test_launcher_aliases_returned_by_local_helpers_are_rejected +Ran 5 tests in 0.219s — OK +``` + +All examples were parsed and checked as AST data; no specimen, hook, environment value or source program was executed, compiled or passed through a symbol-table helper. No other scanner boundary or compiler-source allowlist changed. No 119-helper sweep, packet selector, full suite, Go/hosted CI or live operation was run; exact-head packet integration and independent reviews remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index a37387ce..947c4257 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -4672,9 +4672,33 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' ' inner()\n' 'outer()\n', + 'import os\n' + 'def outer():\n' + ' emit = None\n' + ' def middle():\n' + ' def install():\n' + ' nonlocal emit\n' + ' from sys import excepthook as emit\n' + ' install()\n' + ' middle()\n' + ' handlers = [emit]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + 'outer()\n', 'import os\nfrom sys import excepthook as emit\n' 'saved = emit\nemit = None\nhandlers = [saved]\n' 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as source\n' + 'def outer():\n' + ' saved = None\n' + ' def middle():\n' + ' def install():\n' + ' nonlocal saved\n' + ' saved = source\n' + ' install()\n' + ' middle()\n' + ' handlers = [saved]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + 'outer()\n', 'import os\nfrom sys import excepthook as emit\n' 'def outer():\n' ' def emit(*args):\n' From 1fbd7a1fd4c186036f6d367f385cf0f2165c6e30 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 14:14:16 +0900 Subject: [PATCH 073/123] track hook aliases through comprehension walrus stores --- docs/evidence/g01-recovery-packet.md | 38 +++++++++++++++++- .../issue79_regression_test.py | 39 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 9437f17e..eb96f05e 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -19637,6 +19637,20 @@ def python_sensitive_alias_scope_chain(node, parents): return chain +def python_sensitive_alias_binding_scope(node, parents): + """Map comprehension walrus stores to their containing Python scope.""" + scope = python_sensitive_alias_scope(node, parents) + target = parents.get(node) + if not isinstance(target, ast.NamedExpr) or target.target is not node: + return scope + while isinstance( + scope, + (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp), + ): + scope = python_sensitive_alias_scope(parents.get(scope), parents) + return scope + + def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None): """Resolve sensitive imported callables in the reference's binding scope.""" if not isinstance(node, ast.Name) or tree is None: @@ -19683,7 +19697,7 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None for candidate in ast.walk(tree): if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store): record_local( - python_sensitive_alias_scope(candidate, parents), + python_sensitive_alias_binding_scope(candidate, parents), candidate.id, ) elif isinstance(candidate, ast.arg): @@ -19757,7 +19771,7 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None for candidate in ast.walk(tree): if isinstance(candidate, ast.Name) and isinstance(candidate.ctx, ast.Store): - scope = python_sensitive_alias_scope(candidate, parents) + scope = python_sensitive_alias_binding_scope(candidate, parents) target = parents.get(candidate) if ( isinstance(target, ast.Assign) @@ -37573,3 +37587,23 @@ Ran 5 tests in 0.219s — OK ``` All examples were parsed and checked as AST data; no specimen, hook, environment value or source program was executed, compiled or passed through a symbol-table helper. No other scanner boundary or compiler-source allowlist changed. No 119-helper sweep, packet selector, full suite, Go/hosted CI or live operation was run; exact-head packet integration and independent reviews remain with the coordinator. + +#### Comprehension walrus hook stores use their containing scope from `734ef68b` + +At entry on 2026-10-04 the worktree was clean at `734ef68b6036f86f5a6ecc68014e9888e07de80f`. The prior focused hook and adjacent run passed five methods in 0.220s, but the coordinator reproduced four new AST-only accepts at that source: a direct local alias, a module-global alias stored through a list, a nonlocal alias stored through a list, and a nonlocal alias assigned in a nested comprehension before a later list-held call. Each source used a comprehension assignment expression to store an imported `sys.excepthook` alias; Python binds that named expression in the containing scope rather than the comprehension scope. These cases extend the imported-hook family linked to [Codex finding 4173966622](https://github.com/1XP-AI/gh-runnerd/pull/103#discussion_r4173966622); no separate external URL was provided for the comprehension variants. + +The four source strings were added to `test_excepthook_aliases_are_sensitive_error_sinks`; the direct and container-held forms exercise local, `global`, `nonlocal`, and nested-comprehension ownership. Before the correction, the focused selector failed four assertions in 0.206s. The scanner now maps a `NamedExpr` target out of all enclosing comprehension AST scopes to its containing Python binding scope before collecting local names and indexing stores. Existing `global` and nearest-actual-cell `nonlocal` ownership logic then handles those stores; unresolved ownership remains fail-closed. A safe local callback assigned through a comprehension walrus was added as a positive control and remains accepted. + +The final post-ledger changed-boundary command passed five methods in 0.231s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_displayhook_aliases_are_sensitive_output_sinks \ + Issue79RegressionTests.test_imported_exit_alias_and_system_exit_preserve_sensitive_taint \ + Issue79RegressionTests.test_output_sink_method_alias_keeps_sensitive_taint \ + Issue79RegressionTests.test_launcher_aliases_returned_by_local_helpers_are_rejected +Ran 5 tests in 0.231s — OK +``` + +The specimens remained inert source strings parsed by the reviewed scanner; no hook, environment value, specimen, or verifier/compiler recipe was executed. Only imported-hook binding-scope handling and its tests changed; Git/Popen, path, cwd, process-cleanup, compiler-source provenance, helper names and source allowlists are unchanged. No packet selector, 119-helper sweep, full suite, Go/hosted CI, or live operation was run; those checks and exact-head independent reviews remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 947c4257..44f0b2c5 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -4709,6 +4709,38 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' ' inner()\n' 'outer()\n', + 'import os\nfrom sys import excepthook as source\n' + 'def install():\n' + ' [emit := source for _ in (0,)]\n' + ' emit(RuntimeError, RuntimeError(os.environ), None)\n' + 'install()\n', + 'import os\nfrom sys import excepthook as source\n' + 'def install():\n' + ' global saved\n' + ' [saved := source for _ in (0,)]\n' + 'install()\n' + 'handlers = [saved]\n' + 'handlers[0](RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as source\n' + 'def outer():\n' + ' saved = None\n' + ' def install():\n' + ' nonlocal saved\n' + ' [saved := source for _ in (0,)]\n' + ' install()\n' + ' handlers = [saved]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + 'outer()\n', + 'import os\nfrom sys import excepthook as source\n' + 'def outer():\n' + ' saved = None\n' + ' def install():\n' + ' nonlocal saved\n' + ' [[saved := source for _ in (0,)] for __ in (0,)]\n' + ' install()\n' + ' handlers = [saved]\n' + ' handlers[0](RuntimeError, RuntimeError(os.environ), None)\n' + 'outer()\n', ) for body in unsafe: with self.subTest(body=body): @@ -4729,6 +4761,13 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'def local_callback(emit):\n' ' handlers = [emit]\n handlers[0]("offline fixture")\n' )) + self.assertIsNone(self.inspect( + 'def callback(*args):\n return None\n' + 'def install():\n' + ' [handler := callback for _ in (0,)]\n' + ' handler("offline fixture")\n' + 'install()\n' + )) def test_git_query_timeout_cleanup_must_remain_reachable(self) -> None: bounded_body, marker = self.packet_heredoc_containing( From e9ab4dc6b7ab6ef14f8dde5bc86d60d081cd04fa Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 14:49:37 +0900 Subject: [PATCH 074/123] Harden issue 79 scanner provenance --- docs/evidence/g01-recovery-packet.md | 129 +++++++++++++++++- .../issue79_regression_test.py | 91 ++++++++++++ 2 files changed, 214 insertions(+), 6 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index eb96f05e..85492bde 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -15799,6 +15799,25 @@ def reviewed_python_git_query_runtime_dependencies_stable(tree, parents): "time": {"monotonic", "sleep"}, } import_counts = {module: 0 for module in exported_members} + if any( + isinstance(node, ast.Name) and node.id == "__import__" + or isinstance(node, ast.Attribute) and node.attr == "_os" + or ( + isinstance(node, ast.ImportFrom) + and node.module == "tempfile" + and any(alias.name in {"_os", "*"} for alias in node.names) + ) + or ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and len(node.args) > 1 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value == "_os" + ) + for node in ast.walk(tree) + ): + return False for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: @@ -15814,6 +15833,60 @@ def reviewed_python_git_query_runtime_dependencies_stable(tree, parents): if any(count != 1 for count in import_counts.values()): return False + helper_names = { + "run_bounded_git_query", + "capture_git_query_output", + "terminate_git_query_group", + "close_git_query_streams", + "wait_for_git_query_group_exit", + "git_query_group_exists", + } + builder_names = {"git_query", "git_command"} + used_builders = { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in builder_names + } + stable_names = helper_names | used_builders + definitions = {name: 0 for name in stable_names} + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if node.name in stable_names: + if parents.get(node) is not tree or not isinstance( + node, (ast.FunctionDef, ast.AsyncFunctionDef) + ): + return False + definitions[node.name] += 1 + elif isinstance(node, ast.Name) and node.id in stable_names: + if isinstance(node.ctx, (ast.Store, ast.Del)): + return False + if isinstance(node.ctx, ast.Load): + parent = parents.get(node) + if not isinstance(parent, ast.Call) or parent.func is not node: + return False + elif isinstance(node, ast.arg) and node.arg in stable_names: + return False + elif isinstance(node, ast.Import): + if any( + (alias.asname or alias.name.split(".", 1)[0]) in stable_names + for alias in node.names + ): + return False + elif isinstance(node, ast.ImportFrom): + if any( + (alias.asname or alias.name) in stable_names + for alias in node.names + ): + return False + elif isinstance(node, (ast.Global, ast.Nonlocal)) and any( + name in stable_names for name in node.names + ): + return False + if any(count != 1 for count in definitions.values()): + return False + provider_names = set(exported_members) for node in ast.walk(tree): if isinstance(node, ast.Name) and node.id in provider_names: @@ -15942,6 +16015,15 @@ def python_working_directory_change_violation(tree, parents): and any(node.value.id in names for names in sensitive_modules.values()) ): return "Python heredoc accesses an unreviewed working-directory change API" + if ( + isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "__import__" + and node.value.args + and isinstance(node.value.args[0], ast.Constant) + and node.value.args[0].value in sensitive_modules + ): + return "Python heredoc accesses an unreviewed working-directory change API" if not isinstance(node, ast.Call): continue if isinstance(node.func, ast.Name) and ( @@ -16779,6 +16861,8 @@ def python_filesystem_mutator_alias_violation(tree, parents): for target, value in assignments: if not isinstance(target, ast.Name): continue + while isinstance(value, ast.NamedExpr): + value = value.value receiver = None method = None if ( @@ -19910,12 +19994,15 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None }[imported.module] ): return True - elif ( - kind == "assignment" - and isinstance(binding.value, ast.Name) - and resolves(binding.value, visited | {key}) - ): - return True + elif kind == "assignment": + assigned_value = binding.value + while isinstance(assigned_value, ast.NamedExpr): + assigned_value = assigned_value.value + if ( + isinstance(assigned_value, ast.Name) + and resolves(assigned_value, visited | {key}) + ): + return True return False return False @@ -37607,3 +37694,33 @@ Ran 5 tests in 0.231s — OK ``` The specimens remained inert source strings parsed by the reviewed scanner; no hook, environment value, specimen, or verifier/compiler recipe was executed. Only imported-hook binding-scope handling and its tests changed; Git/Popen, path, cwd, process-cleanup, compiler-source provenance, helper names and source allowlists are unchanged. No packet selector, 119-helper sweep, full suite, Go/hosted CI, or live operation was run; those checks and exact-head independent reviews remain with the coordinator. + +#### Five exact-head Codex P1s from `1fbd7a1f` + +The pushed `1fbd7a1fd4c186036f6d367f385cf0f2165c6e30` had completed GitHub Codex review on 2026-10-04; its five blocking findings are recorded together at [the review issue comment](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-5976946399). The coordinator independently reproduced all five as accepted inert AST mutations against that immutable source. This correction covers: `__import__("contextlib").chdir(...)` around a repository-relative read; a `sys.excepthook` alias passed through an assignment expression; an owned `Path.move_into` stored through a walrus and called with an unowned destination; replacement of `tempfile._os.read` with a synthetic reader yielding the expected origin bytes; and reassignment of `run_bounded_git_query` to a fabricated `CompletedProcess`. The Git-provider test also checks reassignment of the capture, termination, stream-close, group-wait, group-existence and `git_query` helpers so their reviewed names cannot stand in for stable definitions. + +Before scanner changes, the four relevant methods failed 11 assertions in 3.949s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_contextlib_chdir_is_rejected_through_aliases_and_containers \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 3.949s — FAILED (11 failures) +``` + +The scanner now checks literal dynamic-import receivers for `os` and `contextlib` cwd mutators, follows assignment-expression values when tracing imported sensitive callables, and unwraps walrus values while classifying extracted Path mutators. The bounded Git proof refuses dynamic imports and private `tempfile._os` re-exports/reflection in its verifier body, then requires one top-level definition and no competing binding for the runner, collector, cleanup helpers and the builder used by the origin query. It retains the direct canonical verifier, bounded collector, imported providers and cleanup control-flow checks; no source hash, marker exception, helper-name allowlist or compiler-input expansion was added. + +The same four focused methods passed after the correction in 3.904s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_contextlib_chdir_is_rejected_through_aliases_and_containers \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 3.904s — OK +``` + +The changed scanner/compiler body passed `test_current_scanner_compiler_body_remains_self_audit_compatible` in 43.062s, and the reviewed scanner namespace cwd positive passed `test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards` in 0.292s. The first combined invocation yielded before its completion output was captured; after it had exited, these two selectors were rerun individually to retain their reported results, so each was invoked twice. Existing `contextlib.nullcontext`, safe callback, temporary-owned Path mutation and bounded-and-compared Git-origin positives remained accepted within the focused methods. All injected source stayed inert AST data; only the trusted harness and reviewed scanner definitions executed. No packet-wide selector, full suite, 119-helper sweep, Go/hosted CI or live action was run; the coordinator owns final packet integration and the exact-head independent reviews. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 44f0b2c5..498f1db7 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3480,6 +3480,87 @@ def synthetic_read(_fd, _size): "rebinding the budget guard's builtin must not certify Git output", ) + reexported = ast.parse( + verification_body, filename="" + ) + reexport_query = next( + statement for statement in reexported.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + ) + reexport_patch = ast.parse( + f"synthetic_chunks = iter(({origin_bytes!r}, b''))\n" + "def synthetic_read(_fd, _size):\n" + " return next(synthetic_chunks, b'')\n" + "__import__('tempfile')._os.read = synthetic_read\n", + filename="", + ) + reexported.body[ + reexported.body.index(reexport_query):reexported.body.index(reexport_query) + ] = reexport_patch.body + ast.fix_missing_locations(reexported) + with self.subTest(mutated_provider="dynamic tempfile OS re-export"): + self.assertIsNotNone( + inspect_verification(ast.unparse(reexported), marker), # type: ignore[operator] + "a dynamic tempfile OS re-export must not supply the bounded Git pipe bytes", + ) + + for helper, replacement in ( + ( + "run_bounded_git_query", + f"run_bounded_git_query = lambda *args, **kwargs: " + f"subprocess.CompletedProcess([], 0, {origin_bytes!r}, b'')\n", + ), + ( + "capture_git_query_output", + "capture_git_query_output = lambda *args: (b'', b'')\n", + ), + ( + "terminate_git_query_group", + "terminate_git_query_group = lambda process: None\n", + ), + ( + "close_git_query_streams", + "close_git_query_streams = lambda process: None\n", + ), + ( + "wait_for_git_query_group_exit", + "wait_for_git_query_group_exit = lambda process, deadline: True\n", + ), + ( + "git_query_group_exists", + "git_query_group_exists = lambda process: False\n", + ), + ( + "git_query", + "git_query = lambda arguments: []\n", + ), + ): + with self.subTest(rebound_git_helper=helper): + mutated = ast.parse( + verification_body, filename="" + ) + origin_query = next( + statement for statement in mutated.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + ) + insertion = mutated.body.index(origin_query) + mutated.body[insertion:insertion] = ast.parse( + replacement, filename="" + ).body + ast.fix_missing_locations(mutated) + self.assertIsNotNone( + inspect_verification(ast.unparse(mutated), marker), # type: ignore[operator] + f"rebinding {helper} must not inherit the reviewed collector proof", + ) + @staticmethod def _verification_without_origin_comparison(body: str) -> str: mutated = ast.parse(body, filename="") @@ -4245,6 +4326,8 @@ def test_contextlib_chdir_is_rejected_through_aliases_and_containers(self) -> No 'from os import chdir as switch_root\n' 'with switch_root("synthetic-other-root"):\n' ' print(open("docs/backlog.json").read())\n', + 'with __import__("contextlib").chdir("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', ) for body in unsafe: with self.subTest(body=body): @@ -4551,6 +4634,9 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'sys.excepthook(RuntimeError, RuntimeError(os.environ), None)\n', 'import os\nfrom sys import excepthook as emit\n' 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as source\n' + 'emit = (tmp := source)\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', 'import os, sys\nemit = sys.excepthook\n' 'emit(RuntimeError, RuntimeError(os.environ), None)\n', 'import os, sys\nemit = getattr(sys, "excepthook")\n' @@ -4917,6 +5003,11 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' action(Path("synthetic-destination"))\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' move = (tmp := source.move_into)\n' + ' move(Path("/tmp/maintainer-owned-directory"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', 'from pathlib import Path\n' 'seen = {"node"}\n' From 74e865e5ed4c8096b4e0a83cb5897df11cb04310 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 14:56:30 +0900 Subject: [PATCH 075/123] Reject pattern captures of reviewed Git helpers --- docs/evidence/g01-recovery-packet.md | 28 +++++++++++++++ .../issue79_regression_test.py | 36 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 85492bde..758b8b30 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -15868,6 +15868,12 @@ def reviewed_python_git_query_runtime_dependencies_stable(tree, parents): return False elif isinstance(node, ast.arg) and node.arg in stable_names: return False + elif isinstance(node, ast.ExceptHandler) and node.name in stable_names: + return False + elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name in stable_names: + return False + elif isinstance(node, ast.MatchMapping) and node.rest in stable_names: + return False elif isinstance(node, ast.Import): if any( (alias.asname or alias.name.split(".", 1)[0]) in stable_names @@ -37724,3 +37730,25 @@ Ran 4 tests in 3.904s — OK ``` The changed scanner/compiler body passed `test_current_scanner_compiler_body_remains_self_audit_compatible` in 43.062s, and the reviewed scanner namespace cwd positive passed `test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards` in 0.292s. The first combined invocation yielded before its completion output was captured; after it had exited, these two selectors were rerun individually to retain their reported results, so each was invoked twice. Existing `contextlib.nullcontext`, safe callback, temporary-owned Path mutation and bounded-and-compared Git-origin positives remained accepted within the focused methods. All injected source stayed inert AST data; only the trusted harness and reviewed scanner definitions executed. No packet-wide selector, full suite, 119-helper sweep, Go/hosted CI or live action was run; the coordinator owns final packet integration and the exact-head independent reviews. + +#### Pattern-capture rebinding follow-up from `e9ab4dc6` + +The focused follow-up to `e9ab4dc6b7ab6ef14f8dde5bc86d60d081cd04fa` closed a remaining binding form in the same helper-rebinding family as the fifth finding in [the immutable Codex review comment](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-5976946399). The runtime-dependency stability check already rejected ordinary name stores, imports, function parameters, definitions and global/nonlocal declarations, but did not inspect pattern-capture binder fields. Inert AST mutations inserting `match 0: case run_bounded_git_query: pass` and a nested mapping capture of `run_bounded_git_query` immediately before the canonical origin query both left the verifier accepted on the parent source. + +Before the correction, this exact focused command failed both new subtests in 4.462s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 1 test in 4.462s — FAILED (failures=2) +``` + +The stability check now rejects stable helper or builder names captured by `ast.MatchAs`, `ast.MatchStar` or `ast.MatchMapping.rest`, and also rejects competing `ast.ExceptHandler.name` bindings. The same focused command passed after the change in 4.453s, including its unchanged canonical-verifier positive and the existing provider/helper replacement negatives. The quick reviewed-namespace positive also passed: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards +Ran 1 test in 0.292s — OK +``` + +These probes parsed and inspected source strings only; no verifier recipe, pattern specimen, Git query, hook, process or filesystem action was executed. No full suite, packet selector, compiler self-audit, 119-helper sweep, Go/hosted CI or live operation was repeated; exact-head packet integration and independent review remain pending with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 498f1db7..c5c1fb6e 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3561,6 +3561,42 @@ def synthetic_read(_fd, _size): f"rebinding {helper} must not inherit the reviewed collector proof", ) + for label, pattern_binding in ( + ( + "match capture", + "match 0:\n" + " case run_bounded_git_query:\n" + " pass\n", + ), + ( + "nested mapping capture", + 'match {"query": {"runner": 1}}:\n' + ' case {"query": {"runner": run_bounded_git_query}}:\n' + " pass\n", + ), + ): + with self.subTest(pattern_bound_git_helper=label): + mutated = ast.parse( + verification_body, filename="" + ) + origin_query = next( + statement for statement in mutated.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + ) + insertion = mutated.body.index(origin_query) + mutated.body[insertion:insertion] = ast.parse( + pattern_binding, filename="" + ).body + ast.fix_missing_locations(mutated) + self.assertIsNotNone( + inspect_verification(ast.unparse(mutated), marker), # type: ignore[operator] + f"pattern capture {label} must not replace the reviewed Git runner", + ) + @staticmethod def _verification_without_origin_comparison(body: str) -> str: mutated = ast.parse(body, filename="") From 1fde7b9ef333504885e31e173e6a4175c1c153db Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 15:29:16 +0900 Subject: [PATCH 076/123] Close wrapped Issue 79 provenance escapes --- docs/evidence/g01-recovery-packet.md | 70 +++++++++++++++++++ .../issue79_regression_test.py | 49 +++++++++++++ 2 files changed, 119 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 758b8b30..7107fff0 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -15850,6 +15850,24 @@ def reviewed_python_git_query_runtime_dependencies_stable(tree, parents): and node.func.id in builder_names } stable_names = helper_names | used_builders + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr == "__globals__": + return False + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + if node.func.id in {"globals", "locals"} or ( + node.func.id == "vars" and not node.args + ): + return False + if ( + ( + node.func.id == "getattr" + or python_assigned_callable_alias(node.func.id, "getattr", tree) + ) + and len(node.args) > 1 + and "__globals__" in python_static_string_values(node.args[1], tree) + ): + return False definitions = {name: 0 for name in stable_names} for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): @@ -15995,6 +16013,15 @@ def python_working_directory_change_violation(tree, parents): } cwd_methods = {"chdir", "fchdir"} for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "__import__" + and node.args + and isinstance(node.args[0], ast.Constant) + and node.args[0].value in sensitive_modules + ): + return "Python heredoc dynamically imports a working-directory module" if isinstance(node, ast.ImportFrom) and node.module in sensitive_modules: if any(alias.name == "*" or alias.name in cwd_methods for alias in node.names): return "Python heredoc imports an unreviewed working-directory change API" @@ -16897,6 +16924,23 @@ def python_filesystem_mutator_alias_violation(tree, parents): f"on line {node.lineno}" ) if receiver is None: + wrapped_mutators = [ + candidate for candidate in ast.walk(value) + if isinstance(candidate, ast.Attribute) + and candidate is not value + and candidate.attr in python_filesystem_mutating_methods + and not ( + candidate.attr == "copy" + and python_known_non_path_set_copy_receiver( + candidate.value, tree, parents + ) + ) + ] + if wrapped_mutators: + return ( + "Python heredoc wraps an extracted filesystem mutator " + f"in an unreviewed value on line {node.lineno}" + ) continue if method == "copy" and python_known_non_path_set_copy_receiver( receiver, tree, parents @@ -20009,6 +20053,13 @@ def python_sensitive_imported_function_alias(node, tree, parents=None, seen=None and resolves(assigned_value, visited | {key}) ): return True + if any( + isinstance(candidate, ast.Name) + and isinstance(candidate.ctx, ast.Load) + and resolves(candidate, visited | {key}) + for candidate in ast.walk(assigned_value) + ): + return True return False return False @@ -37752,3 +37803,22 @@ Ran 1 test in 0.292s — OK ``` These probes parsed and inspected source strings only; no verifier recipe, pattern specimen, Git query, hook, process or filesystem action was executed. No full suite, packet selector, compiler self-audit, 119-helper sweep, Go/hosted CI or live operation was repeated; exact-head packet integration and independent review remain pending with the coordinator. + +#### Four unique blocking variants from the 2026-10-04 Codex comment + +The completed Codex review on `74e865e5ed4c8096b4e0a83cb5897df11cb04310` recorded five entries in [one issue comment](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-5977232555); two entries repeated the same tuple-extracted hook case, so this ledger treats it once and records four unique variants. The coordinator reproduced the four as accepted inert AST mutations on that exact parent: a saved `__import__("contextlib")` module alias reaching `chdir`; an imported `sys.excepthook` passed through a tuple/subscript wrapper containing a walrus; a temporary-owned `Path.move_into` extracted through the same wrapper and called with an unowned destination; and a synthetic bounded-Git runner installed through a function `__globals__` mapping, directly or through `getattr`. + +Before the correction, the four changed-boundary methods failed five assertions in 5.265s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_contextlib_chdir_is_rejected_through_aliases_and_containers \ + Issue79RegressionTests.test_excepthook_aliases_are_sensitive_error_sinks \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 5.265s — FAILED (failures=5) +``` + +The scanner now fails closed on literal dynamic imports of `os` or `contextlib`, on unsupported wrapped expressions carrying an imported sensitive callable, and on wrapped extracted filesystem mutators while retaining the proven local set-copy case. The bounded Git dependency check rejects function-global namespace access, reflective `__globals__` access and namespace-inspection calls before relying on the reviewed helper definition. The same four methods passed in 5.180s after the change; their canonical-verifier and reviewed provider controls remained green, as did the safe `contextlib.nullcontext`, ordinary callback and temporary-owned Path controls in those methods. + +The focused probes used the same exact command shown above with the four selectors and `python3 -I -B`; no finding specimen or packet recipe was executed, compiled or evaluated, and no hook, environment value, path mutation, Git command or process was used. No full suite, packet selector, compiler self-audit, 119-helper sweep, Go/hosted CI or live operation was run for this delta; the parent `74e865e` results are not evidence for the new candidate, and the coordinator owns final packet integration and exact-head review. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index c5c1fb6e..c0be25eb 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -3597,6 +3597,44 @@ def synthetic_read(_fd, _size): f"pattern capture {label} must not replace the reviewed Git runner", ) + for label, namespace_write in ( + ( + "function globals attribute", + "synthetic_runner.__globals__['run_bounded_git_query'] = synthetic_runner\n", + ), + ( + "reflected function globals attribute", + "getattr(synthetic_runner, '__globals__')['run_bounded_git_query'] = synthetic_runner\n", + ), + ): + with self.subTest(git_helper_namespace_write=label): + mutated = ast.parse( + verification_body, filename="" + ) + origin_query = next( + statement for statement in mutated.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "origin_result" + for target in statement.targets + ) + ) + insertion = mutated.body.index(origin_query) + synthetic_runner = ( + "def synthetic_runner(*args, **kwargs):\n" + f" return subprocess.CompletedProcess([], 0, {origin_bytes!r}, b'')\n" + + namespace_write + ) + mutated.body[insertion:insertion] = ast.parse( + synthetic_runner, + filename="", + ).body + ast.fix_missing_locations(mutated) + self.assertIsNotNone( + inspect_verification(ast.unparse(mutated), marker), # type: ignore[operator] + f"{label} must not redirect the verified origin query", + ) + @staticmethod def _verification_without_origin_comparison(body: str) -> str: mutated = ast.parse(body, filename="") @@ -4364,6 +4402,9 @@ def test_contextlib_chdir_is_rejected_through_aliases_and_containers(self) -> No ' print(open("docs/backlog.json").read())\n', 'with __import__("contextlib").chdir("synthetic-other-root"):\n' ' print(open("docs/backlog.json").read())\n', + 'module = __import__("contextlib")\n' + 'with module.chdir("synthetic-other-root"):\n' + ' print(open("docs/backlog.json").read())\n', ) for body in unsafe: with self.subTest(body=body): @@ -4673,6 +4714,9 @@ def test_excepthook_aliases_are_sensitive_error_sinks(self) -> None: 'import os\nfrom sys import excepthook as source\n' 'emit = (tmp := source)\n' 'emit(RuntimeError, RuntimeError(os.environ), None)\n', + 'import os\nfrom sys import excepthook as source\n' + 'emit = ((tmp := source),)[0]\n' + 'emit(RuntimeError, RuntimeError(os.environ), None)\n', 'import os, sys\nemit = sys.excepthook\n' 'emit(RuntimeError, RuntimeError(os.environ), None)\n', 'import os, sys\nemit = getattr(sys, "excepthook")\n' @@ -5044,6 +5088,11 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' move(Path("/tmp/maintainer-owned-directory"))\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' move = ((tmp := source.move_into),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', 'from pathlib import Path\n' 'seen = {"node"}\n' From 513844225017403261b0aa2524837c079f7e8e81 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 15:47:21 +0900 Subject: [PATCH 077/123] Harden wrapped Path mutator provenance --- docs/evidence/g01-recovery-packet.md | 56 +++++++++++++++++-- .../issue79_regression_test.py | 15 +++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 7107fff0..982e630f 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16926,13 +16926,42 @@ def python_filesystem_mutator_alias_violation(tree, parents): if receiver is None: wrapped_mutators = [ candidate for candidate in ast.walk(value) - if isinstance(candidate, ast.Attribute) + if ( + ( + isinstance(candidate, ast.Attribute) + and candidate.attr in python_filesystem_mutating_methods + ) + or ( + isinstance(candidate, ast.Call) + and isinstance(candidate.func, ast.Name) + and candidate.func.id == "getattr" + and len(candidate.args) == 2 + and isinstance(candidate.args[1], ast.Constant) + and candidate.args[1].value + in python_filesystem_mutating_methods + ) + ) and candidate is not value - and candidate.attr in python_filesystem_mutating_methods and not ( - candidate.attr == "copy" - and python_known_non_path_set_copy_receiver( - candidate.value, tree, parents + isinstance(parents.get(candidate), ast.Call) + and parents[candidate].func is candidate + ) + and not ( + ( + isinstance(candidate, ast.Attribute) + and candidate.attr == "copy" + and python_known_non_path_set_copy_receiver( + candidate.value, tree, parents + ) + ) + or ( + isinstance(candidate, ast.Call) + and len(candidate.args) == 2 + and isinstance(candidate.args[1], ast.Constant) + and candidate.args[1].value == "copy" + and python_known_non_path_set_copy_receiver( + candidate.args[0], tree, parents + ) ) ) ] @@ -37822,3 +37851,20 @@ Ran 4 tests in 5.265s — FAILED (failures=5) The scanner now fails closed on literal dynamic imports of `os` or `contextlib`, on unsupported wrapped expressions carrying an imported sensitive callable, and on wrapped extracted filesystem mutators while retaining the proven local set-copy case. The bounded Git dependency check rejects function-global namespace access, reflective `__globals__` access and namespace-inspection calls before relying on the reviewed helper definition. The same four methods passed in 5.180s after the change; their canonical-verifier and reviewed provider controls remained green, as did the safe `contextlib.nullcontext`, ordinary callback and temporary-owned Path controls in those methods. The focused probes used the same exact command shown above with the four selectors and `python3 -I -B`; no finding specimen or packet recipe was executed, compiled or evaluated, and no hook, environment value, path mutation, Git command or process was used. No full suite, packet selector, compiler self-audit, 119-helper sweep, Go/hosted CI or live operation was run for this delta; the parent `74e865e` results are not evidence for the new candidate, and the coordinator owns final packet integration and exact-head review. + +#### Immediate-call receiver compatibility follow-up from `1fde7b9e` + +The coordinator reported the exact `1fde7b9ef333504885e31e173e6a4175c1c153db` packet selector failing one test in 54.076s with 24 diagnostics: the newly added wrapped-mutator fallback treated immediately called methods such as `token.replace(...)` as extracted filesystem mutators. A focused inert text case, `normalized = token.replace("\\", "/")`, reproduced this false positive on that parent; its single selected test failed in 0.166s. + +The fallback now ignores an attribute only when it is directly the `func` of its parent `ast.Call`, leaving that call to the existing receiver and destination checks. A follow-up security probe found that the same wrapper could hide `getattr(source, "move_into")`; the focused path-mutator selector failed on that inert source in 0.215s. The fallback now also recognizes a literal `getattr` call for a listed filesystem mutator when it is carried in an unreviewed wrapper, while still leaving an immediately invoked call to the existing call checks. This is a shape-specific provenance check, not a method-name exemption: direct and wrapped unowned Path mutators remain refused, and known local set-copy plus temp-owned direct mutation controls remain accepted. + +The initial focused string/path run passed in 0.213s before the reflective-`getattr` regression was added. After the follow-up RED and narrow correction, this exact focused command passed both the string compatibility control and the path mutator boundary in 0.219s. The path test keeps unowned direct `Path.replace`, tuple-wrapped `Path.move_into`, and wrapped reflective `getattr(source, "move_into")` refused while temporary-owned direct replace/move operations and literal set-copy remain accepted: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 2 tests in 0.219s — OK +``` + +The specimen remained source text for the scanner; no recipe or filesystem method was executed. The coordinator owns rerunning the final packet selector; no full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live action was repeated for this narrow correction. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index c0be25eb..a7395b4f 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5049,6 +5049,7 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'from pathlib import Path\nPath("synthetic-owned").copy(Path("synthetic-destination"))\n', 'from pathlib import Path\nPath("synthetic-owned").copy_into(Path("synthetic-destination"))\n', 'from pathlib import Path\nPath("synthetic-owned").move_into(Path("synthetic-destination"))\n', + 'from pathlib import Path\nPath("synthetic-owned").replace(Path("synthetic-destination"))\n', 'from pathlib import Path\nPath("synthetic-owned").lchmod(0o600)\n', 'from pathlib import Path\nseen = Path("synthetic-owned")\n' 'seen.copy(Path("synthetic-destination"))\n', @@ -5093,6 +5094,11 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' move(Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' move = ((tmp := getattr(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', 'from pathlib import Path\n' 'seen = {"node"}\n' @@ -5129,6 +5135,7 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' source.copy(copied)\n' ' source.copy_into(destination_dir)\n' ' source.move_into(destination_dir)\n' + ' source.replace(copied)\n' ' source.copy(target=copied)\n' ' action = source.move_into\n' ' action(destination_dir)\n' @@ -5140,6 +5147,14 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ) self.assertIsNone(self.inspect(safe)) + def test_string_replace_result_is_not_an_extracted_path_mutator(self) -> None: + body = ( + 'token = "docs\\\\evidence"\n' + 'normalized = token.replace("\\\\", "/")\n' + 'print(normalized)\n' + ) + self.assertIsNone(self.inspect(body)) + def test_standalone_git_queries_require_explicit_hook_isolation(self) -> None: unsafe = ( "git status --short", "git -P status --short", From 0cbe8207bd9333b181230ab6edc9da76fb4c61d0 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 16:01:22 +0900 Subject: [PATCH 078/123] Close reflective Path mutator aliases --- docs/evidence/g01-recovery-packet.md | 133 ++++++++++++------ .../issue79_regression_test.py | 42 ++++++ 2 files changed, 134 insertions(+), 41 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 982e630f..205fff6d 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16907,13 +16907,31 @@ def python_filesystem_mutator_alias_violation(tree, parents): 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 + and ( + value.func.id == "getattr" + or python_assigned_callable_alias( + value.func.id, "getattr", tree + ) + ) + and len(value.args) >= 2 ): - receiver = value.args[0] - method = value.args[1].value + possible_names = python_static_string_values(value.args[1], tree) + possible_methods = ( + possible_names & python_filesystem_mutating_methods + ) + if len(possible_methods) == 1 and possible_names == possible_methods: + receiver = value.args[0] + method = next(iter(possible_methods)) + elif possible_methods or ( + not possible_names + and python_path_receiver_expression( + value.args[0], tree, parents + ) + ): + return ( + "Python heredoc extracts an unresolved reflective filesystem " + f"mutator into alias {target.id!r} on line {node.lineno}" + ) elif ( isinstance(value, ast.Attribute) and python_dotted_name(value) in python_filesystem_mutating_functions @@ -16924,47 +16942,61 @@ def python_filesystem_mutator_alias_violation(tree, parents): f"on line {node.lineno}" ) if receiver is None: - wrapped_mutators = [ - candidate for candidate in ast.walk(value) + wrapped_mutators = [] + for candidate in ast.walk(value): + candidate_method = None + candidate_receiver = None if ( - ( - isinstance(candidate, ast.Attribute) - and candidate.attr in python_filesystem_mutating_methods + isinstance(candidate, ast.Attribute) + and candidate.attr in python_filesystem_mutating_methods + ): + candidate_method = candidate.attr + candidate_receiver = candidate.value + elif ( + isinstance(candidate, ast.Call) + and isinstance(candidate.func, ast.Name) + and ( + candidate.func.id == "getattr" + or python_assigned_callable_alias( + candidate.func.id, "getattr", tree + ) ) - or ( - isinstance(candidate, ast.Call) - and isinstance(candidate.func, ast.Name) - and candidate.func.id == "getattr" - and len(candidate.args) == 2 - and isinstance(candidate.args[1], ast.Constant) - and candidate.args[1].value - in python_filesystem_mutating_methods + and len(candidate.args) >= 2 + ): + possible_names = python_static_string_values( + candidate.args[1], tree ) - ) - and candidate is not value - and not ( - isinstance(parents.get(candidate), ast.Call) - and parents[candidate].func is candidate - ) - and not ( - ( - isinstance(candidate, ast.Attribute) - and candidate.attr == "copy" - and python_known_non_path_set_copy_receiver( - candidate.value, tree, parents - ) + possible_methods = ( + possible_names & python_filesystem_mutating_methods ) - or ( - isinstance(candidate, ast.Call) - and len(candidate.args) == 2 - and isinstance(candidate.args[1], ast.Constant) - and candidate.args[1].value == "copy" - and python_known_non_path_set_copy_receiver( - candidate.args[0], tree, parents + if not possible_names and python_path_receiver_expression( + candidate.args[0], tree, parents + ): + possible_methods = python_filesystem_mutating_methods + if possible_methods: + candidate_method = ( + next(iter(possible_methods)) + if len(possible_methods) == 1 + else None ) + candidate_receiver = candidate.args[0] + if candidate_method is None and candidate_receiver is None: + continue + if candidate is value: + continue + if ( + isinstance(parents.get(candidate), ast.Call) + and parents[candidate].func is candidate + ): + continue + if ( + candidate_method == "copy" + and python_known_non_path_set_copy_receiver( + candidate_receiver, tree, parents ) - ) - ] + ): + continue + wrapped_mutators.append(candidate) if wrapped_mutators: return ( "Python heredoc wraps an extracted filesystem mutator " @@ -37868,3 +37900,22 @@ Ran 2 tests in 0.219s — OK ``` The specimen remained source text for the scanner; no recipe or filesystem method was executed. The coordinator owns rerunning the final packet selector; no full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live action was repeated for this narrow correction. + +#### Reflective mutator alias follow-up from `51384422` + +The security review of exact parent `513844225017403261b0aa2524837c079f7e8e81` found that wrapping `getattr(source, "move_into")` was still accepted when the mutator name came from a local variable, the getter was assigned to an alias, or the name was statically concatenated. The focused path-mutator selector reproduced those three accepted inert AST forms as three failed refusal assertions in 0.230s. An unresolved method name on the proven Path receiver, a direct saved literal `getattr` method value, and an immediately invoked reflective method were refused and remain controls. + +Fixture review found an intermediate pair of source lines had been concatenated, making one combined negative specimen syntactically invalid; its earlier 2-test, 0.231s output is not counted as valid GREEN evidence. After separating the fixtures, a direct saved getter alias (`member = getattr; move = member(source, "move_into")`) produced one failed refusal assertion in 0.241s. The stable correction now routes direct getter aliases through the same receiver and destination ownership checks as the literal `getattr` form. + +The wrapped-value check now follows the existing local callable-alias and static-string resolvers for literal `getattr` calls. If the method name is unresolved but the receiver is a proven Path expression, it refuses the wrapped value; immediately invoked receiver calls remain delegated to the existing filesystem source/destination checks. No method-name, source-hash or recipe exception was added. + +The focused string and Path selectors passed together after the final correction in 0.239s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 2 tests in 0.239s — OK +``` + +The selector retains the benign `str.replace` transformation, literal local set-copy, temp-owned direct mutators, and the prior direct/wrapped unowned Path source or destination refusals. All new specimens remained inert source strings inspected by the trusted harness; no specimen, filesystem method or verifier recipe was executed. No packet selector, full suite, 119-helper sweep, compiler self-audit, Go/hosted CI or live action was run; final packet integration and exact-head reviews remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index a7395b4f..2bad6f86 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5099,6 +5099,48 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' move(Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' method = "move_into"\n' + ' move = ((tmp := getattr(source, method)),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' member = getattr\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' move = ((tmp := getattr(source, "move" + "_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' move = ((tmp := getattr(source, method_name)),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' move = getattr(source, "move_into")\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' getattr(source, "move_into")(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' member = getattr\n' + ' move = member(source, "move_into")\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' member = getattr\n' + ' member(source, "move_into")(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', 'from pathlib import Path\n' 'seen = {"node"}\n' From 2a2ee6c8ae397e2ce5a4703bca6f96d98ec8ca67 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 16:28:55 +0900 Subject: [PATCH 079/123] Reject escaped reflective Path mutators --- docs/evidence/g01-recovery-packet.md | 101 +++++++++++++++--- .../issue79_regression_test.py | 64 +++++++++++ 2 files changed, 153 insertions(+), 12 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 205fff6d..73b8e7e6 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16815,6 +16815,52 @@ def python_known_non_path_set_copy_receiver(node, tree, parents, active=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") + getattr_aliases = {"getattr"} + alias_edges = [] + 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 + if isinstance(value, ast.Name): + alias_edges.extend( + (target.id, value.id) + for target in targets + if isinstance(target, ast.Name) + ) + while True: + prior_count = len(getattr_aliases) + getattr_aliases.update( + target for target, source in alias_edges if source in getattr_aliases + ) + if len(getattr_aliases) == prior_count: + break + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Name) + and isinstance(node.ctx, ast.Load) + and node.id in getattr_aliases + ): + continue + parent = parents.get(node) + if isinstance(parent, ast.Call) and parent.func is node: + continue + if ( + isinstance(parent, ast.Assign) + and parent.value is node + and all(isinstance(target, ast.Name) for target in parent.targets) + ) or ( + isinstance(parent, ast.AnnAssign) + and parent.value is node + and isinstance(parent.target, ast.Name) + ): + continue + return "Python heredoc lets a reflective getter callable escape its direct call path" for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) @@ -16907,12 +16953,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): elif ( isinstance(value, ast.Call) and isinstance(value.func, ast.Name) - and ( - value.func.id == "getattr" - or python_assigned_callable_alias( - value.func.id, "getattr", tree - ) - ) + and value.func.id in getattr_aliases and len(value.args) >= 2 ): possible_names = python_static_string_values(value.args[1], tree) @@ -16944,6 +16985,20 @@ def python_filesystem_mutator_alias_violation(tree, parents): if receiver is None: wrapped_mutators = [] for candidate in ast.walk(value): + if isinstance(candidate, ast.Call): + for returned in python_local_call_return_values( + candidate, tree, parents + ): + if any( + isinstance(returned_node, ast.Call) + and isinstance(returned_node.func, ast.Name) + and returned_node.func.id in getattr_aliases + for returned_node in ast.walk(returned) + ): + return ( + "Python heredoc assigns an unreviewed reflective " + f"helper result to alias {target.id!r} on line {node.lineno}" + ) candidate_method = None candidate_receiver = None if ( @@ -16955,12 +17010,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): elif ( isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Name) - and ( - candidate.func.id == "getattr" - or python_assigned_callable_alias( - candidate.func.id, "getattr", tree - ) - ) + and candidate.func.id in getattr_aliases and len(candidate.args) >= 2 ): possible_names = python_static_string_values( @@ -37919,3 +37969,30 @@ Ran 2 tests in 0.239s — OK ``` The selector retains the benign `str.replace` transformation, literal local set-copy, temp-owned direct mutators, and the prior direct/wrapped unowned Path source or destination refusals. All new specimens remained inert source strings inspected by the trusted harness; no specimen, filesystem method or verifier recipe was executed. No packet selector, full suite, 119-helper sweep, compiler self-audit, Go/hosted CI or live action was run; final packet integration and exact-head reviews remain with the coordinator. + +#### Reflective getter escape through holders and helper results from `0cbe8207` + +The focused security follow-up against clean parent `0cbe8207bd9333b181230ab6edc9da76fb4c61d0` found that reflective getter authority could escape into dictionaries, tuples, lists, conditional or assignment-expression values, callbacks and helper results before extracting a `Path` mutator. The accepted forms included `getters = {"get": getattr}; member = getters["get"]; move = ((tmp := member(source, "move_into")),)[0]` and a helper returning `getattr(receiver, name)`; the extracted method was then called with a synthetic unowned destination. A direct reflective call through a container and a conditional getter alias were also accepted. These were reported in the scoped security review; no separate public finding URL was supplied. + +Before the correction, the focused mutator method failed eight refusal assertions in 0.272s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.272s — FAILED (failures=8) +``` + +The scanner now follows only simple local name aliases of `getattr`, requires getter names to be consumed directly as call targets or simple name assignments, and rejects getter values passed through unsupported holders, conditionals, named expressions and callbacks. Direct calls through a simple getter alias still use the existing static member-name and temporary-source/destination checks. Helper return values containing reflective getter calls are refused when assigned as mutator candidates; no general expression evaluator or method-name exception was added. A list-held getter case was added after the RED run and is covered by the final focused pass. + +The final changed-boundary command passed four methods in 5.531s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 5.531s — OK +``` + +The controls retain benign string replacement, literal local set-copy, simple direct getter aliasing of a temporary-owned mutator, canonical reviewed namespace acceptance and the bounded Git verifier. The synthetic source remained inert AST input; no specimen, filesystem method or verifier recipe was executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live action was run; final packet integration and exact-head independent review remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 2bad6f86..70014513 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5141,6 +5141,67 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' member(source, "move_into")(Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' getters = {"get": getattr}\n' + ' member = getters["get"]\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' members = {"member": getattr}\n' + ' move = ((tmp := members["member"](source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' member = getattr if True else None\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' member = (saved := getattr)\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' getters = (getattr,)\n' + ' member = getters[0]\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' getters = [getattr]\n' + ' member = getters[0]\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' def get_member():\n' + ' return getattr\n' + ' member = get_member()\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' def invoke_member(callback, receiver, name):\n' + ' return callback(receiver, name)\n' + ' move = ((tmp := invoke_member(getattr, source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td) / "source"\n' + ' def member(receiver, name):\n' + ' return getattr(receiver, name)\n' + ' move = ((tmp := member(source, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', 'from pathlib import Path\n' 'seen = {"node"}\n' @@ -5182,6 +5243,9 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' action = source.move_into\n' ' action(destination_dir)\n' ' source.lchmod(0o600)\n' + ' member = getattr\n' + ' reflected_move = member(source, "move_into")\n' + ' reflected_move(destination_dir)\n' 'seen = {"ast-node"}\n' 'seen_copy = seen.copy()\n' 'seen_alias = seen\n' From cc4df01f377c9d6503356acf1bf41f70fb130728 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sun, 4 Oct 2026 16:50:00 +0900 Subject: [PATCH 080/123] Reject unresolved reflective mutator aliases --- docs/evidence/g01-recovery-packet.md | 59 +++++++++++++++++++ .../issue79_regression_test.py | 35 +++++++++++ 2 files changed, 94 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 73b8e7e6..f295c8b5 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16982,6 +16982,38 @@ def python_filesystem_mutator_alias_violation(tree, parents): f"{python_dotted_name(value)!r} into alias {target.id!r} " f"on line {node.lineno}" ) + unresolved_reflective_results = [ + candidate + for candidate in ast.walk(value) + if ( + isinstance(candidate, ast.Call) + and isinstance(candidate.func, ast.Name) + and candidate.func.id in getattr_aliases + and len(candidate.args) >= 2 + and not python_static_string_values(candidate.args[1], tree) + and not ( + isinstance(parents.get(candidate), ast.Call) + and parents[candidate].func is candidate + ) + ) + ] + target_scope = python_enclosing_scope(node, parents) + reflective_result_is_called = any( + isinstance(candidate, ast.Name) + and candidate.id == target.id + and isinstance(candidate.ctx, ast.Load) + and isinstance(parents.get(candidate), ast.Call) + and parents[candidate].func is candidate + and target_scope in python_lexical_scope_chain( + python_enclosing_scope(candidate, parents), parents + ) + for candidate in ast.walk(tree) + ) + if unresolved_reflective_results and reflective_result_is_called: + return ( + "Python heredoc stores an unresolved reflective result " + f"in alias {target.id!r} on line {node.lineno}" + ) if receiver is None: wrapped_mutators = [] for candidate in ast.walk(value): @@ -37996,3 +38028,30 @@ Ran 4 tests in 5.531s — OK ``` The controls retain benign string replacement, literal local set-copy, simple direct getter aliasing of a temporary-owned mutator, canonical reviewed namespace acceptance and the bounded Git verifier. The synthetic source remained inert AST input; no specimen, filesystem method or verifier recipe was executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live action was run; final packet integration and exact-head independent review remain with the coordinator. + +#### Function-parameter reflective mutator extraction from `2a2ee6c8` + +The security retry against immutable parent `2a2ee6c8ae397e2ce5a4703bca6f96d98ec8ca67` confirmed that a function could save `getattr(receiver, attribute)` through a wrapped assignment and call the resulting alias with an unowned destination. The receiver and member are parameters, so the existing direct reflective-member check treated the unresolved member as no filesystem mutation; passing a `TemporaryDirectory`-owned `Path` and the literal `"move_into"` at the call site did not close the unresolved extraction. A simple `getattr(receiver, "move_into")` with an untyped receiver parameter was already refused by the existing source-ownership check; that control was retained. The scoped security report did not provide a separate public finding URL. + +Three new inert AST cases were added for the dynamic direct getter, an assigned `getattr` alias, and a default member name with keyword receiver forwarding. Two adjacent static-name cases—direct `getattr` and a simple getter alias on an untyped receiver parameter—exercise the existing source-ownership refusal and passed on the parent. Before the fix, the focused path-mutator selector failed all three new dynamic-member refusal assertions in 0.285s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.285s — FAILED (failures=3) +``` + +The first guard attempt rejected any unresolved reflective result stored in an alias, but the canonical Git collector positive then failed because it stores an unresolved-name stream handle for later `.read()` calls; that four-method run failed one assertion in 0.717s. The final guard refuses the unresolved reflective result when the stored alias is used as a call target in a visible lexical scope, while leaving non-callable stream handles and statically named reflective methods to their existing checks. This keeps the correction bounded to extracted call authority rather than treating every dynamic attribute read as a filesystem mutator. + +After narrowing, the four changed-boundary and compatibility selectors passed in 6.526s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 6.526s — OK +``` + +The Path selector retains simple direct getter aliases over temporary-owned paths, direct mutator source/destination checks, local set-copy and the existing helper/holder refusals; string replacement, reviewed scanner namespace and canonical Git capture positives also remain accepted. All inputs were inert source strings inspected as AST data; no specimen, filesystem method, hook, verifier recipe or compiler recipe was executed. No full suite, packet selector, 119-helper sweep, slow compiler self-audit, Go/hosted CI or live operation was run; final packet integration and exact-head independent review remain with the coordinator. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 70014513..b478c37c 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5201,6 +5201,41 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' move = ((tmp := member(source, "move_into")),)[0]\n' ' move(Path("synthetic-unowned-destination"))\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver):\n' + ' move = ((tmp := getattr(receiver, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver):\n' + ' member = getattr\n' + ' move = ((tmp := member(receiver, "move_into")),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' move = ((tmp := getattr(receiver, attribute)),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td)\n' + ' relocate(source, "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' member = getattr\n' + ' move = ((tmp := member(receiver, attribute)),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td)\n' + ' relocate(source, "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute="move_into"):\n' + ' move = ((tmp := getattr(receiver, attribute)),)[0]\n' + ' move(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' source = Path(td)\n' + ' relocate(receiver=source)\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', 'from pathlib import Path\n' From 3ad98c59f8b14d10d11c4c86e0f3ccc21265c68c Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 13 Oct 2026 10:28:20 +0900 Subject: [PATCH 081/123] Harden reflective path mutator tracking --- docs/evidence/g01-recovery-packet.md | 324 ++++++++++++++++++ .../issue79_regression_test.py | 102 ++++++ 2 files changed, 426 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index f295c8b5..850c6608 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16929,6 +16929,303 @@ def python_filesystem_mutator_alias_violation(tree, parents): "Python heredoc stores or passes an unowned filesystem mutator " f"{python_dotted_name(node)!r} on line {node.lineno}" ) + def unresolved_reflective_call(node): + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in getattr_aliases + and len(node.args) >= 2 + and not reflective_member_name_is_closed(node.args[1]) + ) + + def unresolved_reflective_result(node): + return unresolved_reflective_call(node) and not ( + isinstance(parents.get(node), ast.Call) + and parents[node].func is node + ) + + assignment_records = [] + 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 + elif isinstance(candidate, (ast.For, ast.AsyncFor)): + targets, value = [candidate.target], candidate.iter + elif isinstance(candidate, ast.comprehension): + targets, value = [candidate.target], candidate.iter + else: + continue + binding_scope = python_enclosing_scope(candidate, parents) + for target in targets: + assignment_records.extend( + (binding_scope, stored_name.id, value) + for stored_name in ast.walk(target) + if isinstance(stored_name, ast.Name) + and isinstance(stored_name.ctx, ast.Store) + ) + if isinstance(target, ast.Subscript) and isinstance( + target.value, ast.Name + ): + assignment_records.append( + (binding_scope, target.value.id, value) + ) + + def reflective_member_name_is_closed(node, seen=None): + if seen is None: + seen = set() + if isinstance(node, ast.Constant): + return isinstance(node.value, str) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return reflective_member_name_is_closed( + node.left, set(seen) + ) and reflective_member_name_is_closed(node.right, set(seen)) + if not isinstance(node, ast.Name) or node.id in seen: + return False + seen.add(node.id) + for scope in python_lexical_scope_chain( + python_enclosing_scope(node, parents), parents + ): + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + arguments = scope.args + parameters = ( + list(arguments.posonlyargs) + + list(arguments.args) + + list(arguments.kwonlyargs) + ) + if any(parameter.arg == node.id for parameter in parameters): + return False + if any( + node.id in declaration.names + for declaration in ast.walk(scope) + if isinstance(declaration, (ast.Global, ast.Nonlocal)) + and python_enclosing_scope(declaration, parents) is scope + ): + return False + values = [ + value + for binding_scope, name, value in assignment_records + if binding_scope is scope and name == node.id + ] + if values: + return all( + reflective_member_name_is_closed(value, set(seen)) + for value in values + ) and bool(python_static_string_values(node, tree)) + for candidate in ast.walk(scope): + if isinstance(candidate, (ast.Import, ast.ImportFrom)) and ( + python_enclosing_scope(candidate, parents) is scope + ): + imported_names = ( + [ + alias.asname or alias.name.split(".")[0] + for alias in candidate.names + ] + if isinstance(candidate, ast.Import) + else [alias.asname or alias.name for alias in candidate.names] + ) + if node.id in imported_names: + return False + if isinstance(candidate, ast.ExceptHandler) and ( + candidate.name == node.id + and python_enclosing_scope(candidate, parents) is scope + ): + return False + if isinstance( + candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ) and candidate.name == node.id and ( + python_enclosing_scope(parents.get(candidate), parents) is scope + ): + return False + return False + + reflective_bindings = set() + + def reflective_name_is_bound(node): + if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load): + return False + use_scope = python_enclosing_scope(node, parents) + return any( + (scope, node.id) in reflective_bindings + for scope in python_lexical_scope_chain(use_scope, parents) + ) + + def reflective_value_flows(node): + if unresolved_reflective_result(node): + return True + if isinstance(node, ast.Name): + return reflective_name_is_bound(node) + if isinstance(node, ast.NamedExpr): + return reflective_value_flows(node.value) + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + return any(reflective_value_flows(item) for item in node.elts) + if isinstance(node, ast.Dict): + return any( + reflective_value_flows(item) + for item in [*node.keys, *node.values] + if item is not None + ) + if isinstance(node, ast.Subscript): + return reflective_value_flows(node.value) + if isinstance(node, ast.IfExp): + return reflective_value_flows(node.body) or reflective_value_flows(node.orelse) + if isinstance(node, ast.BoolOp): + return any(reflective_value_flows(item) for item in node.values) + if isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp)): + return reflective_value_flows(node.elt) or any( + reflective_value_flows(generator.iter) + for generator in node.generators + ) + if isinstance(node, ast.DictComp): + return ( + reflective_value_flows(node.key) + or reflective_value_flows(node.value) + or any( + reflective_value_flows(generator.iter) + for generator in node.generators + ) + ) + if isinstance(node, ast.Attribute) and node.attr == "__call__": + return reflective_value_flows(node.value) + return False + + for _ in range(len(assignment_records) + 1): + changed = False + for scope, name, value in assignment_records: + binding = (scope, name) + if binding not in reflective_bindings and reflective_value_flows(value): + reflective_bindings.add(binding) + changed = True + holder_methods = { + "add", "append", "extend", "insert", "setdefault", "update" + } + for candidate in ast.walk(tree): + if not ( + isinstance(candidate, ast.Call) + and isinstance(candidate.func, ast.Attribute) + and candidate.func.attr in holder_methods + and isinstance(candidate.func.value, ast.Name) + and any(reflective_value_flows(argument) for argument in candidate.args) + ): + continue + binding = ( + python_enclosing_scope(candidate, parents), + candidate.func.value.id, + ) + if binding not in reflective_bindings: + reflective_bindings.add(binding) + changed = True + if not changed: + break + + def reflective_callable_target(node): + if isinstance(node, ast.Call): + return unresolved_reflective_result(node) + if isinstance(node, ast.Name): + return reflective_name_is_bound(node) + if isinstance(node, ast.Subscript): + return reflective_callable_target(node.value) or reflective_value_flows( + node.value + ) + if isinstance(node, ast.Attribute) and node.attr == "__call__": + return reflective_callable_target(node.value) + return False + + for candidate in ast.walk(tree): + if not isinstance(candidate, ast.Call): + continue + if unresolved_reflective_call(candidate.func) or reflective_callable_target( + candidate.func + ): + return "Python heredoc calls an unresolved reflective mutator result" + + def callable_parameter_names(function): + parameters = { + parameter.arg + for parameter in ( + list(function.args.posonlyargs) + + list(function.args.args) + + list(function.args.kwonlyargs) + ) + } + called = set() + for candidate in ast.walk(function): + if not ( + isinstance(candidate, ast.Name) + and candidate.id in parameters + and isinstance(candidate.ctx, ast.Load) + and python_enclosing_scope(candidate, parents) is function + ): + continue + parent = parents.get(candidate) + if isinstance(parent, ast.Call) and parent.func is candidate: + called.add(candidate.id) + elif ( + isinstance(parent, ast.Attribute) + and parent.attr == "__call__" + and isinstance(parents.get(parent), ast.Call) + and parents[parent].func is parent + ): + called.add(candidate.id) + return called + + for candidate in ast.walk(tree): + if not isinstance(candidate, ast.Call): + continue + if isinstance(candidate.func, ast.Name): + functions = python_local_function_candidates( + candidate.func.id, candidate, tree, parents + ) + else: + functions = [] + call_scope = python_enclosing_scope(candidate, parents) + for function in functions: + called_parameters = callable_parameter_names(function) + if not called_parameters: + continue + positional = list(function.args.posonlyargs) + list(function.args.args) + for index, argument in enumerate(candidate.args): + if isinstance(argument, ast.Starred): + if reflective_value_flows(argument.value) and any( + parameter.arg in called_parameters + for parameter in positional[index:] + ): + return "Python heredoc passes a reflective result to a local callback" + elif ( + index < len(positional) + and positional[index].arg in called_parameters + and reflective_value_flows(argument) + ): + return "Python heredoc passes a reflective result to a local callback" + named_parameters = { + parameter.arg + for parameter in positional + list(function.args.kwonlyargs) + } + for keyword in candidate.keywords: + if ( + keyword.arg in called_parameters + and reflective_value_flows(keyword.value) + ): + return "Python heredoc passes a reflective result to a local callback" + if keyword.arg is None: + if isinstance(keyword.value, ast.Dict): + if any( + isinstance(key, ast.Constant) + and key.value in called_parameters + and reflective_value_flows(value) + for key, value in zip( + keyword.value.keys, keyword.value.values + ) + ): + return "Python heredoc passes a reflective result to a local callback" + elif reflective_value_flows(keyword.value) and ( + called_parameters & named_parameters + ): + return "Python heredoc passes a reflective result to a local callback" + for node in ast.walk(tree): assignments = [] if isinstance(node, ast.Assign): @@ -38055,3 +38352,30 @@ Ran 4 tests in 6.526s — OK ``` The Path selector retains simple direct getter aliases over temporary-owned paths, direct mutator source/destination checks, local set-copy and the existing helper/holder refusals; string replacement, reviewed scanner namespace and canonical Git capture positives also remain accepted. All inputs were inert source strings inspected as AST data; no specimen, filesystem method, hook, verifier recipe or compiler recipe was executed. No full suite, packet selector, 119-helper sweep, slow compiler self-audit, Go/hosted CI or live operation was run; final packet integration and exact-head independent review remain with the coordinator. + +#### Secondary reflective-result transfers from immutable `cc4df01f` + +The coordinator's focused review of exact parent `cc4df01f377c9d6503356acf1bf41f70fb130728` found the same unresolved reflective-mutator result could escape a direct local assignment through a second alias, a list/dictionary/tuple, container `append` or subscript storage, a loop or comprehension, `.__call__`, or a local callback. Two additional accepted cases showed that a module-level literal such as `attribute = "read_text"` must not override a same-named function parameter supplied as `"move_into"`, and that a local `name = attribute` binding must not borrow a module-level literal. A pinned AST-only probe against that parent reported those cases accepted while its direct computed-call, unpack, immediate-alias and helper-return controls were refused; the canonical Git stream collector, benign string replacement and temporary-owned Path controls remained accepted. + +The focused path-mutator method first failed three refusal assertions in 0.285s for the direct dynamic getter, aliased getter and default/keyword receiver cases. After adding a second alias, holder, `.__call__` and callback variants, the same method failed four assertions in 0.329s. The coordinator's pinned parent probe then confirmed acceptance of the broader container/loop/comprehension and member-name shadow cases; after those were added, the focused method failed seven more refusal assertions in 0.355s for append/subscript storage, loop/comprehension extraction, direct reflective `.__call__`, and the two shadowed-name cases. The exact command for that expanded RED was: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.355s — FAILED (failures=7) +``` + +The scanner now treats a reflected member name as closed only when a literal or same-scope literal assignment chain proves it; parameters, imports, declaration overrides and unsupported bindings remain unresolved even when a same-named outer literal exists. It propagates unresolved callable provenance through name aliases, container literals and stores, common container insertions, loop/comprehension values, `.__call__` and local callback arguments, then refuses a call through that value. It does not refuse every stored `getattr` result: the canonical collector's stored process-stream handle is retained through its existing read/register/close shape proof, not by variable name or source marker. + +After the correction, the path-mutator method passed in 0.348s. The changed-boundary and compatibility selectors then passed together in 6.812s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 6.812s — OK +``` + +The focused mutator selector retains direct and wrapped unowned Path source/destination refusals, container and callback refusal cases, and accepted temporary-owned direct/reflected mutations, local set-copy and a locally constant reflected method name. The separate selectors retain the benign `str.replace` result, canonical scanner namespace and canonical bounded Git stream control. All new examples remained inert AST strings; no specimen, filesystem method, verifier recipe, environment value, Git action or live operation was executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run; final packet integration remains with the coordinator, and the exact parent results are not evidence for this candidate. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index b478c37c..c3e85bb5 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5236,6 +5236,105 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' source = Path(td)\n' ' relocate(receiver=source)\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' move = ((tmp := getattr(receiver, attribute)),)[0]\n' + ' action = move\n' + ' action(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = [getattr(receiver, attribute)]\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = {"move": getattr(receiver, attribute)}\n' + ' actions["move"](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = (getattr(receiver, attribute),)\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = []\n' + ' actions.append(getattr(receiver, attribute))\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = {}\n' + ' actions["move"] = getattr(receiver, attribute)\n' + ' actions["move"](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' for action in [getattr(receiver, attribute)]:\n' + ' action(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = [getattr(receiver, attribute) for item in [None]]\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' action = getattr(receiver, attribute)\n' + ' action.__call__(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' getattr(receiver, attribute).__call__(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'attribute = "read_text"\n' + 'def relocate(receiver, attribute):\n' + ' actions = [getattr(receiver, attribute)]\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'name = "read_text"\n' + 'def relocate(receiver, attribute):\n' + ' name = attribute\n' + ' actions = [getattr(receiver, name)]\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' action = ((tmp := getattr(receiver, attribute)),)[0]\n' + ' action.__call__(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def invoke(callback, destination):\n' + ' callback(destination)\n' + 'def relocate(receiver, attribute):\n' + ' action = ((tmp := getattr(receiver, attribute)),)[0]\n' + ' invoke(action, Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def member(receiver, attribute):\n' + ' return getattr(receiver, attribute)\n' + 'def relocate(receiver, attribute):\n' + ' action = ((tmp := member(receiver, attribute)),)[0]\n' + ' action(Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'with TemporaryDirectory() as td:\n' ' Path(td).joinpath("source").copy(**{"target": Path("synthetic-destination")})\n', 'from pathlib import Path\n' @@ -5281,6 +5380,9 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' member = getattr\n' ' reflected_move = member(source, "move_into")\n' ' reflected_move(destination_dir)\n' + ' method_name = "move_into"\n' + ' named_move = getattr(source, method_name)\n' + ' named_move(destination_dir)\n' 'seen = {"ast-node"}\n' 'seen_copy = seen.copy()\n' 'seen_alias = seen\n' From 4c83bd3d060967efe7349b1a7e6e75b8406badc5 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 13 Oct 2026 10:32:35 +0900 Subject: [PATCH 082/123] Track reflective mutators through callback and holders --- docs/evidence/g01-recovery-packet.md | 97 +++++++++++++++++-- .../issue79_regression_test.py | 21 ++++ 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 850c6608..eba14621 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -17090,6 +17090,18 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) if isinstance(node, ast.Attribute) and node.attr == "__call__": return reflective_value_flows(node.value) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in {"dict", "frozenset", "list", "set", "tuple"} + ): + return any( + reflective_value_flows(value) + for value in [ + *node.args, + *(keyword.value for keyword in node.keywords), + ] + ) return False for _ in range(len(assignment_records) + 1): @@ -17108,7 +17120,13 @@ def python_filesystem_mutator_alias_violation(tree, parents): and isinstance(candidate.func, ast.Attribute) and candidate.func.attr in holder_methods and isinstance(candidate.func.value, ast.Name) - and any(reflective_value_flows(argument) for argument in candidate.args) + and any( + reflective_value_flows(value) + for value in [ + *candidate.args, + *(keyword.value for keyword in candidate.keywords), + ] + ) ): continue binding = ( @@ -17151,26 +17169,66 @@ def python_filesystem_mutator_alias_violation(tree, parents): + list(function.args.kwonlyargs) ) } + alias_origins = {parameter: {parameter} for parameter in parameters} + function_nodes = [ + candidate + for candidate in ast.walk(function) + if python_enclosing_scope(candidate, parents) is function + ] + for _ in range(len(function_nodes) + 1): + changed = False + for candidate in function_nodes: + if isinstance(candidate, ast.Assign): + targets, value = candidate.targets, candidate.value + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + if candidate.value is None: + continue + targets, value = [candidate.target], candidate.value + else: + continue + origins = set().union(*( + alias_origins.get(source.id, set()) + for source in ast.walk(value) + if isinstance(source, ast.Name) + and isinstance(source.ctx, ast.Load) + and python_enclosing_scope(source, parents) is function + )) + if not origins: + continue + for target in targets: + if not isinstance(target, ast.Name): + continue + stored_origins = alias_origins.setdefault(target.id, set()) + prior_count = len(stored_origins) + stored_origins.update(origins) + changed |= len(stored_origins) != prior_count + if not changed: + break called = set() - for candidate in ast.walk(function): + for candidate in function_nodes: if not ( isinstance(candidate, ast.Name) - and candidate.id in parameters + and candidate.id in alias_origins and isinstance(candidate.ctx, ast.Load) - and python_enclosing_scope(candidate, parents) is function ): continue parent = parents.get(candidate) if isinstance(parent, ast.Call) and parent.func is candidate: - called.add(candidate.id) + called.update(alias_origins[candidate.id]) elif ( isinstance(parent, ast.Attribute) and parent.attr == "__call__" and isinstance(parents.get(parent), ast.Call) and parents[parent].func is parent ): - called.add(candidate.id) - return called + called.update(alias_origins[candidate.id]) + elif ( + isinstance(parent, ast.Subscript) + and isinstance(parents.get(parent), ast.Call) + and parents[parent].func is parent + ): + called.update(alias_origins[candidate.id]) + return called & parameters for candidate in ast.walk(tree): if not isinstance(candidate, ast.Call): @@ -38379,3 +38437,28 @@ Ran 4 tests in 6.812s — OK ``` The focused mutator selector retains direct and wrapped unowned Path source/destination refusals, container and callback refusal cases, and accepted temporary-owned direct/reflected mutations, local set-copy and a locally constant reflected method name. The separate selectors retain the benign `str.replace` result, canonical scanner namespace and canonical bounded Git stream control. All new examples remained inert AST strings; no specimen, filesystem method, verifier recipe, environment value, Git action or live operation was executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run; final packet integration remains with the coordinator, and the exact parent results are not evidence for this candidate. + +#### Secondary callback and collection transfers from immutable `3ad98c59` + +The coordinator's AST-only audit of exact parent `3ad98c59f8b14d10d11c4c86e0f3ccc21265c68c` found three remaining accepted transfers of an unresolved reflective mutator: a local callback parameter was copied to an alias before invocation; a dictionary holder received the value through `update` keyword arguments; and a list constructor wrapped a list containing the reflective value before it was called by subscript. These three inert forms were added to the existing path-mutator regression method; the untouched scanner at parent `3ad98c59` then failed exactly those three refusal assertions in 0.380s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.380s — FAILED (failures=3) +``` + +The bounded correction propagates reflective callback provenance through same-function simple aliases and recognizes an alias used through a subscript call target. It also carries unresolved reflective values through the selected collection constructors and through positional or keyword values supplied to the existing holder-insertion methods. This extends only the existing conservative flow rule; it does not add a source, method-name, variable-name or recipe exception. + +After the correction, the path-mutator method passed in 0.423s. The changed-boundary and adjacent compatibility command passed four methods in 7.134s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 7.134s — OK +``` + +These selectors preserve the existing direct and wrapped Path ownership refusals, temporary-owned mutation controls, benign string replacement, canonical scanner namespace and bounded Git collector. The three added snippets were parsed only as inert AST strings; no specimen, filesystem method or verifier recipe ran. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run. This closes the three reproduced transfer forms only; arbitrary unmodeled helper or container semantics still require their own evidence and review. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index c3e85bb5..99248921 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5327,6 +5327,27 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'with TemporaryDirectory() as td:\n' ' relocate(Path(td), "move_into")\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' def invoke(callback):\n' + ' action = callback\n' + ' action(Path("synthetic-unowned-destination"))\n' + ' invoke(getattr(receiver, attribute))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = {}\n' + ' actions.update(move=getattr(receiver, attribute))\n' + ' actions["move"](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = list([getattr(receiver, attribute)])\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'def member(receiver, attribute):\n' ' return getattr(receiver, attribute)\n' 'def relocate(receiver, attribute):\n' From 30c9aa7588e717990739c687372d3512553e1cba Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 13 Oct 2026 10:34:42 +0900 Subject: [PATCH 083/123] Clarify reflective transfer RED attribution --- docs/evidence/g01-recovery-packet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index eba14621..a71faf4d 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -38415,7 +38415,7 @@ The Path selector retains simple direct getter aliases over temporary-owned path The coordinator's focused review of exact parent `cc4df01f377c9d6503356acf1bf41f70fb130728` found the same unresolved reflective-mutator result could escape a direct local assignment through a second alias, a list/dictionary/tuple, container `append` or subscript storage, a loop or comprehension, `.__call__`, or a local callback. Two additional accepted cases showed that a module-level literal such as `attribute = "read_text"` must not override a same-named function parameter supplied as `"move_into"`, and that a local `name = attribute` binding must not borrow a module-level literal. A pinned AST-only probe against that parent reported those cases accepted while its direct computed-call, unpack, immediate-alias and helper-return controls were refused; the canonical Git stream collector, benign string replacement and temporary-owned Path controls remained accepted. -The focused path-mutator method first failed three refusal assertions in 0.285s for the direct dynamic getter, aliased getter and default/keyword receiver cases. After adding a second alias, holder, `.__call__` and callback variants, the same method failed four assertions in 0.329s. The coordinator's pinned parent probe then confirmed acceptance of the broader container/loop/comprehension and member-name shadow cases; after those were added, the focused method failed seven more refusal assertions in 0.355s for append/subscript storage, loop/comprehension extraction, direct reflective `.__call__`, and the two shadowed-name cases. The exact command for that expanded RED was: +The 0.285s three-failure run is historical RED evidence for the earlier correction from immutable parent `2a2ee6c8ae397e2ce5a4703bca6f96d98ec8ca67` to `cc4df01f377c9d6503356acf1bf41f70fb130728`; it is not a RED against untouched `cc4df01f` or `3ad98c59`. The later 0.329s four-failure run was incremental working-tree RED evidence after adding a second alias, holder, `.__call__` and callback variants. The 0.355s seven-failure run was another incremental working-tree RED after the initial transfer guard and after adding append/subscript storage, loop/comprehension extraction, direct reflective `.__call__`, and member-name shadow cases. These unittest results are distinct from the coordinator's pinned AST probes against immutable `cc4df01f`, which independently confirmed the broader acceptance cases. The exact command for the expanded 0.355s RED was: ```text python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ From 95385fdeb96bbdfbcfe35d319f685bc15583cf3f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 13 Oct 2026 11:30:16 +0900 Subject: [PATCH 084/123] Close reflected mutator alias transfer paths --- docs/evidence/g01-recovery-packet.md | 265 ++++++++++++++++-- .../issue79_regression_test.py | 53 ++++ 2 files changed, 295 insertions(+), 23 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a71faf4d..b051498a 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16974,6 +16974,120 @@ def python_filesystem_mutator_alias_violation(tree, parents): (binding_scope, target.value.id, value) ) + collection_constructor_names = { + "dict", "frozenset", "list", "set", "tuple" + } + + def known_collection_constructor(node, seen=None): + if seen is None: + seen = set() + if not isinstance(node, ast.Name): + return False + for scope in python_lexical_scope_chain( + python_enclosing_scope(node, parents), parents + ): + binding = (id(scope), node.id) + if binding in seen: + return False + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + arguments = scope.args + parameters = ( + list(arguments.posonlyargs) + + list(arguments.args) + + list(arguments.kwonlyargs) + ) + if any(parameter.arg == node.id for parameter in parameters): + return False + if any( + node.id in declaration.names + for declaration in ast.walk(scope) + if isinstance(declaration, (ast.Global, ast.Nonlocal)) + and python_enclosing_scope(declaration, parents) is scope + ): + return False + for candidate in ast.walk(scope): + candidate_scope = ( + python_enclosing_scope(parents.get(candidate), parents) + if isinstance( + candidate, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef), + ) + else python_enclosing_scope(candidate, parents) + ) + if candidate_scope is not scope: + continue + if isinstance(candidate, (ast.Import, ast.ImportFrom)): + imported_names = ( + [ + alias.asname or alias.name.split(".")[0] + for alias in candidate.names + ] + if isinstance(candidate, ast.Import) + else [alias.asname or alias.name for alias in candidate.names] + ) + if node.id in imported_names: + return False + if isinstance(candidate, ast.ExceptHandler) and candidate.name == node.id: + return False + if isinstance( + candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ) and candidate.name == node.id and ( + python_enclosing_scope(parents.get(candidate), parents) is scope + ): + return False + values = [ + value + for binding_scope, name, value in assignment_records + if binding_scope is scope and name == node.id + ] + if values: + return all( + known_collection_constructor(value, seen | {binding}) + for value in values + ) + if any( + isinstance(candidate, ast.Name) + and candidate.id == node.id + and isinstance(candidate.ctx, ast.Store) + and python_enclosing_scope(candidate, parents) is scope + for candidate in ast.walk(scope) + ): + return False + return node.id in collection_constructor_names + + holder_methods = { + "add", "append", "extend", "insert", "setdefault", "update" + } + holder_method_aliases = {} + holder_method_name_aliases = {} + for scope, name, value in assignment_records: + if ( + isinstance(value, ast.Attribute) + and value.attr in holder_methods + and isinstance(value.value, ast.Name) + ): + holder_method_aliases.setdefault((scope, name), set()).add( + value.value.id + ) + elif isinstance(value, ast.Name): + holder_method_name_aliases.setdefault((scope, name), set()).add( + value.id + ) + + def holder_receivers_for_alias(name, scope, seen=None): + if seen is None: + seen = set() + binding = (scope, name) + if binding in seen: + return set() + seen.add(binding) + receivers = set(holder_method_aliases.get(binding, ())) + for source_name in holder_method_name_aliases.get(binding, ()): + receivers.update( + holder_receivers_for_alias(source_name, scope, set(seen)) + ) + return receivers + def reflective_member_name_is_closed(node, seen=None): if seen is None: seen = set() @@ -17092,8 +17206,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): return reflective_value_flows(node.value) if ( isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id in {"dict", "frozenset", "list", "set", "tuple"} + and known_collection_constructor(node.func) ): return any( reflective_value_flows(value) @@ -17111,31 +17224,33 @@ def python_filesystem_mutator_alias_violation(tree, parents): if binding not in reflective_bindings and reflective_value_flows(value): reflective_bindings.add(binding) changed = True - holder_methods = { - "add", "append", "extend", "insert", "setdefault", "update" - } for candidate in ast.walk(tree): - if not ( - isinstance(candidate, ast.Call) - and isinstance(candidate.func, ast.Attribute) + if not isinstance(candidate, ast.Call) or not any( + reflective_value_flows(value) + for value in [ + *candidate.args, + *(keyword.value for keyword in candidate.keywords), + ] + ): + continue + scope = python_enclosing_scope(candidate, parents) + if ( + isinstance(candidate.func, ast.Attribute) and candidate.func.attr in holder_methods and isinstance(candidate.func.value, ast.Name) - and any( - reflective_value_flows(value) - for value in [ - *candidate.args, - *(keyword.value for keyword in candidate.keywords), - ] - ) ): - continue - binding = ( - python_enclosing_scope(candidate, parents), - candidate.func.value.id, - ) - if binding not in reflective_bindings: - reflective_bindings.add(binding) - changed = True + holder_names = {candidate.func.value.id} + elif isinstance(candidate.func, ast.Name): + holder_names = holder_receivers_for_alias( + candidate.func.id, scope + ) + else: + holder_names = set() + for holder_name in holder_names: + binding = (scope, holder_name) + if binding not in reflective_bindings: + reflective_bindings.add(binding) + changed = True if not changed: break @@ -17228,6 +17343,83 @@ def python_filesystem_mutator_alias_violation(tree, parents): and parents[parent].func is parent ): called.update(alias_origins[candidate.id]) + + def scope_binds_name(scope, name): + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + arguments = scope.args + parameters = ( + list(arguments.posonlyargs) + + list(arguments.args) + + list(arguments.kwonlyargs) + ) + if any(parameter.arg == name for parameter in parameters): + return True + if any( + name in declaration.names + for declaration in ast.walk(scope) + if isinstance(declaration, ast.Global) + and python_enclosing_scope(declaration, parents) is scope + ): + return True + for candidate in ast.walk(scope): + candidate_scope = ( + python_enclosing_scope(parents.get(candidate), parents) + if isinstance( + candidate, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef), + ) + else python_enclosing_scope(candidate, parents) + ) + if candidate_scope is not scope: + continue + if isinstance(candidate, ast.Name) and ( + candidate.id == name + and isinstance(candidate.ctx, ast.Store) + ): + return True + if isinstance(candidate, (ast.Import, ast.ImportFrom)): + imported_names = ( + [ + alias.asname or alias.name.split(".")[0] + for alias in candidate.names + ] + if isinstance(candidate, ast.Import) + else [alias.asname or alias.name for alias in candidate.names] + ) + if name in imported_names: + return True + if isinstance(candidate, ast.ExceptHandler) and candidate.name == name: + return True + if isinstance( + candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ) and candidate.name == name and ( + python_enclosing_scope(parents.get(candidate), parents) is scope + ): + return True + return False + + for candidate in ast.walk(function): + if not ( + isinstance(candidate, ast.Name) + and isinstance(candidate.ctx, ast.Load) + ): + continue + use_scope = python_enclosing_scope(candidate, parents) + if use_scope is function: + continue + lexical_scopes = python_lexical_scope_chain(use_scope, parents) + if function not in lexical_scopes: + continue + origins = alias_origins.get(candidate.id, set()) + if not origins: + continue + intervening_scopes = lexical_scopes[:lexical_scopes.index(function)] + if any( + scope_binds_name(scope, candidate.id) + for scope in intervening_scopes + ): + continue + called.update(origins) return called & parameters for candidate in ast.walk(tree): @@ -38462,3 +38654,30 @@ Ran 4 tests in 7.134s — OK ``` These selectors preserve the existing direct and wrapped Path ownership refusals, temporary-owned mutation controls, benign string replacement, canonical scanner namespace and bounded Git collector. The three added snippets were parsed only as inert AST strings; no specimen, filesystem method or verifier recipe ran. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run. This closes the three reproduced transfer forms only; arbitrary unmodeled helper or container semantics still require their own evidence and review. + +#### Constructor, insertion and nested-callback transfers from immutable `30c9aa75` + +The coordinator's pinned AST probes against exact parent `30c9aa7588e717990739c687372d3512553e1cba` accepted three unresolved reflective-mutator transfers: a `list` constructor alias wrapping the getter result; an alias of a holder's `.append` method storing it; and a callback parameter captured and invoked by a nested closure. The independent review of that same immutable parent reproduced the same three P1 forms and confirmed the benign constructor, insertion, closure and shadowing controls; it reported no additional finding. The review predated this candidate, and its parent result is not approval of the current source. + +The three cases were added to the existing path-mutator method before scanner edits. An initial version of the benign closure control used `print`, which the existing sink guard correctly refused; that run failed four assertions in 0.366s and is not the targeted RED. After replacing it with a local echo callback, the untouched `30c9aa75` scanner failed exactly the three intended refusal assertions in 0.396s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.396s — FAILED (failures=3) +``` + +The correction resolves simple local aliases of the selected collection constructors against lexical bindings, follows local aliases of the existing holder-insertion methods, and propagates reflective callback provenance through simple same-function aliases and nested closures. A captured name contributes the outer callback provenance only when nearer function scopes do not bind or shadow that name. The safe controls cover ordinary constructor and insertion aliases, a local echo callback, and nested closures that shadow the callback by parameter or local assignment; no source, helper-name or marker exception was added. + +During control refinement, one shadow fixture reused `invoke`, which collided with an earlier same-named helper candidate and caused one safe-control failure in 0.386s; renaming that fixture to `invoke_shadow` removed the ambiguity. The final path-mutator selector passed with the parameter-shadow and local-rebind controls, and the final changed-boundary command passed all four methods in 8.338s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 8.338s — OK +``` + +This focused run retains the canonical bounded Git collector, reviewed scanner namespace, benign `str.replace`, temporary-owned Path operations and the existing unowned-path refusals. All added specimens remained inert AST strings; no specimen or packet recipe was compiled or executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run; the coordinator owns packet integration. This delta covers only the reproduced constructor, insertion and nested-closure shapes. To roll back just this unit, revert its local two-file commit; earlier commits remain untouched. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 99248921..ead5db8d 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5348,6 +5348,30 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'with TemporaryDirectory() as td:\n' ' relocate(Path(td), "move_into")\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' wrapper = list\n' + ' actions = wrapper([getattr(receiver, attribute)])\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = []\n' + ' insert = actions.append\n' + ' insert(getattr(receiver, attribute))\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' def invoke(callback):\n' + ' def captured():\n' + ' callback(Path("synthetic-unowned-destination"))\n' + ' captured()\n' + ' invoke(getattr(receiver, attribute))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'def member(receiver, attribute):\n' ' return getattr(receiver, attribute)\n' 'def relocate(receiver, attribute):\n' @@ -5408,6 +5432,35 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'seen_copy = seen.copy()\n' 'seen_alias = seen\n' 'seen_alias_copy = seen_alias.copy()\n' + 'wrapper = list\n' + 'actions = wrapper(["ast-node"])\n' + 'items = []\n' + 'insert = items.append\n' + 'insert("ast-node")\n' + 'def echo(value):\n' + ' return value\n' + 'def invoke(callback):\n' + ' def captured():\n' + ' callback("ast-node")\n' + ' captured()\n' + 'invoke(echo)\n' + 'def nested_shadow(receiver, attribute):\n' + ' def invoke_shadow(callback):\n' + ' def captured_shadow(callback):\n' + ' callback("ast-node")\n' + ' captured_shadow(echo)\n' + ' invoke_shadow(getattr(receiver, attribute))\n' + 'with TemporaryDirectory() as shadow_td:\n' + ' nested_shadow(Path(shadow_td), "move_into")\n' + 'def nested_rebind(receiver, attribute):\n' + ' def invoke_rebind(callback):\n' + ' def captured_rebind():\n' + ' callback = lambda: "ok"\n' + ' callback()\n' + ' captured_rebind()\n' + ' invoke_rebind(getattr(receiver, attribute))\n' + 'with TemporaryDirectory() as rebind_td:\n' + ' nested_rebind(Path(rebind_td), "move_into")\n' ) self.assertIsNone(self.inspect(safe)) From 62e0fe6b6bfa53a43c229d30fa260e29781ed0af Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 13 Oct 2026 11:46:58 +0900 Subject: [PATCH 085/123] Track reflective mutators through helper transfers --- docs/evidence/g01-recovery-packet.md | 194 +++++++++++++++++- .../issue79_regression_test.py | 58 ++++++ 2 files changed, 244 insertions(+), 8 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index b051498a..66683e02 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -17074,17 +17074,127 @@ def python_filesystem_mutator_alias_violation(tree, parents): value.id ) + def scope_binds_name(scope, name): + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + arguments = scope.args + parameters = ( + list(arguments.posonlyargs) + + list(arguments.args) + + list(arguments.kwonlyargs) + ) + if any(parameter.arg == name for parameter in parameters): + return True + if any( + name in declaration.names + for declaration in ast.walk(scope) + if isinstance(declaration, (ast.Global, ast.Nonlocal)) + and python_enclosing_scope(declaration, parents) is scope + ): + return False + for candidate in ast.walk(scope): + candidate_scope = ( + python_enclosing_scope(parents.get(candidate), parents) + if isinstance( + candidate, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef), + ) + else python_enclosing_scope(candidate, parents) + ) + if candidate_scope is not scope: + continue + if isinstance(candidate, ast.Name) and ( + candidate.id == name and isinstance(candidate.ctx, ast.Store) + ): + return True + if isinstance(candidate, (ast.Import, ast.ImportFrom)): + imported_names = ( + [ + alias.asname or alias.name.split(".")[0] + for alias in candidate.names + ] + if isinstance(candidate, ast.Import) + else [alias.asname or alias.name for alias in candidate.names] + ) + if name in imported_names: + return True + if isinstance(candidate, ast.ExceptHandler) and candidate.name == name: + return True + if isinstance( + candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ) and candidate.name == name and ( + python_enclosing_scope(parents.get(candidate), parents) is scope + ): + return True + return False + + def holder_binding_scope(name, scope): + lexical_scopes = python_lexical_scope_chain(scope, parents) + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + declarations = { + declaration_type: set().union(*( + set(declaration.names) + for declaration in ast.walk(scope) + if isinstance(declaration, declaration_type) + and python_enclosing_scope(declaration, parents) is scope + )) + for declaration_type in (ast.Global, ast.Nonlocal) + } + if name in declarations[ast.Global]: + module = next( + ( + candidate + for candidate in lexical_scopes + if isinstance(candidate, ast.Module) + ), + None, + ) + return ( + module + if module is not None and scope_binds_name(module, name) + else None + ) + if name in declarations[ast.Nonlocal]: + return next( + ( + candidate + for candidate in lexical_scopes[1:] + if isinstance( + candidate, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda), + ) + and scope_binds_name(candidate, name) + ), + None, + ) + return next( + ( + candidate + for candidate in lexical_scopes + if scope_binds_name(candidate, name) + ), + None, + ) + def holder_receivers_for_alias(name, scope, seen=None): if seen is None: seen = set() - binding = (scope, name) + binding_scope = holder_binding_scope(name, scope) + if binding_scope is None: + return set() + binding = (binding_scope, name) if binding in seen: return set() seen.add(binding) - receivers = set(holder_method_aliases.get(binding, ())) + receivers = set() + for receiver_name in holder_method_aliases.get(binding, ()): + receiver_scope = holder_binding_scope(receiver_name, binding_scope) + if receiver_scope is not None: + receivers.add((receiver_scope, receiver_name)) for source_name in holder_method_name_aliases.get(binding, ()): receivers.update( - holder_receivers_for_alias(source_name, scope, set(seen)) + holder_receivers_for_alias( + source_name, binding_scope, set(seen) + ) ) return receivers @@ -17217,6 +17327,41 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) return False + def transfer_capable_argument(node, scope): + if isinstance(node, ast.Name): + return bool(holder_receivers_for_alias(node.id, scope)) or ( + known_collection_constructor(node) + ) + if isinstance(node, ast.Attribute): + return node.attr in holder_methods + return False + + for candidate in ast.walk(tree): + if not isinstance(candidate, ast.Call) or not isinstance( + candidate.func, ast.Name + ): + continue + scope = python_enclosing_scope(candidate, parents) + if not python_local_function_candidates( + candidate.func.id, candidate, tree, parents + ): + continue + arguments = [ + *candidate.args, + *(keyword.value for keyword in candidate.keywords), + ] + if not any(reflective_value_flows(value) for value in arguments): + continue + if any( + transfer_capable_argument(value, scope) + for value in arguments + if not reflective_value_flows(value) + ): + return ( + "Python heredoc forwards a reflective filesystem result with a " + f"holder or constructor to a local helper on line {candidate.lineno}" + ) + for _ in range(len(assignment_records) + 1): changed = False for scope, name, value in assignment_records: @@ -17239,15 +17384,21 @@ def python_filesystem_mutator_alias_violation(tree, parents): and candidate.func.attr in holder_methods and isinstance(candidate.func.value, ast.Name) ): - holder_names = {candidate.func.value.id} + receiver_scope = holder_binding_scope( + candidate.func.value.id, scope + ) + holder_bindings = ( + {(receiver_scope, candidate.func.value.id)} + if receiver_scope is not None + else set() + ) elif isinstance(candidate.func, ast.Name): - holder_names = holder_receivers_for_alias( + holder_bindings = holder_receivers_for_alias( candidate.func.id, scope ) else: - holder_names = set() - for holder_name in holder_names: - binding = (scope, holder_name) + holder_bindings = set() + for binding in holder_bindings: if binding not in reflective_bindings: reflective_bindings.add(binding) changed = True @@ -38681,3 +38832,30 @@ Ran 4 tests in 8.338s — OK ``` This focused run retains the canonical bounded Git collector, reviewed scanner namespace, benign `str.replace`, temporary-owned Path operations and the existing unowned-path refusals. All added specimens remained inert AST strings; no specimen or packet recipe was compiled or executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run; the coordinator owns packet integration. This delta covers only the reproduced constructor, insertion and nested-closure shapes. To roll back just this unit, revert its local two-file commit; earlier commits remain untouched. + +#### Higher-order holder and constructor transfers from immutable `95385fde` + +The coordinator's immutable-source AST probes against exact parent `95385fdeb96bbdfbcfe35d319f685bc15583cf3f` accepted five unresolved reflective-mutator transfers: a module-level holder method alias passed with the reflective result to a local inserter helper; a holder-method alias captured by a nested population function; reflective values forwarded beside a bound `append` method or the `list.append` descriptor to local helper parameters; and a reflective value returned inside a list built through a constructor parameter. The independent review reproduced the parameterized inserter, descriptor and constructor forms; the coordinator reproduced all five. These were inert `Path.move_into` examples whose final call used an unowned destination; no new public finding URL was supplied. + +The five examples were added to `test_new_path_mutators_require_owned_sources_and_destinations` before scanner edits. On untouched parent `95385fde`, the focused method failed exactly the five new refusal assertions in 0.491s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.491s — FAILED (failures=5) +``` + +The correction resolves holder aliases to the lexical scope that actually binds the receiver, including captures and local shadowing, and conservatively refuses unresolved reflective values forwarded with a holder method or collection constructor to a local helper. Harmless higher-order constructor and insertion calls, including a shadowed local holder, remain positive controls. The change stays within the existing reflective-mutator boundary and does not add source, variable-name, helper-name or marker exceptions. + +After the correction, the path-mutator method passed in 0.483s. The final four requested boundary and compatibility selectors passed together in 10.118s: + +```text +python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 10.118s — OK +``` + +These checks retain the existing unowned Path mutation refusals, temporary-owned mutation and local set-copy positives, benign `str.replace`, canonical reviewed scanner namespace, and stored Git stream collector. The new specimens were parsed only as inert source strings; no specimen, packet recipe, filesystem method, verifier, compiler or live operation was executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run; the coordinator owns final packet integration. This evidence covers the five reproduced transfer shapes and adjacent controls only; unmodeled Python helper and container behavior is not claimed closed. Rollback is to revert this local two-file commit; earlier commits remain untouched. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index ead5db8d..092386ed 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5372,6 +5372,52 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'with TemporaryDirectory() as td:\n' ' relocate(Path(td), "move_into")\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'actions = []\n' + 'insert = actions.append\n' + 'def store(inserter, value):\n' + ' inserter(value)\n' + 'def relocate(receiver, attribute):\n' + ' store(insert, getattr(receiver, attribute))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n' + 'actions[0](Path("synthetic-unowned-destination"))\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def relocate(receiver, attribute):\n' + ' actions = []\n' + ' insert = actions.append\n' + ' def populate():\n' + ' insert(getattr(receiver, attribute))\n' + ' populate()\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def store(inserter, value):\n' + ' inserter(value)\n' + 'def relocate(receiver, attribute):\n' + ' actions = []\n' + ' store(actions.append, getattr(receiver, attribute))\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def store(inserter, target, value):\n' + ' inserter(target, value)\n' + 'def relocate(receiver, attribute):\n' + ' actions = []\n' + ' store(list.append, actions, getattr(receiver, attribute))\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def build(constructor, value):\n' + ' return constructor([value])\n' + 'def relocate(receiver, attribute):\n' + ' actions = build(list, getattr(receiver, attribute))\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'def member(receiver, attribute):\n' ' return getattr(receiver, attribute)\n' 'def relocate(receiver, attribute):\n' @@ -5461,6 +5507,18 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' invoke_rebind(getattr(receiver, attribute))\n' 'with TemporaryDirectory() as rebind_td:\n' ' nested_rebind(Path(rebind_td), "move_into")\n' + 'def build_plain(constructor, value):\n' + ' return constructor([value])\n' + 'plain_values = build_plain(list, "ast-node")\n' + 'def store_plain(inserter, value):\n' + ' inserter(value)\n' + 'plain_items = []\n' + 'store_plain(plain_items.append, "ast-node")\n' + 'def shadow_plain_holder():\n' + ' plain_items = []\n' + ' insert_plain = plain_items.append\n' + ' store_plain(insert_plain, "ast-node")\n' + 'shadow_plain_holder()\n' ) self.assertIsNone(self.inspect(safe)) From 39e42787e8e1a8a22c1dfd1c63b30b486df44d42 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 13 Oct 2026 11:58:46 +0900 Subject: [PATCH 086/123] Harden variadic reflective mutator flows --- docs/evidence/g01-recovery-packet.md | 91 +++++++++++++++++-- .../issue79_regression_test.py | 48 ++++++++++ 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 66683e02..8c137562 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16995,6 +16995,8 @@ def python_filesystem_mutator_alias_violation(tree, parents): list(arguments.posonlyargs) + list(arguments.args) + list(arguments.kwonlyargs) + + ([arguments.vararg] if arguments.vararg is not None else []) + + ([arguments.kwarg] if arguments.kwarg is not None else []) ) if any(parameter.arg == node.id for parameter in parameters): return False @@ -17081,6 +17083,8 @@ def python_filesystem_mutator_alias_violation(tree, parents): list(arguments.posonlyargs) + list(arguments.args) + list(arguments.kwonlyargs) + + ([arguments.vararg] if arguments.vararg is not None else []) + + ([arguments.kwarg] if arguments.kwarg is not None else []) ) if any(parameter.arg == name for parameter in parameters): return True @@ -17219,6 +17223,8 @@ def python_filesystem_mutator_alias_violation(tree, parents): list(arguments.posonlyargs) + list(arguments.args) + list(arguments.kwonlyargs) + + ([arguments.vararg] if arguments.vararg is not None else []) + + ([arguments.kwarg] if arguments.kwarg is not None else []) ) if any(parameter.arg == node.id for parameter in parameters): return False @@ -17282,6 +17288,8 @@ def python_filesystem_mutator_alias_violation(tree, parents): return True if isinstance(node, ast.Name): return reflective_name_is_bound(node) + if isinstance(node, ast.Starred): + return reflective_value_flows(node.value) if isinstance(node, ast.NamedExpr): return reflective_value_flows(node.value) if isinstance(node, (ast.List, ast.Tuple, ast.Set)): @@ -17427,14 +17435,15 @@ def python_filesystem_mutator_alias_violation(tree, parents): return "Python heredoc calls an unresolved reflective mutator result" def callable_parameter_names(function): + positional = list(function.args.posonlyargs) + list(function.args.args) parameters = { parameter.arg - for parameter in ( - list(function.args.posonlyargs) - + list(function.args.args) - + list(function.args.kwonlyargs) - ) + for parameter in positional + list(function.args.kwonlyargs) } + if function.args.vararg is not None: + parameters.add(function.args.vararg.arg) + if function.args.kwarg is not None: + parameters.add(function.args.kwarg.arg) alias_origins = {parameter: {parameter} for parameter in parameters} function_nodes = [ candidate @@ -17502,6 +17511,8 @@ def python_filesystem_mutator_alias_violation(tree, parents): list(arguments.posonlyargs) + list(arguments.args) + list(arguments.kwonlyargs) + + ([arguments.vararg] if arguments.vararg is not None else []) + + ([arguments.kwarg] if arguments.kwarg is not None else []) ) if any(parameter.arg == name for parameter in parameters): return True @@ -17588,11 +17599,22 @@ def python_filesystem_mutator_alias_violation(tree, parents): if not called_parameters: continue positional = list(function.args.posonlyargs) + list(function.args.args) + variadic_positional = ( + function.args.vararg.arg + if function.args.vararg is not None + else None + ) for index, argument in enumerate(candidate.args): if isinstance(argument, ast.Starred): - if reflective_value_flows(argument.value) and any( - parameter.arg in called_parameters - for parameter in positional[index:] + if ( + reflective_value_flows(argument.value) + and ( + any( + parameter.arg in called_parameters + for parameter in positional[index:] + ) + or variadic_positional in called_parameters + ) ): return "Python heredoc passes a reflective result to a local callback" elif ( @@ -17601,16 +17623,32 @@ def python_filesystem_mutator_alias_violation(tree, parents): and reflective_value_flows(argument) ): return "Python heredoc passes a reflective result to a local callback" + elif ( + index >= len(positional) + and variadic_positional in called_parameters + and reflective_value_flows(argument) + ): + return "Python heredoc passes a reflective result to a local callback" named_parameters = { parameter.arg for parameter in positional + list(function.args.kwonlyargs) } + variadic_keywords = ( + function.args.kwarg.arg if function.args.kwarg is not None else None + ) for keyword in candidate.keywords: if ( keyword.arg in called_parameters and reflective_value_flows(keyword.value) ): return "Python heredoc passes a reflective result to a local callback" + if ( + keyword.arg is not None + and variadic_keywords in called_parameters + and keyword.arg not in named_parameters + and reflective_value_flows(keyword.value) + ): + return "Python heredoc passes a reflective result to a local callback" if keyword.arg is None: if isinstance(keyword.value, ast.Dict): if any( @@ -17622,8 +17660,18 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) ): return "Python heredoc passes a reflective result to a local callback" + if ( + variadic_keywords in called_parameters + and any( + reflective_value_flows(value) + for value in keyword.value.values + if value is not None + ) + ): + return "Python heredoc passes a reflective result to a local callback" elif reflective_value_flows(keyword.value) and ( called_parameters & named_parameters + or variadic_keywords in called_parameters ): return "Python heredoc passes a reflective result to a local callback" @@ -38859,3 +38907,30 @@ Ran 4 tests in 10.118s — OK ``` These checks retain the existing unowned Path mutation refusals, temporary-owned mutation and local set-copy positives, benign `str.replace`, canonical reviewed scanner namespace, and stored Git stream collector. The new specimens were parsed only as inert source strings; no specimen, packet recipe, filesystem method, verifier, compiler or live operation was executed. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run; the coordinator owns final packet integration. This evidence covers the five reproduced transfer shapes and adjacent controls only; unmodeled Python helper and container behavior is not claimed closed. Rollback is to revert this local two-file commit; earlier commits remain untouched. + +#### Variadic reflective-callback transfers from immutable `62e0fe6b` + +The independent review of exact parent `62e0fe6b6bfa53a43c229d30fa260e29781ed0af` found three unresolved reflective-mutator flows accepted by the scanner: a getter result passed to a `*callbacks` parameter and called through `callbacks[0]`; a getter result passed through `**{"callback": ...}` to a `**callbacks` parameter and called through `callbacks["callback"]`; and a starred tuple forwarding both a bound holder inserter and the getter result into a `*items` helper that stores and later invokes them. The coordinator reproduced those three forms with a pinned AST-only 21-case probe in 0.512s; eleven unsafe cases were refused, seven benign cases accepted, and these three unsafe cases accepted. The specimens were parsed as AST data only, not executed. + +I added the three refusal cases and benign variadic echo and same-name variadic-shadow controls before editing the scanner. Against untouched parent `62e0fe6b`, the focused path-mutator method failed exactly the three new refusal assertions in 0.516s (0.61s wall time): + +```text +/usr/bin/time -p python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations +Ran 1 test in 0.516s — FAILED (failures=3); real 0.61s +``` + +The scanner now includes variadic positional and keyword parameters when finding locally invoked callback bindings, follows starred expressions when checking reflective-value flow, and maps reflective values entering `*args` or `**kwargs` to those invoked callback parameters. Lexical scope binding checks also include variadic parameters, so a nested same-name variadic parameter remains local. This stays within the existing reflective-mutator and local-call analysis; it adds no helper-name, source-name, marker or hash exception. + +The final focused command passed the path-mutator, benign string-replacement, reviewed scanner namespace and Git runtime-dependency selectors together in 8.394s (8.48s wall time): + +```text +/usr/bin/time -p python3 -I -B scripts/evidence_packet/issue79_regression_test.py \ + Issue79RegressionTests.test_new_path_mutators_require_owned_sources_and_destinations \ + Issue79RegressionTests.test_string_replace_result_is_not_an_extracted_path_mutator \ + Issue79RegressionTests.test_reviewed_scanner_namespace_remains_accepted_with_cwd_guards \ + Issue79RegressionTests.test_git_query_runtime_dependencies_cannot_be_patched_around_capture +Ran 4 tests in 8.394s — OK; real 8.48s +``` + +The controls retain benign `*callbacks` and `**callbacks` echo calls, variadic shadowing, temporary-owned Path mutation, the string transformation, the reviewed scanner namespace and the stored Git stream collector. The coordinator's earlier full packet AST selector on immutable `62e0fe6b` passed one scan in 511.238s (261 shell blocks, 86 Python blocks, zero violations); it is historical parent evidence and does not qualify this candidate. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run for this correction. The new cases cover the reproduced variadic transfers only; other unmodeled Python helper or container flows are not claimed closed. Rollback is to revert this local two-file commit; earlier commits remain untouched. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 092386ed..632b278e 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -5372,6 +5372,30 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: 'with TemporaryDirectory() as td:\n' ' relocate(Path(td), "move_into")\n', 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def invoke(*callbacks):\n' + ' callbacks[0](Path("synthetic-unowned-destination"))\n' + 'def relocate(receiver, attribute):\n' + ' invoke(getattr(receiver, attribute))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def invoke(**callbacks):\n' + ' callbacks["callback"](Path("synthetic-unowned-destination"))\n' + 'def relocate(receiver, attribute):\n' + ' invoke(**{"callback": getattr(receiver, attribute)})\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' + 'def store(*items):\n' + ' items[0](items[1])\n' + 'def relocate(receiver, attribute):\n' + ' actions = []\n' + ' insert = actions.append\n' + ' store(*(insert, getattr(receiver, attribute)))\n' + ' actions[0](Path("synthetic-unowned-destination"))\n' + 'with TemporaryDirectory() as td:\n' + ' relocate(Path(td), "move_into")\n', + 'from pathlib import Path\nfrom tempfile import TemporaryDirectory\n' 'actions = []\n' 'insert = actions.append\n' 'def store(inserter, value):\n' @@ -5519,6 +5543,30 @@ def test_new_path_mutators_require_owned_sources_and_destinations(self) -> None: ' insert_plain = plain_items.append\n' ' store_plain(insert_plain, "ast-node")\n' 'shadow_plain_holder()\n' + 'def echo_variadic(value):\n' + ' return value\n' + 'def invoke_variadic(*callbacks):\n' + ' callbacks[0]("ast-node")\n' + 'invoke_variadic(echo_variadic)\n' + 'def invoke_keywords(**callbacks):\n' + ' callbacks["callback"]("ast-node")\n' + 'invoke_keywords(**{"callback": echo_variadic})\n' + 'def invoke_variadic_shadow(callback):\n' + ' def captured_variadic_shadow(*callback):\n' + ' callback[0]("ast-node")\n' + ' captured_variadic_shadow(echo_variadic)\n' + 'def invoke_keyword_shadow(callback):\n' + ' def captured_keyword_shadow(**callback):\n' + ' callback["callback"]("ast-node")\n' + ' captured_keyword_shadow(callback=echo_variadic)\n' + 'with TemporaryDirectory() as shadow_td:\n' + ' invoke_variadic_shadow(getattr(Path(shadow_td), "move_into"))\n' + ' invoke_keyword_shadow(getattr(Path(shadow_td), "move_into"))\n' + 'def invoke_shadow(callback):\n' + ' def captured_shadow(callback):\n' + ' callback("ast-node")\n' + ' captured_shadow(echo_variadic)\n' + 'invoke_shadow(echo_variadic)\n' ) self.assertIsNone(self.inspect(safe)) From 8de8e07bcb838db3b5afb02bb833fa0b4feb7661 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Tue, 13 Oct 2026 12:24:21 +0900 Subject: [PATCH 087/123] docs: record bounded protocol review reuse --- docs/evidence/g01-recovery-packet.md | 44 ++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 8c137562..0fe57055 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -41,6 +41,34 @@ in the driver. Bounded fixture rules preserve a later zero or absent observation from erasing an earlier work-bearing or uncertain observation; that is not a live or production reconciliation result. +### Published offline protocol review and bounded reuse + +On 2026-10-03, Codex (GPT-6-Luna, max) approved only the offline protocol +evidence for the provisional released SDK v0.4.0 listener and independent +reconciliation in [the published review](https://github.com/1XP-AI/gh-runnerd/issues/1#issuecomment-5967217026) +(08:33:02Z); it held full G01, production behavior and live qualification. +[The 08:39:04Z supplement](https://github.com/1XP-AI/gh-runnerd/issues/1#issuecomment-5967258854) +records the missing regex alternative for +`TestSDKJITLookupBeforeCreationDoesNotDiscoverIdentity`: the original seven +alternatives passed in the package run in 0.359s; the corrected standalone +selector passed in 0.361s on reviewed source +`9636e33781c72c32319db419334f49f1bc1866bc` with Go 1.26.8. These historical +tests were not rerun for this documentation change. + +Read-only Git tree comparisons show that `experiments/g01-scaleset` is tree +`9636ee550304dc8f155a44faa78d93b80939a138` and `experiments/g02-auth` is tree +`8015fedeccd7604cedef3e5e1814d190d7443757` at the reviewed source, current +`main` (`9a82a6a2b9c05c96c34a6ca9d0d8a6680ca209ff`), packet parent +`39e42787e8e1a8a22c1dfd1c63b30b486df44d42`, and separate G01 documentation +commit `8ae0c1a3dd158360784de64232a4890f3f3b2be6`. The G01 `go.mod` binds SDK +`v0.4.0` and the local `../g02-auth` replacement; G02 declares Go 1.26.8 and +no additional local replacement. This permits reuse of the review as historical +offline source/fixture evidence for those unchanged module trees. Reuse is +invalidated by a change to either tree or a relevant SDK/sum, toolchain, +Runner/JIT, authorization, fixture or test-selection boundary. Tree identity +does not prove external archive or runtime bytes, external GitHub semantics, the +current Issue79 scanner/packet review, full G01 completion or live behavior. + ## Classification and evidence index Use these classifications throughout the packet: @@ -74,6 +102,7 @@ Use these classifications throughout the packet: | [Exact observation adapter](g01-exact-observations.md) | Fixture: bounded exact-ID/status/provenance readers; no affirmative live recovery or cleanup. | [d62242b07b779ff6f7414e97096d2c8828634a5d](https://github.com/1XP-AI/gh-runnerd/blob/d62242b07b779ff6f7414e97096d2c8828634a5d/docs/evidence/g01-exact-observations.md) | | [Worker/JIT harness](g01-worker-harness.md) | Source + fixture: worker profile and secret boundary; no image startup, JIT mint, workflow or Docker result. | [c1c0b6a6f2f94651546f533576eb3bbe8a4089b4](https://github.com/1XP-AI/gh-runnerd/blob/c1c0b6a6f2f94651546f533576eb3bbe8a4089b4/docs/evidence/g01-worker-harness.md) | | [Paired collection](g01-paired-baseline.md) and [terminal path](g01-paired-terminal.md) | Fixture: serialized controller/worker receipts and terminal correlation; no live cleanup or success claim. | [8dd64adc551ba5174892807a678e8bc614d0a474](https://github.com/1XP-AI/gh-runnerd/blob/8dd64adc551ba5174892807a678e8bc614d0a474/docs/evidence/g01-paired-baseline.md), [cf67d4aeb511116fee0de31a4ac38409f28fa29f](https://github.com/1XP-AI/gh-runnerd/blob/cf67d4aeb511116fee0de31a4ac38409f28fa29f/docs/evidence/g01-paired-terminal.md) | +| [Published offline protocol review and supplement](#published-offline-protocol-review-and-bounded-reuse) | Source + fixture: Codex GPT-6-Luna/max approved only the provisional SDK listener and independent-reconciliation protocol evidence; the supplement records the corrected regex-alternative result. No full G01, production or live approval. | [Review, 2026-10-03 08:33:02Z](https://github.com/1XP-AI/gh-runnerd/issues/1#issuecomment-5967217026); [supplement, 08:39:04Z](https://github.com/1XP-AI/gh-runnerd/issues/1#issuecomment-5967258854); reviewed source `9636e33781c72c32319db419334f49f1bc1866bc`; G01/G02 trees recorded above. | | G01 evidence base | The selected safety/evidence base is the immutable merge below; this row intentionally does not identify a moving branch tip, and historical records retain their original source pins. | [G01 safety merge `070268968e440e52f11c7f3dea3cd9bff4eb0c34`](https://github.com/1XP-AI/gh-runnerd/commit/070268968e440e52f11c7f3dea3cd9bff4eb0c34) | | [Private canary infrastructure observation](#private-canary-infrastructure-observation) | Live infrastructure: authorized runner-group/identity/heartbeat only; public aliases only; native macOS ARM64 trust and reviewed source binding `match` required; not product Scale Set SDK listener, ACK, acquisition, JIT, drain, App/API authority, or cleanup. | Public aliases `canary-run-20260916-01` / `canary-job-runner-baseline-20260916-01`; source fingerprint `canary-workflow-source-20260916-01`; maintainer authorization [issue comment 5697441477](https://github.com/1XP-AI/gh-runnerd/issues/1#issuecomment-5697441477) bound to SHA-256 `09a806866c83d270f8b9dfaa4500c18b9422221657d10927921d1bf99c08ede8`; native macOS ARM64 trusted-execution [issue comment 5700601191](https://github.com/1XP-AI/gh-runnerd/issues/1#issuecomment-5700601191) bound to SHA-256 `111f8598241deabe172abc1527fb95bd067d46bb69089e112d071ad74dc89011`; private mapping not included | @@ -238,9 +267,14 @@ The following are explicit open product gates. They are not implied by the fixture records and are not closed by the private canary infrastructure observation: -1. Obtain an independent `gpt-luna-max` protocol/recovery review of the exact - candidate source and this packet. New work follows the current - `gpt-luna-max` route; historical Astra authorship is not current routing. +1. The published Oct 3 review above is reusable only for the unchanged offline + protocol evidence and recorded source/module trees; it does not review this + Issue79 scanner/packet correction or approve full G01, production or live + behavior. Exact-candidate review of the current packet/source remains + separate, and any future live phase additionally requires review/approval + of the exact live source, harness/workflow and target plus explicit + authorization. New work follows the current `gpt-luna-max` route; historical + Astra authorship is not current routing. 2. Name one disposable private repository, selected-only runner group, reviewed workflow/run attempt/source/head/path, owner nonce and immutable harness/workflow commits. Record an unchanged manual-runner inventory. @@ -284,11 +318,11 @@ the count includes the actual live-canary gate row. | Boundary | Reuse unchanged evidence when | Exact focused rerun | Class/result to record | |---|---|---|---| -| ACK, callback loss and acquisition order | SDK commit, listener source, adapter call order and root protocol fixtures are unchanged. | See the exact ACK/callback selector in the command block below, including `TestNoMessageDoesNotCountAsCompletedBarrier`. | Fixture: record ACK-before-acquisition and reservation/quarantine behavior; do not add server or exactly-once claims. | +| ACK, callback loss and acquisition order | SDK commit, listener source, adapter call order, root protocol fixtures and the recorded G01/G02 module tree identities are unchanged. Reuse of the Oct 3 review also requires the relevant SDK/sum, toolchain, authorization, fixture and test selection to remain unchanged. | See the exact ACK/callback selector in the command block below, including `TestNoMessageDoesNotCountAsCompletedBarrier`. | Fixture: record ACK-before-acquisition and reservation/quarantine behavior; do not add server or exactly-once claims. | | Baseline listener/admission | listener, strict source reader, journal/lease, caps and selectors are unchanged. | See the exact baseline and admission selectors in the command block below, including the worker admission integrity suite and the tagged `TestUnsupportedAccountLookupRefusesBeforeJournal` selector. | Fixture: record actual test result and pinned SDK; no live result or production worker claim. | | Runner parser/JIT causality and transport | Runner version/source commit, parser/materialization behavior, JIT transport, causal fixture and response-loss controls are unchanged. | A Runner pin or source-commit change first requires the immutable source-audit gate above; then rerun the root-package and livecanary selectors in the command blocks below, with tagged worker input/refusal and worker transport selectors as applicable. Local Go fixture results cannot substitute for the source audit. | Source + fixture: record parser/materialization comparison, one-request controls and quarantine; never print or retain JIT. | | Controller update policy | pinned SDK update-setting request, confirmation and drift guards are unchanged. | See the exact `./livecanary` update-policy selectors in the worker/runtime command block below: `TestCreateRequestsDisabledRunnerUpdate`, `TestUnconfirmedUpdateSettingQuarantinesCreate`, `TestUpdateSettingDriftStopsBeforeSessionOrJIT` and `TestUpdateSettingDriftDoesNotBlockSafeEmptyCleanup`. | Source + fixture: record request/confirmation and pre-session/JIT refusal; no live service-setting result. | -| Reconciliation, inventory/identity/quarantine | statistics readers, paged inventory normalization, generation fences, ownership checks and journal/authority schema are unchanged. | Retain the prior reconciliation selector and add the exact focused `TestObservation*` and `TestRoster*` families, inventory/quarantine and journal/authority selectors in the command block below, including controller-side `TestAmbiguousCreateNeverRetriesAfterRestart`, the four `TestInventory*` cases, `TestDemandStatisticsAllowControlledProbeButNeverCleanup`, `TestCleanupOnlyForNeverIssuedWorkerWithExactReceipt`, `TestWorkerPreparationReturnsCanonicalSnapshotAndRejectsPriorEffect`, and the named journal/authority contracts in both `./livecanary` and `./liveworker`. | Fixture: record normalized facts and retained uncertainty; no live equality/absence claim. | +| Reconciliation, inventory/identity/quarantine | Statistics readers, paged inventory normalization, generation fences, ownership checks, journal/authority schema and the recorded G01/G02 module tree identities are unchanged. Reuse of the Oct 3 review is limited to its offline reconciliation evidence and requires the relevant toolchain, authorization, fixture and test selection to remain unchanged. | Retain the prior reconciliation selector and add the exact focused `TestObservation*` and `TestRoster*` families, inventory/quarantine and journal/authority selectors in the command block below, including controller-side `TestAmbiguousCreateNeverRetriesAfterRestart`, the four `TestInventory*` cases, `TestDemandStatisticsAllowControlledProbeButNeverCleanup`, `TestCleanupOnlyForNeverIssuedWorkerWithExactReceipt`, `TestWorkerPreparationReturnsCanonicalSnapshotAndRejectsPriorEffect`, and the named journal/authority contracts in both `./livecanary` and `./liveworker`. | Fixture: record normalized facts and retained uncertainty; no live equality/absence claim. | | Secret and error handling | SDK error sentinels, HTTP body/debug redaction and shared response-budget controls are unchanged. | See the exact root, livecanary and liveworker selectors in the secret/error command block below, including decoded-body truncation, gzip-budget, uncertain-start, bounded Unix-response/redirect, journal-create-failure and raw-runtime-status tests. | Fixture: record normalized sentinel/error and body-budget behavior; no secret-bearing output or live result is claimed. | | Tagged controller/JIT input and credential transport boundary | `g01_live` source, input reader, credential attestation and transport refusal tests are unchanged. | Run the tagged command below plus the livecanary credential/transport selector below, including `TestAuthoritySplitAndPolicyRejection`, `TestSDKTransportOwnership`, `TestCredentialAttestationMismatchAndExpiredTokenRejected` and `TestTransportRejectsPlaintextOffHostAndProxyBeforeNetwork`. | Fixture: record no-secret/no-echo/refusal result; no credential or live phase. | | Idle drain and withdrawal | Only reuse the historical PR #72 review head `f5560ba950f77343e57034cc1cf85dc67f5ac922` and its unchanged fixture/source. | On a checkout of that historical PR #72 review head, run the guarded `drain-pr72` and `drain-pr72-race` commands below; both carry the same exact selector/package digest, with `default+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8` and `default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8` metadata respectively. | Fixture/source: record physical-write markers as client facts and inconclusive server receipt; never reuse as live assignment/drain evidence. | From 36e39fb0dfda5d49391d5e8b5505da8cb0905efc Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 12:48:19 +0900 Subject: [PATCH 088/123] Pin shared Go 1.26.9 dependency for issue 79 packet requalification Apply the already-authorized Go 1.26.9 shared pins, offline-harness BuildInfo admission gates, and current-contract updates needed for packet 1.26.9 compatibility (source: immutable G01 pin commit c81a17b654686600277474ae63376fdcb1210193, corrected record 9776562). Existing 12 issue-79 commits preserved; no rebase/reset/wholesale copy. Scope: active pins (4 go.mod, Makefile, 3 scripts, compare-sdk.sh, 2 workflows), runtime gates (live/worker buildRevisionFromInfo, validBrokerBuild) plus their admission tests, tooling harness expectations, and current contracts (CI.md, DEPENDENCIES toolchain line, matcher provenance note, 3 experiment READMEs). Excluded: G01 evidence docs (checkpoint addendum, live-canary checklist) and unrelated G01 action-SHA prose; this branch keeps its v4.2.2/v5.5.0 workflow pins. Historical 1.26.8 records unaltered (CI.md warmed baseline, R1 evidence section, matcher 1.26.8 adaptation source). RED (reused known tests, before pins): TestToolingCheckToolchain- RequiresGo1269 FAIL (3 subtests, go-directive mismatch), TestBrokerBuildMustMatchReviewedController FAIL (reviewed metadata rejected), live/worker TestBuildRevisionAdmissionRequiresGo1269 FAIL (1.26.9 build rejected). GREEN (focused, GOTOOLCHAIN=go1.26.9): make toolchain exit 0; tooling pin tests ok 1.575s; G02 4-test admission set ok 0.314s; live/worker admission ok 0.289s/0.279s; go vet clean on scripts, live, worker, g02; go mod tidy -diff clean on all 4 modules; gofmt/git diff --check clean. Full suites and live profiles not run. Co-authored-by: Cursor --- .github/workflows/ci.yml | 8 +- .github/workflows/pr-fast.yml | 2 +- Makefile | 2 +- docs/CI.md | 8 +- docs/DEPENDENCIES.md | 2 +- docs/third-party/go-testing-matcher.md | 4 +- experiments/g01-scaleset/README.md | 6 +- experiments/g01-scaleset/cmd/g01-live/main.go | 6 +- .../g01-scaleset/cmd/g01-live/main_test.go | 23 ++++ .../g01-scaleset/cmd/g01-worker/main.go | 6 +- .../g01-scaleset/cmd/g01-worker/main_test.go | 22 ++++ experiments/g01-scaleset/compare-sdk.sh | 2 +- experiments/g01-scaleset/go.mod | 2 +- experiments/g02-auth/README.md | 2 +- .../g02-auth/broker_paired_prep_test.go | 31 ++++- experiments/g02-auth/broker_process.go | 2 +- experiments/g02-auth/broker_process_test.go | 17 ++- experiments/g02-auth/go.mod | 2 +- experiments/r1-credentials/README.md | 6 +- experiments/r1-credentials/go.mod | 2 +- go.mod | 2 +- scripts/check-offline-experiments.sh | 2 +- scripts/check-toolchain.sh | 4 +- scripts/fast-check.sh | 2 +- scripts/tooling_test.go | 122 ++++++++++++------ 25 files changed, 208 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a83fe158..33d0c8f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: timeout-minutes: 15 env: # Match Make's explicit selection even if the installed default changes. - GOTOOLCHAIN: go1.26.8 + GOTOOLCHAIN: go1.26.9 steps: - name: Check out source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -81,7 +81,7 @@ jobs: timeout-minutes: 15 env: # Match Make's explicit selection even if the installed default changes. - GOTOOLCHAIN: go1.26.8 + GOTOOLCHAIN: go1.26.9 steps: - name: Check out source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -111,7 +111,7 @@ jobs: - experiments/g02-auth - experiments/r1-credentials env: - GOTOOLCHAIN: go1.26.8 + GOTOOLCHAIN: go1.26.9 OFFLINE_EXPERIMENT_MODULE: ${{ matrix.module }} steps: - name: Check out source @@ -136,7 +136,7 @@ jobs: timeout-minutes: 15 env: # Match Make's explicit selection even if the installed default changes. - GOTOOLCHAIN: go1.26.8 + GOTOOLCHAIN: go1.26.9 steps: - name: Check out source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/pr-fast.yml b/.github/workflows/pr-fast.yml index 4563167c..fa8103f4 100644 --- a/.github/workflows/pr-fast.yml +++ b/.github/workflows/pr-fast.yml @@ -19,7 +19,7 @@ jobs: env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - GOTOOLCHAIN: go1.26.8 + GOTOOLCHAIN: go1.26.9 steps: - name: Check out source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/Makefile b/Makefile index e6349608..b0b87d83 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ SHELL := /usr/bin/env bash GO ?= go GOFLAGS ?= # A go directive is a minimum under auto, including on a newer installation. -override GOTOOLCHAIN := go1.26.8 +override GOTOOLCHAIN := go1.26.9 export GOTOOLCHAIN export GOFLAGS FAST_MODULE := $(value FAST_MODULE) diff --git a/docs/CI.md b/docs/CI.md index f8a9cec7..8fcbadd2 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,11 +1,11 @@ # Public CI and local checks -G03 establishes the reproducible development baseline. The module requires Go 1.26.8 in [go.mod](../go.mod). That directive is a minimum under automatic toolchain selection; Make explicitly exports `GOTOOLCHAIN=go1.26.8` to select the exact version even on a newer installation. No redundant `toolchain` directive is kept. Go's [release policy](https://go.dev/doc/devel/release) and [security guidance](https://go.dev/doc/security/) support security fixes for the two most recent major releases; on 2026-09-07 the official release history lists Go 1.27.1 and Go 1.26.8. The project uses 1.26.8 because it is the latest patch in the supported 1.26 line observed on that date and the released Scale Set SDK baseline requires Go 1.25.3 or newer. Revisit the pin when a newer supported patch is selected and compatibility is rechecked. +G03 established the reproducible development baseline. Its 2026-09-07 snapshot recorded Go 1.26.8 in [go.mod](../go.mod) and selected it as the latest patch in the supported 1.26 line then observed; the official release history on that date listed Go 1.27.1 and Go 1.26.8. The Go directive is a minimum under automatic toolchain selection, and Make selects an exact toolchain without a redundant `toolchain` directive. Following the 2026-10-13 source-mode vulnerability result for Go 1.26.8, the approved current shared pin is Go 1.26.9. The G01 source re-verification that motivated this pin is recorded on the G01 branch (immutable pin commit `c81a17b654686600277474ae63376fdcb1210193`, corrected record `9776562fc792e0fb76684fb7be90a7ed118ed0ec`); it does not close the remaining G01/G02 or live qualification gates. The released Scale Set SDK baseline requires Go 1.25.3 or newer. -Make and standalone `scripts/check-toolchain.sh` select `go1.26.8` explicitly; +Make and standalone `scripts/check-toolchain.sh` select `go1.26.9` explicitly; they do not change the user's global Go configuration. Go may download that version into its normal per-user toolchain cache. The hosted workflow uses -`actions/setup-go` to install 1.26.8 from `go.mod` and uses the same explicit +`actions/setup-go` to install 1.26.9 from `go.mod` and uses the same explicit `GOTOOLCHAIN` value. The equality check still refuses an unexpected toolchain. Conventional settings such as `make GOFLAGS=-mod=readonly build` are exported through Go's environment, rather than placed before its subcommand. @@ -88,7 +88,7 @@ Individual commands are available when iterating: | Command | Check | | --- | --- | -| `make toolchain` | Confirm the module directives and selected Go toolchain are exactly 1.26.8. | +| `make toolchain` | Confirm the module directives and selected Go toolchain are exactly 1.26.9. | | `make build` | Link the application executable; included in `make check` and hosted CI. | | `make fmt` / `make fmt-check` | Format or verify all Go sources with the selected toolchain. | | `make vet` | Run `go vet ./...`. | diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 45dd32c0..2af70afe 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -21,7 +21,7 @@ runtime graph contains only the local module; nested experiments remain separate ## Toolchain -Go standard library: `go1.26.8`, BSD-3-Clause, the language/runtime baseline; +Go standard library: `go1.26.9`, BSD-3-Clause, the language/runtime baseline; [official release history](https://go.dev/doc/devel/release). The selector matcher helper adapted from the Go standard library is documented diff --git a/docs/third-party/go-testing-matcher.md b/docs/third-party/go-testing-matcher.md index 3ab4594c..903540a7 100644 --- a/docs/third-party/go-testing-matcher.md +++ b/docs/third-party/go-testing-matcher.md @@ -20,8 +20,8 @@ The upstream source identifies its notice as follows: // license that can be found in the LICENSE file. ``` -The applicable Go license text from the pinned Go 1.26.8 toolchain is reproduced -verbatim below. +The applicable Go license text from the Go 1.26.8 source used for this +adaptation is reproduced verbatim below. ```text Copyright 2009 The Go Authors. diff --git a/experiments/g01-scaleset/README.md b/experiments/g01-scaleset/README.md index a331a204..9775c6fb 100644 --- a/experiments/g01-scaleset/README.md +++ b/experiments/g01-scaleset/README.md @@ -8,15 +8,15 @@ own test server. Public dependency/toolchain downloads are separate from tests. ```sh cd experiments/g01-scaleset -GOTOOLCHAIN=go1.26.8 go test -race -count=1 -timeout=45s ./... -GOTOOLCHAIN=go1.26.8 go vet ./... +GOTOOLCHAIN=go1.26.9 go test -race -count=1 -timeout=45s ./... +GOTOOLCHAIN=go1.26.9 go vet ./... ./compare-sdk.sh ``` `compare-sdk.sh` copies the module to a disposable directory and runs the same suite against release `v0.4.0` and the immutable audited pseudo-version. It does not change this module's pin. With dependencies cached, the tests are offline. -`GOTOOLCHAIN=go1.26.8` makes the verification toolchain exact; the module's Go +`GOTOOLCHAIN=go1.26.9` makes the verification toolchain exact; the module's Go directive alone is a minimum and the toolchain directive alone is a suggestion. The red commit contains a compiling callback-only recovery baseline. The green diff --git a/experiments/g01-scaleset/cmd/g01-live/main.go b/experiments/g01-scaleset/cmd/g01-live/main.go index c08dc0ca..c6a18da8 100644 --- a/experiments/g01-scaleset/cmd/g01-live/main.go +++ b/experiments/g01-scaleset/cmd/g01-live/main.go @@ -21,7 +21,11 @@ import ( func buildRevision() (string, bool) { info, ok := debug.ReadBuildInfo() - if !ok || info.GoVersion != "go1.26.8" { + return buildRevisionFromInfo(info, ok) +} + +func buildRevisionFromInfo(info *debug.BuildInfo, ok bool) (string, bool) { + if !ok || info == nil || info.GoVersion != "go1.26.9" { return "", false } version, clean, sdk := "", false, false diff --git a/experiments/g01-scaleset/cmd/g01-live/main_test.go b/experiments/g01-scaleset/cmd/g01-live/main_test.go index fb52658c..53325b67 100644 --- a/experiments/g01-scaleset/cmd/g01-live/main_test.go +++ b/experiments/g01-scaleset/cmd/g01-live/main_test.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "runtime/debug" "strings" "syscall" "testing" @@ -19,6 +20,28 @@ import ( "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/liveworker" ) +func TestBuildRevisionAdmissionRequiresGo1269(t *testing.T) { + revision := strings.Repeat("a", 40) + good := &debug.BuildInfo{ + GoVersion: "go1.26.9", + Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.0"}}, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: revision}, + {Key: "vcs.modified", Value: "false"}, + }, + } + if got, ok := buildRevisionFromInfo(good, true); !ok || got != revision { + t.Fatalf("Go 1.26.9 reviewed build rejected: revision=%q admitted=%v", got, ok) + } + for _, version := range []string{"go1.26.8", "go1.27.1", ""} { + bad := *good + bad.GoVersion = version + if _, ok := buildRevisionFromInfo(&bad, true); ok { + t.Errorf("build toolchain %q admitted", version) + } + } +} + type unreadable struct{ t *testing.T } func (r unreadable) Read([]byte) (int, error) { diff --git a/experiments/g01-scaleset/cmd/g01-worker/main.go b/experiments/g01-scaleset/cmd/g01-worker/main.go index 780399f7..2a358823 100644 --- a/experiments/g01-scaleset/cmd/g01-worker/main.go +++ b/experiments/g01-scaleset/cmd/g01-worker/main.go @@ -16,7 +16,11 @@ import ( func buildRevision() (string, bool) { info, ok := debug.ReadBuildInfo() - if !ok || info.GoVersion != "go1.26.8" { + return buildRevisionFromInfo(info, ok) +} + +func buildRevisionFromInfo(info *debug.BuildInfo, ok bool) (string, bool) { + if !ok || info == nil || info.GoVersion != "go1.26.9" { return "", false } revision := "" diff --git a/experiments/g01-scaleset/cmd/g01-worker/main_test.go b/experiments/g01-scaleset/cmd/g01-worker/main_test.go index 3fb38e42..b9cd5a24 100644 --- a/experiments/g01-scaleset/cmd/g01-worker/main_test.go +++ b/experiments/g01-scaleset/cmd/g01-worker/main_test.go @@ -6,11 +6,33 @@ import ( "bytes" "context" "io" + "runtime/debug" "strings" "testing" "time" ) +func TestBuildRevisionAdmissionRequiresGo1269(t *testing.T) { + revision := strings.Repeat("a", 40) + good := &debug.BuildInfo{ + GoVersion: "go1.26.9", + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: revision}, + {Key: "vcs.modified", Value: "false"}, + }, + } + if got, ok := buildRevisionFromInfo(good, true); !ok || got != revision { + t.Fatalf("Go 1.26.9 reviewed build rejected: revision=%q admitted=%v", got, ok) + } + for _, version := range []string{"go1.26.8", "go1.27.1", ""} { + bad := *good + bad.GoVersion = version + if _, ok := buildRevisionFromInfo(&bad, true); ok { + t.Errorf("build toolchain %q admitted", version) + } + } +} + type unreadable struct{ t *testing.T } func (r unreadable) Read([]byte) (int, error) { diff --git a/experiments/g01-scaleset/compare-sdk.sh b/experiments/g01-scaleset/compare-sdk.sh index 584cfa8a..23856ea7 100755 --- a/experiments/g01-scaleset/compare-sdk.sh +++ b/experiments/g01-scaleset/compare-sdk.sh @@ -5,7 +5,7 @@ set -eu experiment_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) comparison_dir=$(mktemp -d "${TMPDIR:-/tmp}/g01-compare.XXXXXXXX") trap 'rm -rf -- "$comparison_dir"' EXIT HUP INT TERM -export GOTOOLCHAIN=go1.26.8 +export GOTOOLCHAIN=go1.26.9 for sdk_version in v0.4.0 v0.4.1-0.20260721134647-cb0405b2d874; do mkdir "$comparison_dir/$sdk_version" cp "$experiment_dir/go.mod" "$experiment_dir/go.sum" "$experiment_dir/"*.go "$comparison_dir/$sdk_version/" diff --git a/experiments/g01-scaleset/go.mod b/experiments/g01-scaleset/go.mod index e53d6274..7ace3e69 100644 --- a/experiments/g01-scaleset/go.mod +++ b/experiments/g01-scaleset/go.mod @@ -1,6 +1,6 @@ module github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset -go 1.26.8 +go 1.26.9 require ( github.com/1XP-AI/gh-runnerd/experiments/g02-auth v0.0.0 diff --git a/experiments/g02-auth/README.md b/experiments/g02-auth/README.md index aba894b8..03848196 100644 --- a/experiments/g02-auth/README.md +++ b/experiments/g02-auth/README.md @@ -10,7 +10,7 @@ go vet ./... go run ./cmd/g02-synthetic ``` -The command generates a new RSA key, verifies two synthetic organization/installation bindings through a fake API, commits them once to an in-memory sink, and prints only a sanitized summary. It has no live mode. Tests make a local random-port HTTP request and use fake GitHub responses. No existing credentials are read, no App/installation/runner is created, and the synthetic demo saves no key. Driver input tests create only their own temporary synthetic PEM fixture. Go 1.26.8 is pinned; there are no third-party module dependencies. +The command generates a new RSA key, verifies two synthetic organization/installation bindings through a fake API, commits them once to an in-memory sink, and prints only a sanitized summary. It has no live mode. Tests make a local random-port HTTP request and use fake GitHub responses. No existing credentials are read, no App/installation/runner is created, and the synthetic demo saves no key. Driver input tests create only their own temporary synthetic PEM fixture. Go 1.26.9 is pinned; there are no third-party module dependencies. The library contains: diff --git a/experiments/g02-auth/broker_paired_prep_test.go b/experiments/g02-auth/broker_paired_prep_test.go index 2a619077..8c8a9317 100644 --- a/experiments/g02-auth/broker_paired_prep_test.go +++ b/experiments/g02-auth/broker_paired_prep_test.go @@ -20,7 +20,7 @@ const ( pairedRealCadenceBridgeTags = "g01_live,g01_pair_fixture,g01_pair_real_cadence" pairedFixtureProgramPath = "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live" pairedFixtureSDKVersion = "v0.4.0" - pairedFixtureGoVersion = "go1.26.8" + pairedFixtureGoVersion = "go1.26.9" ) type preparedBridgeBinary struct { @@ -101,7 +101,7 @@ func compilePairedG01Binaries(t *testing.T, tagSets []string) map[string]prepare } command = exec.Command("go", "build", "-buildvcs=true", "-tags", tags, "-o", out, "./cmd/g01-live") command.Dir = filepath.Join(cloneRoot, "experiments", "g01-scaleset") - command.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.8") + command.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.9") if output, err := command.CombinedOutput(); err != nil { _ = output t.Fatal("build reviewed g01 bridge binary") @@ -176,6 +176,33 @@ func validPairedFixtureBuild(info *debug.BuildInfo, expectedHead, expectedTags s return goos == runtime.GOOS && goarch == runtime.GOARCH && cgo == "1" && revision == expectedHead && clean && sdk && tags == expectedTags } +func TestPairedFixtureBuildRequiresExactGo1269(t *testing.T) { + head := strings.Repeat("a", 40) + info := &debug.BuildInfo{ + GoVersion: pairedFixtureGoVersion, + Path: pairedFixtureProgramPath, + Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: pairedFixtureSDKVersion}}, + Settings: []debug.BuildSetting{ + {Key: "GOOS", Value: runtime.GOOS}, + {Key: "GOARCH", Value: runtime.GOARCH}, + {Key: "CGO_ENABLED", Value: "1"}, + {Key: "-tags", Value: pairedFixtureBridgeTags}, + {Key: "vcs.revision", Value: head}, + {Key: "vcs.modified", Value: "false"}, + }, + } + if !validPairedFixtureBuild(info, head, pairedFixtureBridgeTags) { + t.Fatal("Go 1.26.9 fixture build rejected") + } + for _, version := range []string{"go1.26.8", "go1.27.1", ""} { + previous := *info + previous.GoVersion = version + if validPairedFixtureBuild(&previous, head, pairedFixtureBridgeTags) { + t.Fatalf("fixture build with Go %q admitted", version) + } + } +} + func formatPreparedBridgeReceipt(head, digest, tags string) []byte { return []byte(strings.Join([]string{ "head " + head, diff --git a/experiments/g02-auth/broker_process.go b/experiments/g02-auth/broker_process.go index e93616a6..4f3ad7c7 100644 --- a/experiments/g02-auth/broker_process.go +++ b/experiments/g02-auth/broker_process.go @@ -45,7 +45,7 @@ func pairedChildDeadline(parent context.Context, now time.Time) (time.Time, erro } func validBrokerBuild(info *debug.BuildInfo, expected string) bool { - if info == nil || info.GoVersion != "go1.26.8" || info.Path != "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live" || !brokerSHA40.MatchString(expected) { + if info == nil || info.GoVersion != "go1.26.9" || info.Path != "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live" || !brokerSHA40.MatchString(expected) { return false } revision, clean, sdk := "", false, false diff --git a/experiments/g02-auth/broker_process_test.go b/experiments/g02-auth/broker_process_test.go index 0998eef6..61514e0d 100644 --- a/experiments/g02-auth/broker_process_test.go +++ b/experiments/g02-auth/broker_process_test.go @@ -395,11 +395,11 @@ func TestBrokerChildBoundsAndReplacedBinaryRefuse(t *testing.T) { } func TestBrokerBuildMustMatchReviewedController(t *testing.T) { sha := strings.Repeat("a", 40) - good := debug.BuildInfo{GoVersion: "go1.26.8", Path: "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live", Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.0"}}, Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}, {Key: "GOOS", Value: runtime.GOOS}, {Key: "GOARCH", Value: runtime.GOARCH}, {Key: "CGO_ENABLED", Value: "1"}, {Key: "-tags", Value: "g01_live"}}} + good := debug.BuildInfo{GoVersion: "go1.26.9", Path: "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live", Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.0"}}, Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}, {Key: "GOOS", Value: runtime.GOOS}, {Key: "GOARCH", Value: runtime.GOARCH}, {Key: "CGO_ENABLED", Value: "1"}, {Key: "-tags", Value: "g01_live"}}} if !validBrokerBuild(&good, sha) { t.Fatal("reviewed controller metadata rejected") } - for _, kind := range []string{"revision", "dirty", "SDK", "program"} { + for _, kind := range []string{"revision", "dirty", "SDK", "program", "toolchain"} { bad := good bad.Settings = append([]debug.BuildSetting(nil), good.Settings...) switch kind { @@ -411,16 +411,25 @@ func TestBrokerBuildMustMatchReviewedController(t *testing.T) { bad.Deps = []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.1"}} case "program": bad.Path = "arbitrary/program" + case "toolchain": + bad.GoVersion = "go1.26.8" } if validBrokerBuild(&bad, sha) { t.Errorf("accepted %s", kind) } } + for _, version := range []string{"go1.27.1", ""} { + bad := good + bad.GoVersion = version + if validBrokerBuild(&bad, sha) { + t.Errorf("accepted toolchain %q", version) + } + } } func TestBrokerBuildRejectsFixtureCapability(t *testing.T) { sha := strings.Repeat("a", 40) - fixture := debug.BuildInfo{GoVersion: "go1.26.8", Path: "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live", Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.0"}}, Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}, {Key: "GOOS", Value: runtime.GOOS}, {Key: "GOARCH", Value: runtime.GOARCH}, {Key: "CGO_ENABLED", Value: "1"}, {Key: "-tags", Value: "g01_live,g01_pair_fixture"}}} + fixture := debug.BuildInfo{GoVersion: "go1.26.9", Path: "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live", Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.0"}}, Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}, {Key: "GOOS", Value: runtime.GOOS}, {Key: "GOARCH", Value: runtime.GOARCH}, {Key: "CGO_ENABLED", Value: "1"}, {Key: "-tags", Value: "g01_live,g01_pair_fixture"}}} if validBrokerBuild(&fixture, sha) { t.Fatal("fixture-enabled controller build accepted by production broker gate") } @@ -428,7 +437,7 @@ func TestBrokerBuildRejectsFixtureCapability(t *testing.T) { func TestBrokerBuildUnsupportedNativeAdmissionRefuses(t *testing.T) { sha := strings.Repeat("a", 40) - base := debug.BuildInfo{GoVersion: "go1.26.8", Path: "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live", Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.0"}}, Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}, {Key: "GOOS", Value: runtime.GOOS}, {Key: "GOARCH", Value: runtime.GOARCH}, {Key: "CGO_ENABLED", Value: "1"}, {Key: "-tags", Value: "g01_live"}}} + base := debug.BuildInfo{GoVersion: "go1.26.9", Path: "github.com/1XP-AI/gh-runnerd/experiments/g01-scaleset/cmd/g01-live", Deps: []*debug.Module{{Path: "github.com/actions/scaleset", Version: "v0.4.0"}}, Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}, {Key: "GOOS", Value: runtime.GOOS}, {Key: "GOARCH", Value: runtime.GOARCH}, {Key: "CGO_ENABLED", Value: "1"}, {Key: "-tags", Value: "g01_live"}}} for _, tc := range []struct{ key, value string }{{"CGO_ENABLED", "0"}, {"CGO_ENABLED", ""}, {"-tags", "g01_live,osusergo"}, {"-tags", "g01_live osusergo"}, {"GOOS", "other"}, {"GOARCH", "other"}} { t.Run(tc.key+tc.value, func(t *testing.T) { bad := base diff --git a/experiments/g02-auth/go.mod b/experiments/g02-auth/go.mod index ce8f7166..2d555f3d 100644 --- a/experiments/g02-auth/go.mod +++ b/experiments/g02-auth/go.mod @@ -1,3 +1,3 @@ module github.com/1XP-AI/gh-runnerd/experiments/g02-auth -go 1.26.8 +go 1.26.9 diff --git a/experiments/r1-credentials/README.md b/experiments/r1-credentials/README.md index 5b29a17a..a30e9bce 100644 --- a/experiments/r1-credentials/README.md +++ b/experiments/r1-credentials/README.md @@ -24,9 +24,9 @@ credential expiry remains authoritative. Run the candidate checks from this module directory: ```sh -GOTOOLCHAIN=go1.26.8 go test ./... -GOTOOLCHAIN=go1.26.8 go test -race -count=1 ./... -GOTOOLCHAIN=go1.26.8 go vet ./... +GOTOOLCHAIN=go1.26.9 go test ./... +GOTOOLCHAIN=go1.26.9 go test -race -count=1 ./... +GOTOOLCHAIN=go1.26.9 go vet ./... ``` The repository-level offline gate must run from the repository root because its diff --git a/experiments/r1-credentials/go.mod b/experiments/r1-credentials/go.mod index fa69079c..c4ed93e4 100644 --- a/experiments/r1-credentials/go.mod +++ b/experiments/r1-credentials/go.mod @@ -1,3 +1,3 @@ module github.com/1XP-AI/gh-runnerd/experiments/r1-credentials -go 1.26.8 +go 1.26.9 diff --git a/go.mod b/go.mod index 8877525c..34d287c6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/1XP-AI/gh-runnerd -go 1.26.8 +go 1.26.9 diff --git a/scripts/check-offline-experiments.sh b/scripts/check-offline-experiments.sh index f3faca04..f3c50836 100644 --- a/scripts/check-offline-experiments.sh +++ b/scripts/check-offline-experiments.sh @@ -3,7 +3,7 @@ set -euo pipefail go_cmd="${GO:-go}" -exact_toolchain="go1.26.8" +exact_toolchain="go1.26.9" default_heavy_test_regex='^TestBaselineStatisticsPresenceAndEligibility$' default_handoff_test_regex='^TestControllerHandoff' default_heavy_and_handoff_skip_regex='^Test(BaselineStatisticsPresenceAndEligibility|ControllerHandoff.*)$' diff --git a/scripts/check-toolchain.sh b/scripts/check-toolchain.sh index 4b829123..83142090 100644 --- a/scripts/check-toolchain.sh +++ b/scripts/check-toolchain.sh @@ -3,8 +3,8 @@ set -euo pipefail go_cmd="${GO:-go}" -expected_toolchain="go1.26.8" -expected_go_directive="1.26.8" +expected_toolchain="go1.26.9" +expected_go_directive="1.26.9" # Standalone invocation must select the pin too; auto never downgrades a newer Go. export GOTOOLCHAIN="${expected_toolchain}" diff --git a/scripts/fast-check.sh b/scripts/fast-check.sh index 06b1fc7c..b3097b4a 100755 --- a/scripts/fast-check.sh +++ b/scripts/fast-check.sh @@ -3,7 +3,7 @@ set -euo pipefail go_cmd="${GO:-go}" -exact_toolchain="go1.26.8" +exact_toolchain="go1.26.9" # Keep this standalone entry point on the same pinned toolchain as Make and # hosted CI. It is an iteration aid, not a replacement for the complete gate. diff --git a/scripts/tooling_test.go b/scripts/tooling_test.go index 84170489..9af0d934 100644 --- a/scripts/tooling_test.go +++ b/scripts/tooling_test.go @@ -18,10 +18,10 @@ const ( g01TerminalHeavyRegex = `^TestPairedTerminal(FinalResultCapacity|PendingChildCapacity|EligibilityUsesFreshExactFacts|CapturedAcknowledgementCancellation|MissingAcknowledgementsAndPostchecks)$` g01TerminalRemainderSkipRegex = `^TestPairedTerminal(FinalResultCapacity|PendingChildCapacity|EligibilityUsesFreshExactFacts|CapturedAcknowledgementCancellation|MissingAcknowledgementsAndPostchecks|Actual(Controller|Worker)SyncFailures|PostIntent(JournalIdentity|AuthorityBoundaries)|ClosedReplayActualFile|WorkerReceiptSurvivesControllerWriteFailure|FixtureStorageFailure)$` g01TerminalStorageRegex = `^TestPairedTerminal(Actual(Controller|Worker)SyncFailures|PostIntent(JournalIdentity|AuthorityBoundaries)|ClosedReplayActualFile|WorkerReceiptSurvivesControllerWriteFailure|FixtureStorageFailure)$` - g01TerminalHeavyInvocation = "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run " + g01TerminalHeavyRegex + " ./livecanary" - g01TerminalRemainderInvocation = "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPairedTerminal -skip " + g01TerminalRemainderSkipRegex + " ./livecanary" - g01TerminalStorageInvocation = "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run " + g01TerminalStorageRegex + " ./livecanary" - g01LegacyTerminalInvocation = "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPairedTerminal -skip " + g01TerminalStorageRegex + " ./livecanary" + g01TerminalHeavyInvocation = "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run " + g01TerminalHeavyRegex + " ./livecanary" + g01TerminalRemainderInvocation = "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPairedTerminal -skip " + g01TerminalRemainderSkipRegex + " ./livecanary" + g01TerminalStorageInvocation = "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run " + g01TerminalStorageRegex + " ./livecanary" + g01LegacyTerminalInvocation = "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPairedTerminal -skip " + g01TerminalStorageRegex + " ./livecanary" ) func toolingFile(t *testing.T, root, name, data string, mode os.FileMode) { @@ -48,7 +48,7 @@ func toolingRun(t *testing.T, root string, extra []string, args ...string) (stri } env = append(env, entry) } - cmd.Env = append(env, "GOTOOLCHAIN=go1.26.8", "GOFLAGS=", "GOWORK=off") + cmd.Env = append(env, "GOTOOLCHAIN=go1.26.9", "GOFLAGS=", "GOWORK=off") cmd.Env = append(cmd.Env, extra...) data, err := cmd.CombinedOutput() return string(data), err @@ -85,7 +85,7 @@ func toolingFixture(t *testing.T) string { toolingFile(t, root, "cmd/gh-runnerd/main.go", "package main\n\nfunc main() {}\n", 0600) for _, module := range []string{"g01-scaleset", "g02-auth", "r1-credentials"} { base := "experiments/" + module - toolingFile(t, root, base+"/go.mod", "module example.test/"+module+"\n\ngo 1.26.8\n", 0600) + toolingFile(t, root, base+"/go.mod", "module example.test/"+module+"\n\ngo 1.26.9\n", 0600) toolingFile(t, root, base+"/fixture.go", "package fixture\n", 0600) } toolingFile(t, root, "experiments/g01-scaleset/livecanary/fixture.go", "package livecanary\n\nfunc fixtureValue() string { return \"livecanary-fixture\" }\n", 0600) @@ -127,10 +127,10 @@ func TestToolingPinsNewerSystemGo(t *testing.T) { } // Controlled newer-installation fixture: only an explicit exact selection // delegates to the actual installed Go; auto reports a newer bundled version. - toolingFile(t, root, "newer-go", "#!/bin/sh\nif [ \"${GOTOOLCHAIN:-auto}\" = go1.26.8 ]; then exec \"$TOOLING_REAL_GO\" \"$@\"; fi\nif [ \"$1\" = version ]; then echo 'go version go1.27.1 linux/amd64'; else exit 88; fi\n", 0700) + toolingFile(t, root, "newer-go", "#!/bin/sh\nif [ \"${GOTOOLCHAIN:-auto}\" = go1.26.9 ]; then exec \"$TOOLING_REAL_GO\" \"$@\"; fi\nif [ \"$1\" = version ]; then echo 'go version go1.27.1 linux/amd64'; else exit 88; fi\n", 0700) for _, args := range [][]string{{"make", "GO=" + filepath.Join(root, "newer-go"), "toolchain"}, {"bash", "scripts/check-toolchain.sh"}} { env := []string{"GOTOOLCHAIN=auto", "GO=" + filepath.Join(root, "newer-go"), "TOOLING_REAL_GO=" + realGo} - if out, err := toolingRun(t, root, env, args...); err != nil || !strings.Contains(out, "toolchain: go1.26.8") { + if out, err := toolingRun(t, root, env, args...); err != nil || !strings.Contains(out, "toolchain: go1.26.9") { t.Errorf("newer system selection did not use pin: %s", out) } } @@ -142,6 +142,46 @@ func TestToolingPinsNewerSystemGo(t *testing.T) { } } +func TestToolingCheckToolchainRequiresGo1269(t *testing.T) { + for _, tc := range []struct { + name, reportedVersion, want string + wantError bool + }{ + {name: "accept exact patch", reportedVersion: "go1.26.9", want: "toolchain: go1.26.9"}, + {name: "reject previous patch", reportedVersion: "go1.26.8", want: "selected go1.26.8, want go1.26.9", wantError: true}, + {name: "reject different newer patch", reportedVersion: "go1.27.1", want: "selected go1.27.1, want go1.26.9", wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := toolingFixture(t) + toolingFile(t, root, "go.mod", "module example.test/root\n\ngo 1.26.9\n", 0600) + toolingFile(t, root, "reported-go", `#!/bin/sh +set -eu +if [ "${1:-}" = version ]; then + printf 'go version %s darwin/arm64\n' "$TOOLING_REPORTED_GO_VERSION" +elif [ "${1:-}" = env ] && [ "${2:-}" = GOTOOLCHAIN ]; then + printf '%s\n' "$GOTOOLCHAIN" +else + exit 88 +fi +`, 0700) + goPath := filepath.Join(root, "reported-go") + out, err := toolingRun(t, root, []string{ + "GO=" + goPath, + "TOOLING_REPORTED_GO_VERSION=" + tc.reportedVersion, + }, "bash", "scripts/check-toolchain.sh") + if tc.wantError { + if err == nil || !strings.Contains(out, tc.want) { + t.Fatalf("old Go toolchain was not rejected explicitly: err=%v output=%s", err, out) + } + return + } + if err != nil || !strings.Contains(out, tc.want) { + t.Fatalf("Go 1.26.9 did not pass exact toolchain admission: err=%v output=%s", err, out) + } + }) + } +} + func assertFastCheckRequiresExplicitSelectors(t *testing.T, root, fastCheck string) { t.Helper() for _, tc := range []struct { @@ -797,14 +837,14 @@ func TestNestedFastSelected(t *testing.T) {} module: ".", packagePath: "./...", test: "^TestFastSelected$", - invocation: "go1.26.8\tlist -json=false -f {{.Dir}} ./...\ngo1.26.8\ttest -v -json=false -list= -bench= -fuzz= -skip= -c=false -count=1 -cpu=1 -exec= -run ^TestFastSelected$ ./... -args -test.v=test2json", + invocation: "go1.26.9\tlist -json=false -f {{.Dir}} ./...\ngo1.26.9\ttest -v -json=false -list= -bench= -fuzz= -skip= -c=false -count=1 -cpu=1 -exec= -run ^TestFastSelected$ ./... -args -test.v=test2json", }, { name: "nested module", module: "./experiments/g01-scaleset", packagePath: ".", test: "^TestNestedFastSelected$", - invocation: "go1.26.8\tlist -json=false -f {{.Dir}} .\ngo1.26.8\ttest -v -json=false -list= -bench= -fuzz= -skip= -c=false -count=1 -cpu=1 -exec= -run ^TestNestedFastSelected$ . -args -test.v=test2json", + invocation: "go1.26.9\tlist -json=false -f {{.Dir}} .\ngo1.26.9\ttest -v -json=false -list= -bench= -fuzz= -skip= -c=false -count=1 -cpu=1 -exec= -run ^TestNestedFastSelected$ . -args -test.v=test2json", }, } { t.Run(tc.name, func(t *testing.T) { @@ -2128,7 +2168,7 @@ func TestToolingTaggedPairFixturePartitionsRun(t *testing.T) { terminalHeavyPath := "experiments/g01-scaleset/livecanary/pair_fixture_terminal_heavy_regression_test.go" terminalFuturePath := "experiments/g01-scaleset/livecanary/pair_fixture_terminal_future_regression_test.go" storagePath := "experiments/g01-scaleset/livecanary/pair_fixture_storage_regression_test.go" - remainingCollectionInvocation := "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -skip ^TestPaired ./livecanary" + remainingCollectionInvocation := "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -skip ^TestPaired ./livecanary" taggedPositiveSource := func(testName, marker string) string { return `//go:build g01_pair_fixture && !g01_live && !g01_worker @@ -2302,7 +2342,7 @@ func TestPairedTerminalFutureCoverage(t *testing.T) { "paired-collection", pairedCollectionSource, pairedCollectionPositiveSource, - "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPaired -skip ^TestPairedTerminal ./livecanary", + "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPaired -skip ^TestPairedTerminal ./livecanary", }, { listenerPath, @@ -2420,12 +2460,12 @@ func TestPairedTerminalFutureCoverage(t *testing.T) { log := string(data) lines := strings.Split(strings.TrimSpace(log), "\n") for _, invocation := range []string{ - "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPaired -skip ^TestPairedTerminal ./livecanary", + "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -run ^TestPaired -skip ^TestPairedTerminal ./livecanary", remainingCollectionInvocation, g01TerminalHeavyInvocation, g01TerminalRemainderInvocation, g01TerminalStorageInvocation, - "go1.26.8\tvet -tags=g01_pair_fixture ./livecanary", + "go1.26.9\tvet -tags=g01_pair_fixture ./livecanary", } { count := 0 for _, line := range lines { @@ -2437,7 +2477,7 @@ func TestPairedTerminalFutureCoverage(t *testing.T) { t.Fatalf("offline gate logged %q %d times; wrapper log:\n%s", invocation, count, log) } } - legacyCollectionInvocation := "go1.26.8\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -skip ^TestPairedTerminal ./livecanary" + legacyCollectionInvocation := "go1.26.9\ttest -race -count=1 -timeout=120s -tags=g01_pair_fixture -skip ^TestPairedTerminal ./livecanary" for _, line := range lines { if line == legacyCollectionInvocation { t.Fatalf("offline gate retained the unsplit collection invocation: %s", line) @@ -2574,7 +2614,7 @@ func toolingListLivecanary(t *testing.T, run string) []string { defer cancel() cmd := exec.CommandContext(ctx, "go", "test", "-count=1", "-tags=g01_pair_fixture", "-list", run, "./livecanary") cmd.Dir = filepath.Join("..", "experiments", "g01-scaleset") - cmd.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.8", "GOFLAGS=", "GOWORK=off") + cmd.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.9", "GOFLAGS=", "GOWORK=off") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("list run=%q: %s", run, out) @@ -2595,7 +2635,7 @@ func toolingLivecanarySkipCheck(t *testing.T, run, skip string) string { defer cancel() cmd := exec.CommandContext(ctx, "go", "test", "-count=1", "-timeout=15s", "-tags=g01_pair_fixture", "-run", run, "-skip", skip, "./livecanary") cmd.Dir = filepath.Join("..", "experiments", "g01-scaleset") - cmd.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.8", "GOFLAGS=", "GOWORK=off") + cmd.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.9", "GOFLAGS=", "GOWORK=off") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("skip check run=%q skip=%q: %s", run, skip, out) @@ -2844,13 +2884,13 @@ func FuzzDefaultG01Fixture(f *testing.F) { log := string(logData) lines := strings.Split(strings.TrimSpace(log), "\n") for _, invocation := range []string{ - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestBaselineStatisticsPresenceAndEligibility$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestControllerHandoff ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -skip ^Test(BaselineStatisticsPresenceAndEligibility|ControllerHandoff.*)$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerPrepareReviewedG01LiveBinary$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBroker(PrepareReviewedG01LiveBinary|RealCadenceChildExceedsThirtySeconds)$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -skip ^TestPaired ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestBaselineStatisticsPresenceAndEligibility$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestControllerHandoff ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -skip ^Test(BaselineStatisticsPresenceAndEligibility|ControllerHandoff.*)$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerPrepareReviewedG01LiveBinary$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBroker(PrepareReviewedG01LiveBinary|RealCadenceChildExceedsThirtySeconds)$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -skip ^TestPaired ./...", } { count := 0 for _, line := range lines { @@ -2862,7 +2902,7 @@ func FuzzDefaultG01Fixture(f *testing.F) { t.Fatalf("default G01 partition invocation %q ran %d times; wrapper log:\n%s", invocation, count, log) } } - r1Invocation := "go1.26.8\ttest -race -count=1 -timeout=45s ./..." + r1Invocation := "go1.26.9\ttest -race -count=1 -timeout=45s ./..." r1Count := 0 for _, line := range lines { if line == r1Invocation { @@ -2873,9 +2913,9 @@ func FuzzDefaultG01Fixture(f *testing.F) { t.Fatalf("offline gate ran the R1 fixture invocation %d times; wrapper log:\n%s", r1Count, log) } for _, invocation := range []string{ - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=120s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=120s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", } { for _, line := range lines { if line == invocation { @@ -3135,10 +3175,10 @@ func FuzzG02Fixture(f *testing.F) { log := string(logData) lines := strings.Split(strings.TrimSpace(log), "\n") for _, invocation := range []string{ - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerPrepareReviewedG01LiveBinary$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBroker(PrepareReviewedG01LiveBinary|RealCadenceChildExceedsThirtySeconds)$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -skip ^TestPaired ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerPrepareReviewedG01LiveBinary$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBroker(PrepareReviewedG01LiveBinary|RealCadenceChildExceedsThirtySeconds)$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -skip ^TestPaired ./...", } { count := 0 for _, line := range lines { @@ -3151,10 +3191,10 @@ func FuzzG02Fixture(f *testing.F) { } } for _, invocation := range []string{ - "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=45s -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=120s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", - "go1.26.8\ttest -race -count=1 -timeout=120s -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPaired -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=45s -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=120s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", + "go1.26.9\ttest -race -count=1 -timeout=120s -skip ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./...", } { for _, line := range lines { if line == invocation { @@ -3162,7 +3202,7 @@ func FuzzG02Fixture(f *testing.F) { } } } - r1Invocation := "go1.26.8\ttest -race -count=1 -timeout=45s ./..." + r1Invocation := "go1.26.9\ttest -race -count=1 -timeout=45s ./..." r1Count := 0 for _, line := range lines { if line == r1Invocation { @@ -3404,7 +3444,7 @@ exec "$TOOLING_REAL_GO" "$@" defer cancel() cmd := exec.CommandContext(ctx, "bash", "scripts/check-offline-experiments.sh") cmd.Dir = root - cmd.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.8", "GOFLAGS=", "GOWORK=off") + cmd.Env = append(os.Environ(), "GOTOOLCHAIN=go1.26.9", "GOFLAGS=", "GOWORK=off") cmd.Env = append(cmd.Env, env...) var out bytes.Buffer cmd.Stdout = &out @@ -3538,7 +3578,7 @@ func toolingMustNotInvokeCadence(t *testing.T, env []string) { if err != nil { t.Fatal(err) } - cadence := "go1.26.8\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./..." + cadence := "go1.26.9\ttest -race -count=1 -timeout=45s -run ^TestPairedBrokerRealCadenceChildExceedsThirtySeconds$ ./..." for _, line := range strings.Split(string(data), "\n") { if line == cadence { t.Fatalf("cadence ran after G02 prep cleanup path; wrapper log:\n%s", data) @@ -3629,8 +3669,8 @@ func toolingSignalExactPID(t *testing.T, pid int, spec string) { func TestToolingLicenseIdentityAndStaleRows(t *testing.T) { root := toolingFixture(t) - toolingFile(t, root, "go.mod", "module example.test/audit\n\ngo 1.26.8\n\nrequire example.test/dependency v1.2.0\n\nreplace example.test/dependency => ./replacement\n", 0600) - toolingFile(t, root, "replacement/go.mod", "module example.test/dependency\n\ngo 1.26.8\n", 0600) + toolingFile(t, root, "go.mod", "module example.test/audit\n\ngo 1.26.9\n\nrequire example.test/dependency v1.2.0\n\nreplace example.test/dependency => ./replacement\n", 0600) + toolingFile(t, root, "replacement/go.mod", "module example.test/dependency\n\ngo 1.26.9\n", 0600) toolingFile(t, root, "replacement/LICENSE", "Synthetic license fixture\n", 0600) base := "## Runtime modules\n\n| Module | Version | Replacement | License | Source |\n| --- | --- | --- | --- | --- |\n| `example.test/audit` | `local` | `none` | MIT | root |\n" good := "| `example.test/dependency` | `v1.2.0` | `./replacement` | MIT | local fixture |\n" From f362d54addfa015e5fdb795526d53c3f3368a172 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 12:54:33 +0900 Subject: [PATCH 089/123] Requalify packet current profile to Go 1.26.9, preserve history Update only the packet current profile to the branch-active Go 1.26.9 (local dependency 36e39fb; authorized G01 pin c81a17b, record 9776562): pins-table toolchain row (1.26.8 pair recorded historical), 2 ledger toolchain references, wrapper build-identity comment, reviewed_toolchain, and 28 go_test_checked + 2 go_vet_checked prescription identities/commands including drain-pr72. 65 current-profile lines carry go1.26.9; 106 pre-existing go1.26.8 lines (dated RED/GREEN, finding ledgers, audit probes, specimens, snapshots) preserved byte-identical. No blanket replacement. G01 canary/checkpoint docs deliberately not copied (G01-owned). RED (static, no packet execution): new packet_toolchain_profile_test.py (pins row, AST-inspected reviewed_toolchain, all wrapper-fence versions vs branch pins) failed 4/4 on the stale packet. GREEN: 4/4 pass in 0.005s; git diff --check clean; added-line credential/path scan clean (only pre-existing test-name/label tokens); no new Markdown links. Scanner suite not rerun for this unit: it holds no toolchain assertions; one smoke probe fails identically before/after this change (pre-existing #79 reflective-hardening state, out of scope). No packet execution, Go suite, CI, review, or live op claimed. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 181 +++++++++++------- .../packet_toolchain_profile_test.py | 120 ++++++++++++ 2 files changed, 229 insertions(+), 72 deletions(-) create mode 100644 scripts/evidence_packet/packet_toolchain_profile_test.py diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 0fe57055..511cbc2f 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -208,7 +208,7 @@ or exercise the tagged controller driver, and does not close G01. | Boundary | Exact selection | Classification / exposure limit | |---|---|---| | Scale Set SDK | `github.com/actions/scaleset v0.4.0`; tag/source `6ce025902cd964747a078c2aabe7340ebc667eca`; [release](https://github.com/actions/scaleset/releases/tag/v0.4.0), [listener source](https://github.com/actions/scaleset/blob/6ce025902cd964747a078c2aabe7340ebc667eca/listener/listener.go), [module](https://github.com/actions/scaleset/blob/6ce025902cd964747a078c2aabe7340ebc667eca/go.mod) | Source + fixture pin. The module declares Go `1.25.3`; the audited comparison is not the selected release. | -| Go toolchain | Current experiment module directive `go 1.26.8`; verification toolchain `GOTOOLCHAIN=go1.26.8` / `go1.26.8`; reviewed `GOEXPERIMENT=none` | Fixture/toolchain evidence only; every rerun records the exact toolchain and experiment setting. The historical `go 1.26.3` directive was replaced by `go 1.26.8` at [the #93 merge](https://github.com/1XP-AI/gh-runnerd/commit/94d00b0b07b43c156c0311cfb1f16eb6ca280bb3); older evidence retains its original source pins. | +| Go toolchain | Current experiment module directive `go 1.26.9`; verification toolchain `GOTOOLCHAIN=go1.26.9` / `go1.26.9`; reviewed `GOEXPERIMENT=none` | Fixture/toolchain evidence only; every rerun records the exact toolchain and experiment setting. The historical `go 1.26.8` directive/verification pair was replaced by `go 1.26.9` under the authorized shared-Go remediation (immutable G01 pin commit `c81a17b654686600277474ae63376fdcb1210193`, corrected record `9776562fc792e0fb76684fb7be90a7ed118ed0ec`; local dependency `36e39fb`); the earlier `go 1.26.3` directive was replaced by `go 1.26.8` at [the #93 merge](https://github.com/1XP-AI/gh-runnerd/commit/94d00b0b07b43c156c0311cfb1f16eb6ca280bb3); older evidence retains its original source pins. | | Go root/FIPS mode | Reviewed `GOROOT` mode is the toolchain default (`GOROOT=""`, identity `goroot-default`); reviewed `GOFIPS140=off` (identity `gofips140-off`) | Fixture/toolchain evidence only; non-empty inherited or command-prefix `GOROOT` and any `GOFIPS140` other than `off` are refused before a guarded Go child. The effective modes are bound in every guarded build identity; raw root paths are never recorded. | | Runner | `v2.337.0`, source `397b032cbf865e9c3ddfab89d533ec19325e1273`; [release](https://github.com/actions/runner/releases/tag/v2.337.0), [command parser](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Listener/CommandSettings.cs), [JIT materialization](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Listener/Runner.cs) | Source pin; no runner binary was downloaded or executed by the contract evidence. | | JIT bootstrap | Select worker-only `ACTIONS_RUNNER_INPUT_JITCONFIG` as parsed by runner `v2.337.0`; [environment parsing](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Listener/CommandSettings.cs) | Source-supported fallback, with no separate transport version. It avoids argv but remains sensitive in initial environment, process memory, container metadata and runner-written files. `--jitconfig` exposes argv; stdin/FD/file transport is not established and is not claimed. | @@ -325,8 +325,8 @@ the count includes the actual live-canary gate row. | Reconciliation, inventory/identity/quarantine | Statistics readers, paged inventory normalization, generation fences, ownership checks, journal/authority schema and the recorded G01/G02 module tree identities are unchanged. Reuse of the Oct 3 review is limited to its offline reconciliation evidence and requires the relevant toolchain, authorization, fixture and test selection to remain unchanged. | Retain the prior reconciliation selector and add the exact focused `TestObservation*` and `TestRoster*` families, inventory/quarantine and journal/authority selectors in the command block below, including controller-side `TestAmbiguousCreateNeverRetriesAfterRestart`, the four `TestInventory*` cases, `TestDemandStatisticsAllowControlledProbeButNeverCleanup`, `TestCleanupOnlyForNeverIssuedWorkerWithExactReceipt`, `TestWorkerPreparationReturnsCanonicalSnapshotAndRejectsPriorEffect`, and the named journal/authority contracts in both `./livecanary` and `./liveworker`. | Fixture: record normalized facts and retained uncertainty; no live equality/absence claim. | | Secret and error handling | SDK error sentinels, HTTP body/debug redaction and shared response-budget controls are unchanged. | See the exact root, livecanary and liveworker selectors in the secret/error command block below, including decoded-body truncation, gzip-budget, uncertain-start, bounded Unix-response/redirect, journal-create-failure and raw-runtime-status tests. | Fixture: record normalized sentinel/error and body-budget behavior; no secret-bearing output or live result is claimed. | | Tagged controller/JIT input and credential transport boundary | `g01_live` source, input reader, credential attestation and transport refusal tests are unchanged. | Run the tagged command below plus the livecanary credential/transport selector below, including `TestAuthoritySplitAndPolicyRejection`, `TestSDKTransportOwnership`, `TestCredentialAttestationMismatchAndExpiredTokenRejected` and `TestTransportRejectsPlaintextOffHostAndProxyBeforeNetwork`. | Fixture: record no-secret/no-echo/refusal result; no credential or live phase. | -| Idle drain and withdrawal | Only reuse the historical PR #72 review head `f5560ba950f77343e57034cc1cf85dc67f5ac922` and its unchanged fixture/source. | On a checkout of that historical PR #72 review head, run the guarded `drain-pr72` and `drain-pr72-race` commands below; both carry the same exact selector/package digest, with `default+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8` and `default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8` metadata respectively. | Fixture/source: record physical-write markers as client facts and inconclusive server receipt; never reuse as live assignment/drain evidence. | -| Paired terminal/worker support | paired journal/lease, worker profile, `liveworker` runtime source/implementation, image/runtime pins and terminal selectors are unchanged. | See the exact controller update-policy and worker runtime commands plus the tagged partition commands below; the worker runtime selector includes the direct Docker state/inspection, mutation-EOF and changed-daemon contracts, while tagged partition commands retain `go1.26.8`, `-race`, `-count=1` and `-timeout=120s`, the complete worker `^TestPaired` partition is included, and the controller-side terminal groups remain exhaustive/disjoint. | Fixture: record receipt/identity checks only; no live worker or terminal success claim. | +| Idle drain and withdrawal | Only reuse the historical PR #72 review head `f5560ba950f77343e57034cc1cf85dc67f5ac922` and its unchanged fixture/source. | On a checkout of that historical PR #72 review head, run the guarded `drain-pr72` and `drain-pr72-race` commands below; both carry the same exact selector/package digest, with `default+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9` and `default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9` metadata respectively. | Fixture/source: record physical-write markers as client facts and inconclusive server receipt; never reuse as live assignment/drain evidence. | +| Paired terminal/worker support | paired journal/lease, worker profile, `liveworker` runtime source/implementation, image/runtime pins and terminal selectors are unchanged. | See the exact controller update-policy and worker runtime commands plus the tagged partition commands below; the worker runtime selector includes the direct Docker state/inspection, mutation-EOF and changed-daemon contracts, while tagged partition commands retain `go1.26.9`, `-race`, `-count=1` and `-timeout=120s`, the complete worker `^TestPaired` partition is included, and the controller-side terminal groups remain exhaustive/disjoint. | Fixture: record receipt/identity checks only; no live worker or terminal success claim. | | Actual live canary | Reuse the sanitized infrastructure observation only for the same public aliases (`private-canary-repository`, `canary-workflow-20260916-01`, `canary-run-20260916-01`, `canary-job-runner-baseline-20260916-01`, `canary-runner-group-01`, `canary-runner-01`, `canary-macos-arm64`), source fingerprint `canary-workflow-source-20260916-01`, run attempt `1`, reviewed source binding `match`, native macOS ARM64 trusted-execution approval, and the runner-group/identity/heartbeat boundary, after the recorded authorization/review gate and both authorization body digests. It is not reusable as product Scale Set SDK listener, ACK, acquisition, JIT, drain, App/API authority, or cleanup evidence. No reuse if alias, source, path, attempt, or target changes. Product live phases still have no reusable live result. | Infrastructure-only rerun: require fresh maintainer authorization, including trusted native macOS ARM64 execution approval, and independent review of the immutable private workflow/source mapping; verify only the owned target and pre-run inventory; dispatch the exact reviewed manual workflow once; read the run-metadata API; compare returned workflow run head SHA and workflow path against the maintainer-only reviewed source mapping and accept job/runner facts only on `reviewed source binding: match`; on mismatch, missing data, or unknown ownership, classify as unverified/quarantine and do not reuse; then read the attempt jobs API; sanitize to the same public schema/aliases; record a new digest/alias and result; stop on mismatch or unknown ownership. Never substitute the product driver, automatically replay, or perform broad cleanup. Product phases remain separate: rebuild/plan the exact reviewed tagged binary, then run only the explicitly authorized phase from [the driver](g01-live-driver.md); never substitute fixture commands, this infrastructure observation, or broaden phases. | Live infrastructure: sanitized authorized run-metadata API event/attempt/conclusion, `reviewed source binding: match`, and attempt jobs API runner-group/identity/heartbeat facts for aliases `canary-run-20260916-01` / `canary-job-runner-baseline-20260916-01` at fingerprint `canary-workflow-source-20260916-01`. Product live: still record sanitized server observations, authorization and unresolved outcomes; any changed target or boundary requires a fresh approval/rerun. | The two drain prescriptions intentionally bind a different reviewed source tree @@ -562,7 +562,7 @@ set -euo pipefail # Invocation metadata is: package ID module-dir:package, then build ID # tags+race-mode+cgo-mode+cgo-tool-identity+goexperiment+target+goroot+gofips140+toolchain (for # example, experiments/g01-scaleset:./livecanary -# default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8). +# default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9). # The wrapper checks package initialization/build state, derives test names from # the same source/build selection without starting a test binary, then runs the # original command with its own JSON stream. @@ -757,7 +757,7 @@ for source, value in ( f"{label}: {source} GOENV must be {reviewed_goenv!r}" ) env["GOENV"] = reviewed_goenv -reviewed_toolchain = "go1.26.8" +reviewed_toolchain = "go1.26.9" for source, value in ( ("inherited", os.environ.get("GOTOOLCHAIN")), ("command", command_assignments.get("GOTOOLCHAIN")), @@ -3073,77 +3073,77 @@ go_vet_checked() { "$label" "$package_id" "$build_id" "$@" } -go_test_checked 9 9aef95c84ffd42ad632040498c628c78072ce94f5cc2f6af8493fcffc233b707 ack-root experiments/g01-scaleset:. default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s -run '^(TestSDKACKBoundaries|TestSDKDemandAboveFiftyAndPartialAcquisition|TestSDKRepeatedStatisticsAnd202ReuseLastObservation|TestRecoveryAfterACKCallbackCrash|TestRecoveryMissingLifecycleCallback|TestRecoveryErrorsHoldReservationsAndRedact|TestSDKAcquisitionResponseLossAfterACK|TestSDKCapacityWithdrawalDoesNotFenceInFlightAcquisition|TestSDKHTTPFailuresAndSessionRefresh)$' . -go_test_checked 5 f95a296947af0fb862e8b447d3e27b7ce9f01e726659f5f87393789b62b783c9 ack-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestSupportedListenerBarriersAndReservation|TestDriverBarriersThroughPinnedSDK|TestForeignIdentityAndUnreviewedWorkNeverACKOrDelete|TestAuditPR25MultiJobAcquisitionMustRefuseBeforeACK|TestNoMessageDoesNotCountAsCompletedBarrier)$' - -go_test_checked 24 eae489a7d743c942dca803c9b13cb618dcd87fbd9a53634f022bc5381c7d6436 baseline experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=180s ./livecanary -run '^TestBaseline' -go_test_checked 9 b6a378d192ba2c614cd82c20eab7cffcdbbb00115a1bba4da803fc262a1c3d8f admission-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=180s ./livecanary -run '^(TestAdmission.*|TestAuditPR25DistinctStateDirectoriesMustShareCap)$' -go_test_checked 8 4bce3c998c4f2e6806888de2ea9936c6dd9ea877c72b190d47f4f47e24cdb559 admission-liveworker experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=180s ./liveworker -run '^(TestAdmissionDirectoryUsesOSAccountWithoutEnvironmentFallback|TestAdmissionAuthorityChecksTheCurrentClaim|TestWorkerAdmissionCapsIndependentDirectories|TestWorkerAdmissionRetainsSlotAfterOutcomeAndClose|TestAdmissionRejectsCopiedJournalInDifferentDirectory|TestAdmissionSyncFailureMustBeRetried|TestAdmissionRefusesMissingUnsafeOrUnknownRootState|TestAdmissionInitializationLockPrecedesClaimCreation)$' -go_test_checked 1 e7cdff09074beb49efb17b8e68201798140ea6f0e83ad10f3fc096aed98823ce unsupported-liveworker experiments/g01-scaleset:./liveworker osusergo+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -tags=osusergo -count=1 -timeout=180s ./liveworker -run '^TestUnsupportedAccountLookupRefusesBeforeJournal$' -go_test_checked 1 e7cdff09074beb49efb17b8e68201798140ea6f0e83ad10f3fc096aed98823ce unsupported-livecanary experiments/g01-scaleset:./livecanary osusergo+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -tags=osusergo -count=1 -timeout=180s ./livecanary -run '^TestUnsupportedAccountLookupRefusesBeforeJournal$' - -go_test_checked 3 0f5a8b0633982f8cc04a541cb396c46736c6eec486622354a6456554c52cfc75 jit-root experiments/g01-scaleset:. default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s -run '^(TestSDKJITLookupBeforeCreationDoesNotDiscoverIdentity|TestSDKJITResponseLossWithoutCommitDoesNotDiscoverIdentity|TestSDKJITResponseLossDiscoversIdentityWithoutReissuing)$' . -go_test_checked 2 42eac255f830436a6fba9457bcb1964e8f9f596761fecfdb721c2d34b684f4cb jit-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestJITLostResponseIsSecretSafeAndNeverReissued|TestDriverBarriersThroughPinnedSDK)$' - -go_test_checked 34 fcdcde4fce2efa204fdb352c035329a010f7a41b4b9746bf748b2ebc22b4d330 worker-runtime experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./liveworker -run '^(TestOneWorkerNeverRecreatedAndOnlyJITAddedToEnvironment|TestUncertainStartNeverRetriesAndCannotCleanup|TestEveryRuntimeBoundaryRejectsProfileAndOwnershipMismatch|TestUnverifiedRunnerUpdatePolicyRefusesBeforeRuntime|TestSocketReplacementAfterPreflightCannotReceiveAnyMutation|TestSocketModesAndControllerOwnership|TestSocketPostConnectRecheckClosesBeforeHTTP|TestNoCreateBeforeDurableIntent|TestUnknownCreateNeverRetriesAfterRestart|TestCreationWarningsPreserveKnownIDWithoutAuthorizingStart|TestWorkerPreparationReturnsCanonicalSnapshotAndRejectsPriorEffect|TestUnixInspectRequiresStateFlagsBeforeMutation|TestDockerInspectExact(KnownStatesAndSerializableFacts|StatePresenceAndLegacyRequirements|RejectsMalformedOrAmbiguousBodiesBeforeMutation|NotFoundReportsOnlyTheExactGET|RejectsOtherResponsesAndInvalidTargets|RequiresSupported404Body|CancellationNeverReportsPresenceOrAbsence|RejectsReplacedSocket|EOFCancellationKeepsUnknownOutcome)|TestDockerInspectLegacyCleanupKeepsSignedAndAbsentExitPolicy|TestDockerInspectMapsPreserveCaseSensitiveKeysAndProfile|TestDockerCompletedMutationResponseSurvivesEOFCancellation|TestDockerInspectUnknownOrAbsentStatusCannotAuthorizeMutation|TestUnixRuntimeRejectsWrongIdentityImagesAndUnsupportedLimits|TestUnixRuntimeOneShotCreateStartAndNonForceCleanup|TestUnixRuntimeAmbiguousEffectsNeverRetry|TestChangedDaemonCannotCreate|TestChangedDaemonOrAbsentContainerNeverMeansCleanupComplete|TestUnixTransportRejectsSymlinksAndInheritedTCPDestinations|TestAuditPR28MissingBridgeMustNotStart|TestCleanupRetainsActiveAndUnknownWorkers|TestOwnedTerminalCleanupAndRunningRemovalRace)$' -go_test_checked 4 8a0a576036768baff9a9b7d53b3487ef84b2e95f502b3ca9dd453463f396cade update-policy experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestCreateRequestsDisabledRunnerUpdate|TestUnconfirmedUpdateSettingQuarantinesCreate|TestUpdateSettingDriftStopsBeforeSessionOrJIT|TestUpdateSettingDriftDoesNotBlockSafeEmptyCleanup)$' -go_test_checked 2 2614438aa4873bd044d1f0d6247b929403e2225c2e5112c13787bf790af42413 worker-command experiments/g01-scaleset:./cmd/g01-worker g01_worker+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -tags=g01_worker -count=1 -timeout=45s ./cmd/g01-worker -run '^(TestBlockedJITInputHonorsDeadline|TestOfflinePlanAndRefusalDoNotReadSecretsOrEchoInput)$' - -go_test_checked 45 2e182d6aeb4c278eddbe272be1693e6bf0143759c23c03348435da59addfde53 reconciliation experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestAmbiguousCreateNeverRetriesAfterRestart|TestObserve.*|TestObservationIntentFailureStopsBeforeRead|TestObservationResponseCaptureIsLocalAndRejectsOtherOperations|TestStatistics.*|TestDemandStatisticsAllowControlledProbeButNeverCleanup|TestInvalidOwnedProof.*|TestCanonicalPreparationRecordsNoPhaseOrRemoteIntent|TestCanonicalPreparationRefusesInvalidJournalAndPhase|TestCanonicalPreparationRecoveryAndDriverShareLocalGate|TestJournal.*|TestAuthority.*|TestInventoryStrictPages|TestInventoryMalformedStopsLegacyEffects|TestInventoryTransportRefusalIsBoundedAndSanitized|TestInventoryImpossibleTotalStopsBeforeNextPage|TestRosterActualTLSCompleteObservation|TestRosterPreservesOnlyAcceptedPagePrefix|TestRosterFinalPublicationGuardAfterDigest|TestRosterRefusesInvalidEntryWithoutNetwork|TestStrictJSONRejectsDecoderEquivalentDuplicateFields|TestStrictJSONRejectsDuplicateAuthorityFields|TestFailedDirectorySyncMustBeRetried|TestFileJournalRejectsUnrecordedAuthorityBeforeRawDriverEffect|TestRenewedRecoveryApprovalRetainsOwnedState)$' -go_test_checked 9 47a97f1f9083abbe72dfc21a2477bd25580684506fbf71bf5aba9e7f6f1a40e8 reconciliation-worker experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./liveworker -run '^(TestPrivateJournalLocksAndRetainsReservationAcrossRestart|TestJournalRejectsChangedApprovalTornTailAndUnsafeFiles|TestAuthorityLeaseRefusesConcurrentRunsAndFencesClose|TestAuthorityRejectsReplacedJournalOrDirectory|TestStrictJSONRejectsDecoderEquivalentDuplicateFields|TestStrictInputRejectsAmbiguousOrExtraAuthorityFields|TestFailedDirectorySyncMustBeRetried|TestFileJournalRejectsUnrecordedAuthorityBeforeRawDriverEffect|TestRenewedRecoveryApprovalRetainsOwnedState)$' -go_test_checked 11 84b872130057cf2103feaa8f7c6cdb69a4d779e135eebf11cc04cb00590eacb9 reconciliation-quarantine experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestDemandStatisticsAllowControlledProbeButNeverCleanup|TestCleanupOnlyForNeverIssuedWorkerWithExactReceipt|TestZeroStatisticsAndOptionalAbsencePermitEmptyCleanup|TestUnsafeStatisticsStopNewEffectsBeforeControlledMessage|TestEmptyAvailableWithWorkStatisticsStaysQuarantined|TestOlderPendingIntentSurvivesSuccessfulZeroInspection|TestUnownedDiscoveryEvidenceCannotAuthorizeCreationAfterAbsence|TestAuditPR25ObservedJobsMustBlockCleanup|TestUnexpectedWorkMessageQuarantinesBeforeSafeClose|TestObservedRunnerSurvivesLaterAbsenceAndFirstCleanup|TestObservationResultFailureSurvivesFileReopenAndInspection)$' - -go_test_checked 1 be0f21a92ce13d75e85a1182706e84b84884a8cf0ec01172e14e5ac2e04b600f secret-root experiments/g01-scaleset:. default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s -run '^TestSDKBusyRemovalSentinelAndRawErrorExposure$' . -go_test_checked 6 6d0b3fc255928a6c7a5f7ae087d25715f96c0bad9f9298b5fb08ea7bc5fc34df secret-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestHTTPErrorsDoNotReturnSecretResponseBody|TestSDKHTTPDebugDoesNotLogCredentials|TestSharedTransportRejectsOversizeSuccessAndErrorBodies|TestResponseReaderConsumesOnlyBudgetPlusOneAndRejectsTruncation|TestResponseBudgetAppliesAfterGzipDecompression|TestRealJournalCreateFailureBlocksRetryAndContainsNoErrorBody)$' -go_test_checked 3 feda1bf98e323aef3e209cc4ea2765131ca7d17d70dfd7bb4828c0faab14dff4 secret-liveworker experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./liveworker -run '^(TestUncertainStartNeverRetriesAndCannotCleanup|TestUnixResponsesAreBoundedAndRedirectsNeverFollowed|TestObservationDoesNotJournalRawRuntimeStatus)$' - -go_test_checked 10 7ced37498c6790e0a1276ffece4cf0bcb2cad09f39da3ebdd0e28694f752596c live-command experiments/g01-scaleset:./cmd/g01-live g01_live+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -tags=g01_live -count=1 -timeout=45s ./cmd/g01-live -run '^(TestPlanAndRefusalsNeverReadCredentialsOrEchoInputs|TestPreparationCommandNeverReadsCredentialsOrRunsRemotePhase|TestPairedTerminalModeReadsControllerInputAfterAllGates|TestPairedTerminalModeRejectsUnusedPhaseAndControllerFlagsBeforeInput|TestPairedTerminalModeRequiresWorkflowVerificationAuthorityBeforeInput|TestInheritedNamedCredentialFIFODelayedEOF|TestInheritedCredentialPipeStopsAtDeadline|TestCredentialInputRejectsNonPipeDescriptor|TestBlockedCredentialPipeStopsAtDeadline|TestCredentialInputAcceptsCompleteAndRejectsOversize)$' -go_test_checked 4 a751c8b1a6a22d2f4ce76d90d6d79c48ab377355deed0f3bf189496c22b5492e live-transport experiments/g01-scaleset:./livecanary g01_live+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race -tags=g01_live -count=1 -timeout=45s ./livecanary -run '^(TestAuthoritySplitAndPolicyRejection|TestSDKTransportOwnership|TestCredentialAttestationMismatchAndExpiredTokenRejected|TestTransportRejectsPlaintextOffHostAndProxyBeforeNetwork)$' - -go_test_checked 34 a79b7fa367d8eb1e7fe4ee4ef637696518946dab6f2f25410f6e04bfba137298 drain-pr72 experiments/g01-scaleset:./livecanary default+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset ./livecanary -run '^(TestBaselineAcquireTargetIsActionsOnly|TestPinnedSDKDrain.*|TestDriverDrainThroughPinnedSDKAndPollHook|TestDrainListenerWithdrawsWhilePollResponseIsHeld|TestDrainCancellationStopsBeforeReleasingHeldResponse)$' -count=1 -v -timeout=180s -go_test_checked 34 a79b7fa367d8eb1e7fe4ee4ef637696518946dab6f2f25410f6e04bfba137298 drain-pr72-race experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -race ./livecanary -run '^(TestBaselineAcquireTargetIsActionsOnly|TestPinnedSDKDrain.*|TestDriverDrainThroughPinnedSDKAndPollHook|TestDrainListenerWithdrawsWhilePollResponseIsHeld|TestDrainCancellationStopsBeforeReleasingHeldResponse)$' -count=1 -v -timeout=180s +go_test_checked 9 9aef95c84ffd42ad632040498c628c78072ce94f5cc2f6af8493fcffc233b707 ack-root experiments/g01-scaleset:. default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s -run '^(TestSDKACKBoundaries|TestSDKDemandAboveFiftyAndPartialAcquisition|TestSDKRepeatedStatisticsAnd202ReuseLastObservation|TestRecoveryAfterACKCallbackCrash|TestRecoveryMissingLifecycleCallback|TestRecoveryErrorsHoldReservationsAndRedact|TestSDKAcquisitionResponseLossAfterACK|TestSDKCapacityWithdrawalDoesNotFenceInFlightAcquisition|TestSDKHTTPFailuresAndSessionRefresh)$' . +go_test_checked 5 f95a296947af0fb862e8b447d3e27b7ce9f01e726659f5f87393789b62b783c9 ack-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestSupportedListenerBarriersAndReservation|TestDriverBarriersThroughPinnedSDK|TestForeignIdentityAndUnreviewedWorkNeverACKOrDelete|TestAuditPR25MultiJobAcquisitionMustRefuseBeforeACK|TestNoMessageDoesNotCountAsCompletedBarrier)$' + +go_test_checked 24 eae489a7d743c942dca803c9b13cb618dcd87fbd9a53634f022bc5381c7d6436 baseline experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=180s ./livecanary -run '^TestBaseline' +go_test_checked 9 b6a378d192ba2c614cd82c20eab7cffcdbbb00115a1bba4da803fc262a1c3d8f admission-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=180s ./livecanary -run '^(TestAdmission.*|TestAuditPR25DistinctStateDirectoriesMustShareCap)$' +go_test_checked 8 4bce3c998c4f2e6806888de2ea9936c6dd9ea877c72b190d47f4f47e24cdb559 admission-liveworker experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=180s ./liveworker -run '^(TestAdmissionDirectoryUsesOSAccountWithoutEnvironmentFallback|TestAdmissionAuthorityChecksTheCurrentClaim|TestWorkerAdmissionCapsIndependentDirectories|TestWorkerAdmissionRetainsSlotAfterOutcomeAndClose|TestAdmissionRejectsCopiedJournalInDifferentDirectory|TestAdmissionSyncFailureMustBeRetried|TestAdmissionRefusesMissingUnsafeOrUnknownRootState|TestAdmissionInitializationLockPrecedesClaimCreation)$' +go_test_checked 1 e7cdff09074beb49efb17b8e68201798140ea6f0e83ad10f3fc096aed98823ce unsupported-liveworker experiments/g01-scaleset:./liveworker osusergo+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -tags=osusergo -count=1 -timeout=180s ./liveworker -run '^TestUnsupportedAccountLookupRefusesBeforeJournal$' +go_test_checked 1 e7cdff09074beb49efb17b8e68201798140ea6f0e83ad10f3fc096aed98823ce unsupported-livecanary experiments/g01-scaleset:./livecanary osusergo+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -tags=osusergo -count=1 -timeout=180s ./livecanary -run '^TestUnsupportedAccountLookupRefusesBeforeJournal$' + +go_test_checked 3 0f5a8b0633982f8cc04a541cb396c46736c6eec486622354a6456554c52cfc75 jit-root experiments/g01-scaleset:. default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s -run '^(TestSDKJITLookupBeforeCreationDoesNotDiscoverIdentity|TestSDKJITResponseLossWithoutCommitDoesNotDiscoverIdentity|TestSDKJITResponseLossDiscoversIdentityWithoutReissuing)$' . +go_test_checked 2 42eac255f830436a6fba9457bcb1964e8f9f596761fecfdb721c2d34b684f4cb jit-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestJITLostResponseIsSecretSafeAndNeverReissued|TestDriverBarriersThroughPinnedSDK)$' + +go_test_checked 34 fcdcde4fce2efa204fdb352c035329a010f7a41b4b9746bf748b2ebc22b4d330 worker-runtime experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./liveworker -run '^(TestOneWorkerNeverRecreatedAndOnlyJITAddedToEnvironment|TestUncertainStartNeverRetriesAndCannotCleanup|TestEveryRuntimeBoundaryRejectsProfileAndOwnershipMismatch|TestUnverifiedRunnerUpdatePolicyRefusesBeforeRuntime|TestSocketReplacementAfterPreflightCannotReceiveAnyMutation|TestSocketModesAndControllerOwnership|TestSocketPostConnectRecheckClosesBeforeHTTP|TestNoCreateBeforeDurableIntent|TestUnknownCreateNeverRetriesAfterRestart|TestCreationWarningsPreserveKnownIDWithoutAuthorizingStart|TestWorkerPreparationReturnsCanonicalSnapshotAndRejectsPriorEffect|TestUnixInspectRequiresStateFlagsBeforeMutation|TestDockerInspectExact(KnownStatesAndSerializableFacts|StatePresenceAndLegacyRequirements|RejectsMalformedOrAmbiguousBodiesBeforeMutation|NotFoundReportsOnlyTheExactGET|RejectsOtherResponsesAndInvalidTargets|RequiresSupported404Body|CancellationNeverReportsPresenceOrAbsence|RejectsReplacedSocket|EOFCancellationKeepsUnknownOutcome)|TestDockerInspectLegacyCleanupKeepsSignedAndAbsentExitPolicy|TestDockerInspectMapsPreserveCaseSensitiveKeysAndProfile|TestDockerCompletedMutationResponseSurvivesEOFCancellation|TestDockerInspectUnknownOrAbsentStatusCannotAuthorizeMutation|TestUnixRuntimeRejectsWrongIdentityImagesAndUnsupportedLimits|TestUnixRuntimeOneShotCreateStartAndNonForceCleanup|TestUnixRuntimeAmbiguousEffectsNeverRetry|TestChangedDaemonCannotCreate|TestChangedDaemonOrAbsentContainerNeverMeansCleanupComplete|TestUnixTransportRejectsSymlinksAndInheritedTCPDestinations|TestAuditPR28MissingBridgeMustNotStart|TestCleanupRetainsActiveAndUnknownWorkers|TestOwnedTerminalCleanupAndRunningRemovalRace)$' +go_test_checked 4 8a0a576036768baff9a9b7d53b3487ef84b2e95f502b3ca9dd453463f396cade update-policy experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestCreateRequestsDisabledRunnerUpdate|TestUnconfirmedUpdateSettingQuarantinesCreate|TestUpdateSettingDriftStopsBeforeSessionOrJIT|TestUpdateSettingDriftDoesNotBlockSafeEmptyCleanup)$' +go_test_checked 2 2614438aa4873bd044d1f0d6247b929403e2225c2e5112c13787bf790af42413 worker-command experiments/g01-scaleset:./cmd/g01-worker g01_worker+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -tags=g01_worker -count=1 -timeout=45s ./cmd/g01-worker -run '^(TestBlockedJITInputHonorsDeadline|TestOfflinePlanAndRefusalDoNotReadSecretsOrEchoInput)$' + +go_test_checked 45 2e182d6aeb4c278eddbe272be1693e6bf0143759c23c03348435da59addfde53 reconciliation experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestAmbiguousCreateNeverRetriesAfterRestart|TestObserve.*|TestObservationIntentFailureStopsBeforeRead|TestObservationResponseCaptureIsLocalAndRejectsOtherOperations|TestStatistics.*|TestDemandStatisticsAllowControlledProbeButNeverCleanup|TestInvalidOwnedProof.*|TestCanonicalPreparationRecordsNoPhaseOrRemoteIntent|TestCanonicalPreparationRefusesInvalidJournalAndPhase|TestCanonicalPreparationRecoveryAndDriverShareLocalGate|TestJournal.*|TestAuthority.*|TestInventoryStrictPages|TestInventoryMalformedStopsLegacyEffects|TestInventoryTransportRefusalIsBoundedAndSanitized|TestInventoryImpossibleTotalStopsBeforeNextPage|TestRosterActualTLSCompleteObservation|TestRosterPreservesOnlyAcceptedPagePrefix|TestRosterFinalPublicationGuardAfterDigest|TestRosterRefusesInvalidEntryWithoutNetwork|TestStrictJSONRejectsDecoderEquivalentDuplicateFields|TestStrictJSONRejectsDuplicateAuthorityFields|TestFailedDirectorySyncMustBeRetried|TestFileJournalRejectsUnrecordedAuthorityBeforeRawDriverEffect|TestRenewedRecoveryApprovalRetainsOwnedState)$' +go_test_checked 9 47a97f1f9083abbe72dfc21a2477bd25580684506fbf71bf5aba9e7f6f1a40e8 reconciliation-worker experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./liveworker -run '^(TestPrivateJournalLocksAndRetainsReservationAcrossRestart|TestJournalRejectsChangedApprovalTornTailAndUnsafeFiles|TestAuthorityLeaseRefusesConcurrentRunsAndFencesClose|TestAuthorityRejectsReplacedJournalOrDirectory|TestStrictJSONRejectsDecoderEquivalentDuplicateFields|TestStrictInputRejectsAmbiguousOrExtraAuthorityFields|TestFailedDirectorySyncMustBeRetried|TestFileJournalRejectsUnrecordedAuthorityBeforeRawDriverEffect|TestRenewedRecoveryApprovalRetainsOwnedState)$' +go_test_checked 11 84b872130057cf2103feaa8f7c6cdb69a4d779e135eebf11cc04cb00590eacb9 reconciliation-quarantine experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestDemandStatisticsAllowControlledProbeButNeverCleanup|TestCleanupOnlyForNeverIssuedWorkerWithExactReceipt|TestZeroStatisticsAndOptionalAbsencePermitEmptyCleanup|TestUnsafeStatisticsStopNewEffectsBeforeControlledMessage|TestEmptyAvailableWithWorkStatisticsStaysQuarantined|TestOlderPendingIntentSurvivesSuccessfulZeroInspection|TestUnownedDiscoveryEvidenceCannotAuthorizeCreationAfterAbsence|TestAuditPR25ObservedJobsMustBlockCleanup|TestUnexpectedWorkMessageQuarantinesBeforeSafeClose|TestObservedRunnerSurvivesLaterAbsenceAndFirstCleanup|TestObservationResultFailureSurvivesFileReopenAndInspection)$' + +go_test_checked 1 be0f21a92ce13d75e85a1182706e84b84884a8cf0ec01172e14e5ac2e04b600f secret-root experiments/g01-scaleset:. default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s -run '^TestSDKBusyRemovalSentinelAndRawErrorExposure$' . +go_test_checked 6 6d0b3fc255928a6c7a5f7ae087d25715f96c0bad9f9298b5fb08ea7bc5fc34df secret-livecanary experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./livecanary -run '^(TestHTTPErrorsDoNotReturnSecretResponseBody|TestSDKHTTPDebugDoesNotLogCredentials|TestSharedTransportRejectsOversizeSuccessAndErrorBodies|TestResponseReaderConsumesOnlyBudgetPlusOneAndRejectsTruncation|TestResponseBudgetAppliesAfterGzipDecompression|TestRealJournalCreateFailureBlocksRetryAndContainsNoErrorBody)$' +go_test_checked 3 feda1bf98e323aef3e209cc4ea2765131ca7d17d70dfd7bb4828c0faab14dff4 secret-liveworker experiments/g01-scaleset:./liveworker default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -count=1 -timeout=45s ./liveworker -run '^(TestUncertainStartNeverRetriesAndCannotCleanup|TestUnixResponsesAreBoundedAndRedirectsNeverFollowed|TestObservationDoesNotJournalRawRuntimeStatus)$' + +go_test_checked 10 7ced37498c6790e0a1276ffece4cf0bcb2cad09f39da3ebdd0e28694f752596c live-command experiments/g01-scaleset:./cmd/g01-live g01_live+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -tags=g01_live -count=1 -timeout=45s ./cmd/g01-live -run '^(TestPlanAndRefusalsNeverReadCredentialsOrEchoInputs|TestPreparationCommandNeverReadsCredentialsOrRunsRemotePhase|TestPairedTerminalModeReadsControllerInputAfterAllGates|TestPairedTerminalModeRejectsUnusedPhaseAndControllerFlagsBeforeInput|TestPairedTerminalModeRequiresWorkflowVerificationAuthorityBeforeInput|TestInheritedNamedCredentialFIFODelayedEOF|TestInheritedCredentialPipeStopsAtDeadline|TestCredentialInputRejectsNonPipeDescriptor|TestBlockedCredentialPipeStopsAtDeadline|TestCredentialInputAcceptsCompleteAndRejectsOversize)$' +go_test_checked 4 a751c8b1a6a22d2f4ce76d90d6d79c48ab377355deed0f3bf189496c22b5492e live-transport experiments/g01-scaleset:./livecanary g01_live+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race -tags=g01_live -count=1 -timeout=45s ./livecanary -run '^(TestAuthoritySplitAndPolicyRejection|TestSDKTransportOwnership|TestCredentialAttestationMismatchAndExpiredTokenRejected|TestTransportRejectsPlaintextOffHostAndProxyBeforeNetwork)$' + +go_test_checked 34 a79b7fa367d8eb1e7fe4ee4ef637696518946dab6f2f25410f6e04bfba137298 drain-pr72 experiments/g01-scaleset:./livecanary default+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset ./livecanary -run '^(TestBaselineAcquireTargetIsActionsOnly|TestPinnedSDKDrain.*|TestDriverDrainThroughPinnedSDKAndPollHook|TestDrainListenerWithdrawsWhilePollResponseIsHeld|TestDrainCancellationStopsBeforeReleasingHeldResponse)$' -count=1 -v -timeout=180s +go_test_checked 34 a79b7fa367d8eb1e7fe4ee4ef637696518946dab6f2f25410f6e04bfba137298 drain-pr72-race experiments/g01-scaleset:./livecanary default+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -race ./livecanary -run '^(TestBaselineAcquireTargetIsActionsOnly|TestPinnedSDKDrain.*|TestDriverDrainThroughPinnedSDKAndPollHook|TestDrainListenerWithdrawsWhilePollResponseIsHeld|TestDrainCancellationStopsBeforeReleasingHeldResponse)$' -count=1 -v -timeout=180s terminal_heavy_tests='^TestPairedTerminal(FinalResultCapacity|PendingChildCapacity|EligibilityUsesFreshExactFacts|CapturedAcknowledgementCancellation|MissingAcknowledgementsAndPostchecks)$' terminal_remainder_skip='^TestPairedTerminal(FinalResultCapacity|PendingChildCapacity|EligibilityUsesFreshExactFacts|CapturedAcknowledgementCancellation|MissingAcknowledgementsAndPostchecks|Actual(Controller|Worker)SyncFailures|PostIntent(JournalIdentity|AuthorityBoundaries)|ClosedReplayActualFile|WorkerReceiptSurvivesControllerWriteFailure|FixtureStorageFailure)$' terminal_storage_tests='^TestPairedTerminal(Actual(Controller|Worker)SyncFailures|PostIntent(JournalIdentity|AuthorityBoundaries)|ClosedReplayActualFile|WorkerReceiptSurvivesControllerWriteFailure|FixtureStorageFailure)$' -go_test_checked 22 f3efc29451112b27d2cb590763612790db48f05b54718b1e023196ead6625222 paired-collection experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run '^TestPaired' -skip '^TestPairedTerminal' -go_test_checked 106 bdeef850cfe820297e3896477219f0fcf90046bc30fb0bd454bfd0f8fe6db2ff paired-all-except experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -skip '^TestPaired' -go_test_checked 24 b7f253f33da157adba67fe4312eddb0ee90172e24f46d4f48502890ab5f080f5 paired-worker experiments/g01-scaleset:./liveworker g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./liveworker -run '^TestPaired' -go_test_checked 5 9e1a2e099c0fc48bbbd83dd0dc798d3c954910cd150b210037741b2c70211952 paired-heavy experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run "$terminal_heavy_tests" -go_test_checked 15 5e5dd1ee80d3d303271ab17b17aed22d5084e15684bcbe9f31152c1092ec1f73 paired-remainder experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run '^TestPairedTerminal' -skip "$terminal_remainder_skip" -go_test_checked 6 458f77f55209a59338a63bfc27697d85ebe5e0c3c7d1b959a0b56b2527f3ead5 paired-storage experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run "$terminal_storage_tests" -go_vet_checked vet-livecanary experiments/g01-scaleset:./livecanary g01_pair_fixture+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go vet -C experiments/g01-scaleset -tags=g01_pair_fixture ./livecanary -go_vet_checked vet-liveworker experiments/g01-scaleset:./liveworker g01_pair_fixture+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.8 \ - GOTOOLCHAIN=go1.26.8 go vet -C experiments/g01-scaleset -tags=g01_pair_fixture ./liveworker +go_test_checked 22 f3efc29451112b27d2cb590763612790db48f05b54718b1e023196ead6625222 paired-collection experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run '^TestPaired' -skip '^TestPairedTerminal' +go_test_checked 106 bdeef850cfe820297e3896477219f0fcf90046bc30fb0bd454bfd0f8fe6db2ff paired-all-except experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -skip '^TestPaired' +go_test_checked 24 b7f253f33da157adba67fe4312eddb0ee90172e24f46d4f48502890ab5f080f5 paired-worker experiments/g01-scaleset:./liveworker g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./liveworker -run '^TestPaired' +go_test_checked 5 9e1a2e099c0fc48bbbd83dd0dc798d3c954910cd150b210037741b2c70211952 paired-heavy experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run "$terminal_heavy_tests" +go_test_checked 15 5e5dd1ee80d3d303271ab17b17aed22d5084e15684bcbe9f31152c1092ec1f73 paired-remainder experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run '^TestPairedTerminal' -skip "$terminal_remainder_skip" +go_test_checked 6 458f77f55209a59338a63bfc27697d85ebe5e0c3c7d1b959a0b56b2527f3ead5 paired-storage experiments/g01-scaleset:./livecanary g01_pair_fixture+race+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go test -C experiments/g01-scaleset -tags=g01_pair_fixture -race -count=1 -timeout=120s ./livecanary -run "$terminal_storage_tests" +go_vet_checked vet-livecanary experiments/g01-scaleset:./livecanary g01_pair_fixture+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go vet -C experiments/g01-scaleset -tags=g01_pair_fixture ./livecanary +go_vet_checked vet-liveworker experiments/g01-scaleset:./liveworker g01_pair_fixture+norace+cgo1+cgo-cc-clang-apple21.0.0+goexperiment-none+darwin-arm64-goarm64-v8.0+goroot-default+gofips140-off+go1.26.9 \ + GOTOOLCHAIN=go1.26.9 go vet -C experiments/g01-scaleset -tags=g01_pair_fixture ./liveworker ``` The packet also audits every future shell `go test` selector prescription @@ -38968,3 +38968,40 @@ Ran 4 tests in 8.394s — OK; real 8.48s ``` The controls retain benign `*callbacks` and `**callbacks` echo calls, variadic shadowing, temporary-owned Path mutation, the string transformation, the reviewed scanner namespace and the stored Git stream collector. The coordinator's earlier full packet AST selector on immutable `62e0fe6b` passed one scan in 511.238s (261 shell blocks, 86 Python blocks, zero violations); it is historical parent evidence and does not qualify this candidate. No full suite, packet selector, 119-helper sweep, compiler self-audit, Go/hosted CI or live operation was run for this correction. The new cases cover the reproduced variadic transfers only; other unmodeled Python helper or container flows are not claimed closed. Rollback is to revert this local two-file commit; earlier commits remain untouched. + +#### Go 1.26.9 current-profile requalification from local `36e39fb` + +The local shared-Go dependency `36e39fb` moved this checkout's active pins +to Go 1.26.9 (module directives, `GOTOOLCHAIN`, toolchain checker, offline +gate, BuildInfo admission). The packet's current profile still declared Go +1.26.8, so a static RED reproduced the incompatibility before the fix: the +new `scripts/evidence_packet/packet_toolchain_profile_test.py` asserts the +pins-table row, the wrapper `reviewed_toolchain` assignment (AST-inspected +as data, never executed), and every wrapper-fence toolchain version agree +with the branch pins, and all four checks failed against the stale packet +(`'go1.26.8' != 'go1.26.9'`, 4 tests FAILED, no packet code executed). + +The minimal fix updates only the current profile: the pins-table Go +toolchain row (now recording `go 1.26.9` current with the `go 1.26.8` pair +as historical), the two changed-boundary ledger toolchain references, the +wrapper build-identity comment, `reviewed_toolchain = "go1.26.9"`, and the +28 `go_test_checked` plus 2 `go_vet_checked` prescription identities and +commands (packet lines 3076-3146, including the historical-tree +`drain-pr72` pair so the wrapper admits them). 65 current-profile lines now +carry `go1.26.9` (pins row, 2 ledger rows, wrapper comment, +`reviewed_toolchain`, 60 prescription lines); the 106 pre-existing +historical `go1.26.8` lines (dated RED/GREEN records, finding ledgers with +immutable SHAs, recorded audit probes, scanner specimens, environment +snapshots) are preserved byte-identical as historical evidence (counts +exclude this entry's own prose mentions). No blanket replacement was +made. The G01 live-canary checklist and runner checkpoint addendum were +deliberately not copied here; they remain G01-owned evidence. + +Focused GREEN (static only, no packet execution): the four profile checks +pass; `git diff --check` is clean; the added-lines scan found no +credential/token/private-key or personal-path patterns; no new Markdown +links were introduced. The issue-79 scanner regression suite was not rerun: +it contains no toolchain assertions and shares no changed surface with +these version tokens. No Go suite, packet execution, hosted CI, review, or +live operation is claimed. Rollback is to revert this local packet commit; +the `36e39fb` dependency and earlier commits remain untouched. diff --git a/scripts/evidence_packet/packet_toolchain_profile_test.py b/scripts/evidence_packet/packet_toolchain_profile_test.py new file mode 100644 index 00000000..3787889e --- /dev/null +++ b/scripts/evidence_packet/packet_toolchain_profile_test.py @@ -0,0 +1,120 @@ +"""Static current-profile toolchain agreement for the G01 evidence packet. + +Inert-text checks only. The packet markdown and branch pin files are read as +data; one wrapper assignment line is inspected with ``ast.parse`` (parse-only, +never exec/compile/eval/import). Packet recipes, specimens, and audit blocks +are never executed, compiled, evaluated, or imported. No subprocesses. + +Trust boundary: stdlib only (unittest/re/ast/pathlib), file reads under the +repository root, no network, no shell, no Git, no packet execution. +""" + +from __future__ import annotations + +import ast +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PACKET_PATH = ROOT / "docs" / "evidence" / "g01-recovery-packet.md" +GO_MOD_PATH = ROOT / "go.mod" +CHECK_TOOLCHAIN_PATH = ROOT / "scripts" / "check-toolchain.sh" + + +def branch_go_directive() -> str: + text = GO_MOD_PATH.read_text(encoding="utf-8") + match = re.search(r"^go (\d+\.\d+\.\d+)$", text, re.MULTILINE) + assert match is not None, "root go.mod directive not found" + return match.group(1) + + +def branch_expected_toolchain() -> str: + text = CHECK_TOOLCHAIN_PATH.read_text(encoding="utf-8") + match = re.search(r'^expected_toolchain="(go\d+\.\d+\.\d+)"$', text, re.MULTILINE) + assert match is not None, "check-toolchain.sh expected_toolchain not found" + return match.group(1) + + +def wrapper_fence_text(packet: str) -> str: + anchor = packet.index('reviewed_toolchain = "go') + fence_start = packet.rfind("```sh\n", 0, anchor) + if fence_start < 0: + raise AssertionError("packet wrapper fence start not found") + fence_end = packet.index("\n```\n", anchor) + return packet[fence_start:fence_end] + + +def reviewed_toolchain_assignment(packet: str) -> str: + match = re.search(r'^reviewed_toolchain = "go\d+\.\d+\.\d+"$', packet, re.MULTILINE) + assert match is not None, "reviewed_toolchain assignment not found" + return match.group(0) + + +class PacketToolchainProfileTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.packet = PACKET_PATH.read_text(encoding="utf-8") + cls.branch_directive = branch_go_directive() + cls.branch_toolchain = branch_expected_toolchain() + assert cls.branch_toolchain == "go" + cls.branch_directive, ( + f"branch pins disagree: go.mod {cls.branch_directive} vs " + f"check-toolchain.sh {cls.branch_toolchain}" + ) + + def test_pins_table_matches_branch(self) -> None: + match = re.search( + r"\| Go toolchain \| Current experiment module directive `go (\d+\.\d+\.\d+)`; " + r"verification toolchain `GOTOOLCHAIN=(go\d+\.\d+\.\d+)`", + self.packet, + ) + self.assertIsNotNone(match, "current pins-table Go toolchain row not found") + assert match is not None + self.assertEqual(match.group(1), self.branch_directive) + self.assertEqual(match.group(2), self.branch_toolchain) + + def test_wrapper_reviewed_toolchain_matches_branch(self) -> None: + line = reviewed_toolchain_assignment(self.packet) + module = ast.parse(line) + self.assertEqual(len(module.body), 1) + statement = module.body[0] + self.assertIsInstance(statement, ast.Assign) + assert isinstance(statement, ast.Assign) + self.assertEqual(len(statement.targets), 1) + target = statement.targets[0] + self.assertIsInstance(target, ast.Name) + assert isinstance(target, ast.Name) + self.assertEqual(target.id, "reviewed_toolchain") + value = statement.value + self.assertIsInstance(value, ast.Constant) + assert isinstance(value, ast.Constant) + self.assertEqual(value.value, self.branch_toolchain) + + def test_prescription_toolchains_match_branch(self) -> None: + fence = wrapper_fence_text(self.packet) + versions = re.findall(r"go(\d+\.\d+\.\d+)", fence) + self.assertGreater(len(versions), 0, "no toolchain versions in wrapper fence") + for version in versions: + self.assertEqual( + "go" + version, + self.branch_toolchain, + "wrapper-fence toolchain differs from branch pin", + ) + + def test_historical_toolchain_records_preserved(self) -> None: + fence = wrapper_fence_text(self.packet) + outside = self.packet.replace(fence, "") + historical = re.findall(r"go1\.26\.8", outside) + self.assertGreater( + len(historical), 0, "historical go1.26.8 records were removed" + ) + current_old = re.findall(r"go1\.26\.8", fence) + self.assertEqual( + current_old, + [], + "stale go1.26.8 remains in the current-profile wrapper fence", + ) + + +if __name__ == "__main__": + unittest.main() From 13867b4859cc8b48824b9981271feda449861610 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 16:28:54 +0900 Subject: [PATCH 090/123] Memoize scanner self-audit hot paths, fix smoke hang Fix test_current_packet_has_no_static_scanner_violations hang (>600s, no result) caused by superlinear reflective analysis when the packet scanner audits itself (BODY 19, 620KB, 309 top-level stmts, byte-identical since 8de8e07; pre-existing #79 reflective-hardening state, not the f362d54 toolchain change). Minimal semantics-preserving memoization in python_filesystem_mutator_alias_violation only: per-invocation caches for enclosing-scope resolution (32 explicit call sites), known_collection_constructor (pure, keyed by node+seen), and reflective_value_flows True outcomes (sound by fixpoint monotonicity; False recomputed as bindings grow). No policy/logic change; no globals() (self-audit rejects mutable-global access); loader validation unchanged (nested defs only, clean defaults, no new imports). RED (pre-fix, f362d54): BODY 19 >300s timeout; prefix 140 = 50.7s; profile prefix 100 = 11.2s / 85M calls via reflective_value_flows + known_collection_constructor + ast.walk; full suite blocked. GREEN (post-fix): smoke ok, zero violations (261 shell + 86 bodies) in 230s; compiler self-audit 654s -> 216s; toolchain profile 4/4 still pass (0.006s); full suite 174/176 with the 2 remaining path-reader FAILs byte-identical pre/post (pre-existing, out of scope for this unit). Writer: Cursor Agent, Muse Spark 1.3 Max, selected by current user override. Independent and GitHub review not requested for this local work. No push/PR/merge/live op. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 97 ++++++++++++++++++---------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 511cbc2f..a8a81523 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16848,6 +16848,17 @@ def python_known_non_path_set_copy_receiver(node, tree, parents, active=None): def python_filesystem_mutator_alias_violation(tree, parents): """Reject extracted mutator methods unless their receiver is temp-owned.""" + _collection_constructor_cache = {} + _reflective_flow_true_cache = set() + _enclosing_scope_cache = {} + def _cached_enclosing_scope(node, parents): + key = id(node) + try: + return _enclosing_scope_cache[key] + except KeyError: + scope = python_enclosing_scope(node, parents) + _enclosing_scope_cache[key] = scope + return scope shutil_names = python_assigned_module_names(tree, "shutil") getattr_aliases = {"getattr"} alias_edges = [] @@ -16993,7 +17004,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): targets, value = [candidate.target], candidate.iter else: continue - binding_scope = python_enclosing_scope(candidate, parents) + binding_scope = _cached_enclosing_scope(candidate, parents) for target in targets: assignment_records.extend( (binding_scope, stored_name.id, value) @@ -17012,13 +17023,13 @@ def python_filesystem_mutator_alias_violation(tree, parents): "dict", "frozenset", "list", "set", "tuple" } - def known_collection_constructor(node, seen=None): + def _known_collection_constructor_uncached(node, seen=None): if seen is None: seen = set() if not isinstance(node, ast.Name): return False for scope in python_lexical_scope_chain( - python_enclosing_scope(node, parents), parents + _cached_enclosing_scope(node, parents), parents ): binding = (id(scope), node.id) if binding in seen: @@ -17038,17 +17049,17 @@ def python_filesystem_mutator_alias_violation(tree, parents): node.id in declaration.names for declaration in ast.walk(scope) if isinstance(declaration, (ast.Global, ast.Nonlocal)) - and python_enclosing_scope(declaration, parents) is scope + and _cached_enclosing_scope(declaration, parents) is scope ): return False for candidate in ast.walk(scope): candidate_scope = ( - python_enclosing_scope(parents.get(candidate), parents) + _cached_enclosing_scope(parents.get(candidate), parents) if isinstance( candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef), ) - else python_enclosing_scope(candidate, parents) + else _cached_enclosing_scope(candidate, parents) ) if candidate_scope is not scope: continue @@ -17068,7 +17079,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): if isinstance( candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) ) and candidate.name == node.id and ( - python_enclosing_scope(parents.get(candidate), parents) is scope + _cached_enclosing_scope(parents.get(candidate), parents) is scope ): return False values = [ @@ -17085,12 +17096,21 @@ def python_filesystem_mutator_alias_violation(tree, parents): isinstance(candidate, ast.Name) and candidate.id == node.id and isinstance(candidate.ctx, ast.Store) - and python_enclosing_scope(candidate, parents) is scope + and _cached_enclosing_scope(candidate, parents) is scope for candidate in ast.walk(scope) ): return False return node.id in collection_constructor_names + def known_collection_constructor(node, seen=None): + key = (id(node), frozenset(seen) if seen else frozenset()) + try: + return _collection_constructor_cache[key] + except KeyError: + result = _known_collection_constructor_uncached(node, seen) + _collection_constructor_cache[key] = result + return result + holder_methods = { "add", "append", "extend", "insert", "setdefault", "update" } @@ -17126,17 +17146,17 @@ def python_filesystem_mutator_alias_violation(tree, parents): name in declaration.names for declaration in ast.walk(scope) if isinstance(declaration, (ast.Global, ast.Nonlocal)) - and python_enclosing_scope(declaration, parents) is scope + and _cached_enclosing_scope(declaration, parents) is scope ): return False for candidate in ast.walk(scope): candidate_scope = ( - python_enclosing_scope(parents.get(candidate), parents) + _cached_enclosing_scope(parents.get(candidate), parents) if isinstance( candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef), ) - else python_enclosing_scope(candidate, parents) + else _cached_enclosing_scope(candidate, parents) ) if candidate_scope is not scope: continue @@ -17160,7 +17180,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): if isinstance( candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) ) and candidate.name == name and ( - python_enclosing_scope(parents.get(candidate), parents) is scope + _cached_enclosing_scope(parents.get(candidate), parents) is scope ): return True return False @@ -17173,7 +17193,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): set(declaration.names) for declaration in ast.walk(scope) if isinstance(declaration, declaration_type) - and python_enclosing_scope(declaration, parents) is scope + and _cached_enclosing_scope(declaration, parents) is scope )) for declaration_type in (ast.Global, ast.Nonlocal) } @@ -17249,7 +17269,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): return False seen.add(node.id) for scope in python_lexical_scope_chain( - python_enclosing_scope(node, parents), parents + _cached_enclosing_scope(node, parents), parents ): if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): arguments = scope.args @@ -17266,7 +17286,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): node.id in declaration.names for declaration in ast.walk(scope) if isinstance(declaration, (ast.Global, ast.Nonlocal)) - and python_enclosing_scope(declaration, parents) is scope + and _cached_enclosing_scope(declaration, parents) is scope ): return False values = [ @@ -17281,7 +17301,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) and bool(python_static_string_values(node, tree)) for candidate in ast.walk(scope): if isinstance(candidate, (ast.Import, ast.ImportFrom)) and ( - python_enclosing_scope(candidate, parents) is scope + _cached_enclosing_scope(candidate, parents) is scope ): imported_names = ( [ @@ -17295,13 +17315,13 @@ def python_filesystem_mutator_alias_violation(tree, parents): return False if isinstance(candidate, ast.ExceptHandler) and ( candidate.name == node.id - and python_enclosing_scope(candidate, parents) is scope + and _cached_enclosing_scope(candidate, parents) is scope ): return False if isinstance( candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) ) and candidate.name == node.id and ( - python_enclosing_scope(parents.get(candidate), parents) is scope + _cached_enclosing_scope(parents.get(candidate), parents) is scope ): return False return False @@ -17311,13 +17331,13 @@ def python_filesystem_mutator_alias_violation(tree, parents): def reflective_name_is_bound(node): if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load): return False - use_scope = python_enclosing_scope(node, parents) + use_scope = _cached_enclosing_scope(node, parents) return any( (scope, node.id) in reflective_bindings for scope in python_lexical_scope_chain(use_scope, parents) ) - def reflective_value_flows(node): + def _reflective_value_flows_uncached(node): if unresolved_reflective_result(node): return True if isinstance(node, ast.Name): @@ -17369,6 +17389,15 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) return False + def reflective_value_flows(node): + key = id(node) + if key in _reflective_flow_true_cache: + return True + result = _reflective_value_flows_uncached(node) + if result: + _reflective_flow_true_cache.add(key) + return result + def transfer_capable_argument(node, scope): if isinstance(node, ast.Name): return bool(holder_receivers_for_alias(node.id, scope)) or ( @@ -17383,7 +17412,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): candidate.func, ast.Name ): continue - scope = python_enclosing_scope(candidate, parents) + scope = _cached_enclosing_scope(candidate, parents) if not python_local_function_candidates( candidate.func.id, candidate, tree, parents ): @@ -17420,7 +17449,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): ] ): continue - scope = python_enclosing_scope(candidate, parents) + scope = _cached_enclosing_scope(candidate, parents) if ( isinstance(candidate.func, ast.Attribute) and candidate.func.attr in holder_methods @@ -17482,7 +17511,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): function_nodes = [ candidate for candidate in ast.walk(function) - if python_enclosing_scope(candidate, parents) is function + if _cached_enclosing_scope(candidate, parents) is function ] for _ in range(len(function_nodes) + 1): changed = False @@ -17500,7 +17529,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): for source in ast.walk(value) if isinstance(source, ast.Name) and isinstance(source.ctx, ast.Load) - and python_enclosing_scope(source, parents) is function + and _cached_enclosing_scope(source, parents) is function )) if not origins: continue @@ -17554,17 +17583,17 @@ def python_filesystem_mutator_alias_violation(tree, parents): name in declaration.names for declaration in ast.walk(scope) if isinstance(declaration, ast.Global) - and python_enclosing_scope(declaration, parents) is scope + and _cached_enclosing_scope(declaration, parents) is scope ): return True for candidate in ast.walk(scope): candidate_scope = ( - python_enclosing_scope(parents.get(candidate), parents) + _cached_enclosing_scope(parents.get(candidate), parents) if isinstance( candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef), ) - else python_enclosing_scope(candidate, parents) + else _cached_enclosing_scope(candidate, parents) ) if candidate_scope is not scope: continue @@ -17589,7 +17618,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): if isinstance( candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) ) and candidate.name == name and ( - python_enclosing_scope(parents.get(candidate), parents) is scope + _cached_enclosing_scope(parents.get(candidate), parents) is scope ): return True return False @@ -17600,7 +17629,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): and isinstance(candidate.ctx, ast.Load) ): continue - use_scope = python_enclosing_scope(candidate, parents) + use_scope = _cached_enclosing_scope(candidate, parents) if use_scope is function: continue lexical_scopes = python_lexical_scope_chain(use_scope, parents) @@ -17627,7 +17656,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) else: functions = [] - call_scope = python_enclosing_scope(candidate, parents) + call_scope = _cached_enclosing_scope(candidate, parents) for function in functions: called_parameters = callable_parameter_names(function) if not called_parameters: @@ -17777,7 +17806,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): ) ) ] - target_scope = python_enclosing_scope(node, parents) + target_scope = _cached_enclosing_scope(node, parents) reflective_result_is_called = any( isinstance(candidate, ast.Name) and candidate.id == target.id @@ -17785,7 +17814,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): and isinstance(parents.get(candidate), ast.Call) and parents[candidate].func is candidate and target_scope in python_lexical_scope_chain( - python_enclosing_scope(candidate, parents), parents + _cached_enclosing_scope(candidate, parents), parents ) for candidate in ast.walk(tree) ) @@ -17875,7 +17904,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): f"{method!r} into alias {target.id!r} on line {node.lineno}" ) if method in python_path_mutator_destination_keywords: - alias_scope = python_enclosing_scope(node, parents) + alias_scope = _cached_enclosing_scope(node, parents) alias_calls = [] for candidate in ast.walk(tree): if not ( @@ -17884,7 +17913,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): and isinstance(candidate.ctx, ast.Load) ): continue - candidate_scope = python_enclosing_scope(candidate, parents) + candidate_scope = _cached_enclosing_scope(candidate, parents) if candidate_scope is alias_scope: parent = parents.get(candidate) if not ( From b9a5edee5f1c4fd0be628d57af7a8e810abe8e10 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 16:29:01 +0900 Subject: [PATCH 091/123] Fix nested-collision test specimens to use non-sink factory values Fix test_nested_function_name_collision_does_not_hide_launcher_alias FAIL (category: test's wrong expectation, not scanner defect). The safe specimen returned print (a recognized output sink since 6574b87, after the test's 184701d introduction); sink-returns fail closed per policy, so the scanner correctly flagged line 2 and the test failed identically pre/post the memoization fix. The unsafe body also passed vacuously via its incidental print return instead of the intended launcher alias. Minimal 2-line specimen fix: return print -> return len in both factories (len is not a sink/launcher/mutator; str.upper already accepted). No policy change. RED: FAIL 'stores an imported output sink in an unreviewed holder on line 2 is not None', body flagged only for sink (line 6). GREEN: ok in 0.2s; body flagged for launcher (unresolved command-capable call launch, line 8), safe None. Full suite: this test now in the 174 passes. Writer: Cursor Agent, Muse Spark 1.3 Max, selected by current user override. Independent and GitHub review not requested for this local work. No push/PR/merge/live op. Co-authored-by: Cursor --- scripts/evidence_packet/issue79_regression_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 632b278e..2fb69057 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -956,7 +956,7 @@ def test_nested_function_name_collision_does_not_hide_launcher_alias(self) -> No ' return subprocess.run\n' 'def unrelated_scope():\n' ' def launcher_factory():\n' - ' return print\n' + ' return len\n' 'launch = launcher_factory()\n' 'launch(["gh", "workflow", "run", "ci.yml"])\n' ) @@ -964,7 +964,7 @@ def test_nested_function_name_collision_does_not_hide_launcher_alias(self) -> No safe = ( 'def value_factory():\n' - ' return print\n' + ' return len\n' 'def unrelated_scope():\n' ' def value_factory():\n' ' return str.upper\n' From 5d2e5f0e8d2fdd696f471189784d3f3613104e2d Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 17:15:38 +0900 Subject: [PATCH 092/123] Follow helper-returned member names for benign reader resolution The getattr-reader regression test failed after 0cbe820 over-tightened member-name resolution: a safe specimen reads a benign member name via a local helper return (return reader_name()), which the fixpoint treated as an unresolved dynamic name and flagged as a mutator alias, while the unsafe specimen was already caught by the path-reader check. Resolve helper-returned member names (cycle-guarded) at the three member-resolution call sites so benign readers returned by local helpers resolve to closed strings; keep fail-closed behavior for unresolvable or mutator members. RED: test_path_getattr_readers_follow_local_path_and_member_returns FAIL (pre-fix; broken since 0cbe820, safe specimen flagged as mutator alias). GREEN: same test ok in 0.19s post-fix; unsafe specimen still flagged via the path-reader check; related 89 tests pass (40s); packet smoke test OK (231s, 261 shell commands + 86 bodies, zero violations). Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a8a81523..535c0dfe 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -17265,6 +17265,16 @@ def python_filesystem_mutator_alias_violation(tree, parents): return reflective_member_name_is_closed( node.left, set(seen) ) and reflective_member_name_is_closed(node.right, set(seen)) + if isinstance(node, ast.Call): + if id(node) in seen: + return False + returned = python_local_call_return_values(node, tree, parents) + if not returned: + return False + return all( + reflective_member_name_is_closed(value, set(seen) | {id(node)}) + for value in returned + ) if not isinstance(node, ast.Name) or node.id in seen: return False seen.add(node.id) @@ -17738,6 +17748,14 @@ def python_filesystem_mutator_alias_violation(tree, parents): ): return "Python heredoc passes a reflective result to a local callback" + def _resolved_member_names(member_node): + names = python_static_string_values(member_node, tree) + if names: + return names + return python_static_string_values_from_local_calls( + member_node, tree, parents + ) + for node in ast.walk(tree): assignments = [] if isinstance(node, ast.Assign): @@ -17765,7 +17783,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): and value.func.id in getattr_aliases and len(value.args) >= 2 ): - possible_names = python_static_string_values(value.args[1], tree) + possible_names = _resolved_member_names(value.args[1]) possible_methods = ( possible_names & python_filesystem_mutating_methods ) @@ -17799,7 +17817,7 @@ def python_filesystem_mutator_alias_violation(tree, parents): and isinstance(candidate.func, ast.Name) and candidate.func.id in getattr_aliases and len(candidate.args) >= 2 - and not python_static_string_values(candidate.args[1], tree) + and not _resolved_member_names(candidate.args[1]) and not ( isinstance(parents.get(candidate), ast.Call) and parents[candidate].func is candidate @@ -17854,8 +17872,8 @@ def python_filesystem_mutator_alias_violation(tree, parents): and candidate.func.id in getattr_aliases and len(candidate.args) >= 2 ): - possible_names = python_static_string_values( - candidate.args[1], tree + possible_names = _resolved_member_names( + candidate.args[1] ) possible_methods = ( possible_names & python_filesystem_mutating_methods From db79bde7b40a17740e27f4671e0838bcaaeaa310 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 17:23:01 +0900 Subject: [PATCH 093/123] Accept helper-returned benign readers on reviewed paths The filesystem-reader regression test failed after 2a2ee6c blanket-rejected any local-helper result whose return expression contains a reflective getattr call: a safe factory returning getattr(reviewed_packet_path, "read_text") was flagged identically to the unsafe factory returning a reader over synthetic-private paths. Resolve each helper-returned getattr call before rejecting: skip only calls with a single closed non-mutating member on a reviewed reader path (same python_reviewed_path_reader predicate the reader-alias check uses); keep fail-closed rejection for missing args, unresolved or mutating members, and unreviewed receivers. RED: test_path_filesystem_readers_require_reviewed_paths FAIL (pre-fix; broken since 2a2ee6c, safe reviewed factory flagged as unreviewed helper result). GREEN: same test ok in 0.22s post-fix; all 17 unsafe specimens still flagged; related 90 tests pass (38s); packet smoke test OK (222s, 261 shell commands + 86 bodies, zero violations). Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 38 ++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 535c0dfe..58f6147a 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -17848,12 +17848,40 @@ def python_filesystem_mutator_alias_violation(tree, parents): for returned in python_local_call_return_values( candidate, tree, parents ): - if any( - isinstance(returned_node, ast.Call) - and isinstance(returned_node.func, ast.Name) - and returned_node.func.id in getattr_aliases + returned_getattr_calls = [ + returned_node for returned_node in ast.walk(returned) - ): + if ( + isinstance(returned_node, ast.Call) + and isinstance(returned_node.func, ast.Name) + and returned_node.func.id in getattr_aliases + ) + ] + if not returned_getattr_calls: + continue + for returned_node in returned_getattr_calls: + if len(returned_node.args) < 2: + return ( + "Python heredoc assigns an unreviewed reflective " + f"helper result to alias {target.id!r} on line {node.lineno}" + ) + returned_names = _resolved_member_names( + returned_node.args[1] + ) + if ( + len(returned_names) == 1 + and not ( + returned_names + & python_filesystem_mutating_methods + ) + and python_reviewed_path_reader( + returned_node.args[0], + next(iter(returned_names)), + tree, + parents, + ) + ): + continue return ( "Python heredoc assigns an unreviewed reflective " f"helper result to alias {target.id!r} on line {node.lineno}" From cea81e7f2349c449099ba744ad80b2d4e6b785cb Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 21:23:15 +0900 Subject: [PATCH 094/123] Classify from-imported os/shutil calls through the fail-closed OS guard Codex exact-head review of db79bde (comment 6167225325) showed from-imported but unlisted OS mutators lose module identity: 'from os import removedirs' plus a direct call was accepted while 'os.removedirs(...)' was rejected, and 'from shutil import disk_usage' bypassed the unreviewed-entry-point guard that rejects the direct attribute spelling. Resolve bare-Name call targets through from-import aliases before the unknown-OS classification, so imported names receive exactly the direct spelling verdict: unlisted os calls fail closed, reviewed os calls and 'shutil.which' stay accepted, and already-import-rejected mutators keep single attribution. RED: new test_from_imported_os_and_shutil_calls_use_fail_closed_... fails 4 unsafe assertions pre-fix (removedirs/renames/alias/disk_usage accepted). GREEN: test ok in 0.19s post-fix; related 105 tests pass (31s); packet smoke test OK (240s, 261 shell commands + 86 bodies, zero violations). Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 26 ++++++++++++++++++- .../issue79_regression_test.py | 11 ++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 58f6147a..0e5e3f4a 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16138,6 +16138,13 @@ def python_working_directory_change_violation(tree, parents): 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") + from_import_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 {"os", "shutil"} + for alias in node.names + if alias.name != "*" + } for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) @@ -16172,10 +16179,27 @@ def python_unknown_os_call_violation(tree): if not isinstance(node, ast.Call): continue dotted = python_dotted_name(node.func) + resolved_from_import = ( + dotted is not None + and "." not in dotted + and dotted in from_import_aliases + ) + if resolved_from_import: + dotted = from_import_aliases[dotted] if not dotted or "." not in dotted: continue root, remainder = dotted.split(".", 1) - if root not in os_names: + if root == "shutil" and resolved_from_import: + if ( + dotted == "shutil.which" + or dotted in python_filesystem_mutating_functions + ): + continue + return ( + "Python heredoc calls an unreviewed shutil entry point " + f"{dotted!r} on line {node.lineno}" + ) + if root not in os_names and not resolved_from_import: continue canonical = "os." + remainder if root != "os" and canonical in python_filesystem_mutating_functions: diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 2fb69057..2132a9fa 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6751,6 +6751,17 @@ def test_signal_aliases_require_owned_targets(self) -> None: )) self.assertIsNone(self.inspect('import os\nprint("reviewed")\n')) + def test_from_imported_os_and_shutil_calls_use_fail_closed_classification(self) -> None: + for body in ( + 'from os import removedirs\nremovedirs("/tmp/maintainer-owned/empty/leaf")\n', + 'from os import renames\nrenames("/tmp/a", "/tmp/b")\n', + 'from os import removedirs as wipe\nwipe("/tmp/maintainer-owned/empty/leaf")\n', + 'from shutil import disk_usage\nprint(disk_usage("/"))\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('from shutil import which\nprint(which("git"))\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' From 7712a2f3979a16a21e442051cc01d0f62a49e1a9 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 21:28:33 +0900 Subject: [PATCH 095/123] Propagate command-capable launcher values through callable defaults Codex exact-head review of db79bde (comment 6167225325) showed launcher provenance is erased by parameter defaults: '(lambda launch=os.system: launch("rm ..."))()' and the def/kwonly equivalents were accepted because the launcher index only binds assignment targets, never defaults. Bind def/lambda positional and keyword-only defaults in the launcher index fixpoint: a default resolving to a command function marks the parameter, so inner calls receive the same command-argument verdict as direct assignments. Capture without a call stays accepted. RED: new test_launchers_bound_through_callable_defaults_stay_classified fails 3 unsafe assertions pre-fix (lambda/def/kwonly accepted). GREEN: test ok in 0.19s post-fix; related 66 tests pass (23s); packet smoke test OK (224s, 261 shell commands + 86 bodies, zero violations). Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 21 +++++++++++++++++++ .../issue79_regression_test.py | 13 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 0e5e3f4a..7061326e 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -12821,6 +12821,27 @@ def python_import_bindings(tree): break # A command-capable assignment that cannot be resolved remains mapped to # None and is rejected when called below. + for node in ast.walk(tree): + if not isinstance( + node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) + ): + continue + positional = list(node.args.posonlyargs) + list(node.args.args) + defaults = list(node.args.defaults) + pairs = ( + list(zip(positional[-len(defaults):], defaults)) if defaults else [] + ) + pairs.extend( + (parameter, default) + for parameter, default in zip( + node.args.kwonlyargs, node.args.kw_defaults + ) + if default is not None + ) + for parameter, default in pairs: + resolved = resolve_binding(default) + if resolved in python_command_functions: + functions[parameter.arg] = resolved return modules, functions, unresolved diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 2132a9fa..32a3f276 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6762,6 +6762,19 @@ def test_from_imported_os_and_shutil_calls_use_fail_closed_classification(self) self.assertIsNotNone(self.inspect(body)) self.assertIsNone(self.inspect('from shutil import which\nprint(which("git"))\n')) + def test_launchers_bound_through_callable_defaults_stay_classified(self) -> None: + for body in ( + 'import os\n(lambda launch=os.system: launch("rm -rf /tmp/maintainer-owned"))()\n', + 'import os\ndef f(launch=os.system):\n launch("rm -rf /tmp/maintainer-owned")\nf()\n', + 'import subprocess\ndef f(run=subprocess.run):\n run(["gh", "workflow", "run", "ci.yml"])\nf()\n', + 'import os\ndef f(*, launch=os.system):\n launch("rm -rf /tmp/maintainer-owned")\nf()\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('def f(launch=None):\n print("reviewed")\nf()\n')) + self.assertIsNone(self.inspect('def f(fn=len):\n print(fn("reviewed"))\nf()\n')) + self.assertIsNone(self.inspect('import os\ndef f(launch=os.system):\n print("reviewed")\nf()\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' From 6f61a40945b9277454c5fe8d68b4b4068a0f2827 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 21:36:21 +0900 Subject: [PATCH 096/123] Require quoted Python heredoc delimiters before body inspection Codex exact-head review of db79bde (comment 6167225325) showed an unquoted opener ('/opt/homebrew/bin/python3 -I - < --- docs/evidence/g01-recovery-packet.md | 7 +++ .../issue79_regression_test.py | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 7061326e..5f308d1e 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -7689,6 +7689,7 @@ def heredoc_descriptors(stripped): "delimiter": heredoc_match.group(2), "interpreter": tokens[0], "isolated": isolated, + "quoted": bool(heredoc_match.group(1)), } descriptors.append( {"delimiter": heredoc_match.group(2), "invocation": invocation} @@ -12157,6 +12158,7 @@ def reviewed_python_heredoc_segment(command, tokens): return any( descriptor["invocation"] is not None and descriptor["invocation"]["isolated"] + and descriptor["invocation"]["quoted"] and descriptor["invocation"]["interpreter"] == normalized[0] for descriptor in heredoc_descriptors(command) ) @@ -22422,6 +22424,11 @@ for number, body, safe_marker, invocation in python_heredoc_bodies(source): f"line {number}: executable Python heredoc must use -I before body inspection" ) continue + if not invocation["quoted"]: + matches.append( + f"line {number}: executable Python heredoc must quote its delimiter 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" diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 32a3f276..609d6a84 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6775,6 +6775,47 @@ def test_launchers_bound_through_callable_defaults_stay_classified(self) -> None self.assertIsNone(self.inspect('def f(fn=len):\n print(fn("reviewed"))\nf()\n')) self.assertIsNone(self.inspect('import os\ndef f(launch=os.system):\n print("reviewed")\nf()\n')) + def test_python_heredoc_requires_quoted_delimiter(self) -> None: + for opener in ( + "/opt/homebrew/bin/python3 -I - < None: self.assertIsNotNone(self.shell_violation( 'awk \'BEGIN { ARGV[1]="maintainer.pem" } {print}\' docs/EXECUTION.md' @@ -6920,6 +6961,11 @@ def test_current_packet_has_no_static_scanner_violations(self) -> None: f"line {number}: executable Python heredoc must use -I before body inspection" ) continue + if not invocation["quoted"]: + matches.append( + f"line {number}: executable Python heredoc must quote its delimiter 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" From 7638c514ff941fc8d9ec0040670ea651c5b9d350 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 22:46:30 +0900 Subject: [PATCH 097/123] Reject sensitive operands before implicit exception messages Codex exact-head review of db79bde (comment 6167225325) showed '{}[str(os.environ)]' passes the sensitive-read guard, so the KeyError message carries the environment value out of the reviewed body; int()/float() conversions over secret aliases leak the same way through ValueError. The guard only inspected explicit raise/assert messages and os.environ receivers. Collect lexical bindings (assignments, loop targets, parameter defaults) with keys-only iteration flags, resolve operand sensitivity structurally through propagators, iterators, helper returns and aliases, and reject sensitive subscript slices (except Store context and os.environ receivers, which keep their dedicated verdict) plus int/float conversions including assigned aliases. Keys-only iteration over environment mappings (.keys(), dict(), tuple-unpack position 0 of .items()) stays accepted, matching bare 'for key in os.environ'. RED: new test fails all 14 unsafe assertions on the HEAD packet (accepted pre-fix; measured 14 failures); 10 safe assertions pass. GREEN: test ok in 0.23s post-fix; related 68 tests pass (13s); self-host scan of 85 small bodies reports 0 flags (19s); full suite 180 pass, 0 fail (829s); packet smoke reports 261 shell commands + 86 bodies, zero violations. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 315 ++++++++++++++++++ .../issue79_regression_test.py | 47 +++ 2 files changed, 362 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 5f308d1e..f9831b74 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -21434,6 +21434,292 @@ def python_sensitive_read_violation(tree, parents): for base in candidate.bases ) } + error_operand_bindings = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets, value = candidate.targets, candidate.value + loop_iter = None + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr, ast.AugAssign)): + targets, value = [candidate.target], candidate.value + loop_iter = None + elif isinstance(candidate, (ast.For, ast.AsyncFor, ast.comprehension)): + targets, value = [candidate.target], candidate.iter + loop_iter = candidate.iter + else: + continue + if value is None: + continue + binding_scope = python_enclosing_scope(candidate, parents) + for target in targets: + if loop_iter is not None and isinstance(target, (ast.Tuple, ast.List)): + for position, element in enumerate(target.elts): + if not ( + isinstance(element, ast.Name) + and isinstance(element.ctx, ast.Store) + ): + continue + keys_only = ( + position == 0 + and isinstance(loop_iter, ast.Call) + and isinstance(loop_iter.func, ast.Attribute) + and loop_iter.func.attr == "items" + ) + error_operand_bindings.setdefault(element.id, []).append( + (binding_scope, value, keys_only) + ) + continue + for name in ast.walk(target): + if isinstance(name, ast.Name) and isinstance(name.ctx, ast.Store): + error_loop_keys_only = loop_iter is not None + if isinstance(loop_iter, ast.Call) and isinstance( + loop_iter.func, ast.Attribute + ): + error_loop_keys_only = loop_iter.func.attr == "keys" + error_operand_bindings.setdefault(name.id, []).append( + (binding_scope, value, error_loop_keys_only) + ) + error_operand_parameters = {} + for candidate in ast.walk(tree): + if not isinstance( + candidate, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) + ): + continue + positional = list(candidate.args.posonlyargs) + list(candidate.args.args) + defaults = list(candidate.args.defaults) + pairs = ( + list(zip(positional[-len(defaults):], defaults)) if defaults else [] + ) + pairs.extend( + (parameter, default) + for parameter, default in zip( + candidate.args.kwonlyargs, candidate.args.kw_defaults + ) + if default is not None + ) + for parameter, default in pairs: + error_operand_bindings.setdefault(parameter.arg, []).append( + (candidate, default, None) + ) + names = { + parameter.arg + for parameter in positional + list(candidate.args.kwonlyargs) + } + if candidate.args.vararg is not None: + names.add(candidate.args.vararg.arg) + if candidate.args.kwarg is not None: + names.add(candidate.args.kwarg.arg) + error_operand_parameters[candidate] = names + + error_operand_propagators = { + "str", + "bytes", + "repr", + "format", + "ascii", + "tuple", + "frozenset", + } + error_operand_iterators = { + "enumerate", + "filter", + "iter", + "map", + "next", + "reversed", + "sorted", + "zip", + "chain", + } + error_operand_index_methods = { + "find", + "rfind", + "index", + "rindex", + "count", + "startswith", + "endswith", + "isdigit", + "isalpha", + "isalnum", + "isspace", + "isupper", + "islower", + "istitle", + "isdecimal", + "isascii", + "isidentifier", + "isprintable", + "isnumeric", + } + + def error_operand_callable_bases(name): + bases = {name} + for _scope, binding, _loop in error_operand_bindings.get(name, ()): + if isinstance(binding, ast.Name): + bases.add(binding.id) + return bases + + def error_operand_keys_sensitive(node, seen): + if node is None or id(node) in seen: + return False + seen.add(id(node)) + if isinstance(node, ast.Dict): + return any( + error_operand_sensitive(key, set(seen)) + for key in node.keys + if key is not None + ) + if isinstance(node, ast.DictComp): + return error_operand_sensitive(node.key, set(seen)) + if python_dotted_name(node) in {"os.environ", "os.environb"}: + return False + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id == "dict": + return error_operand_keys_sensitive( + node.args[0] if node.args else None, set(seen) + ) + if not isinstance(node.func, ast.Attribute): + return error_operand_sensitive(node, set(seen)) + if node.func.attr in {"keys", "fromkeys"}: + return error_operand_keys_sensitive( + node.args[0] if node.args else node.func.value, set(seen) + ) + if node.func.attr == "copy": + return error_operand_keys_sensitive(node.func.value, set(seen)) + if node.func.attr == "items": + return error_operand_keys_sensitive(node.func.value, set(seen)) + return error_operand_sensitive(node, set(seen)) + if isinstance(node, ast.Name): + chain = python_lexical_scope_chain( + python_enclosing_scope(node, parents), parents + ) + for scope in chain: + entries = [ + (binding, keys_only) + for binding_scope, binding, keys_only in error_operand_bindings.get( + node.id, () + ) + if binding_scope is scope + ] + if entries: + return any( + error_operand_keys_sensitive(binding, set(seen)) + if keys_only + else error_operand_sensitive(binding, set(seen)) + for binding, keys_only in entries + ) + if node.id in error_operand_parameters.get(scope, ()): + return False + return False + return error_operand_sensitive(node, set(seen)) + + def error_operand_sensitive(value, seen): + if value is None or id(value) in seen: + return False + seen.add(id(value)) + if isinstance(value, ast.Name): + chain = python_lexical_scope_chain( + python_enclosing_scope(value, parents), parents + ) + for scope in chain: + entries = [ + (binding, keys_only) + for binding_scope, binding, keys_only in error_operand_bindings.get( + value.id, () + ) + if binding_scope is scope + ] + if entries: + return any( + error_operand_keys_sensitive(binding, set(seen)) + if keys_only + else error_operand_sensitive(binding, set(seen)) + for binding, keys_only in entries + ) + if value.id in error_operand_parameters.get(scope, ()): + return False + return False + if isinstance(value, ast.Attribute): + if python_dotted_name(value) in {"os.environ", "os.environb"}: + return True + return error_operand_sensitive(value.value, set(seen)) + if isinstance(value, ast.Call): + dotted = python_dotted_name(value.func) + if dotted in {"os.getenv", "os.getenvb"}: + return True + if isinstance(value.func, ast.Attribute): + if value.func.attr in error_operand_index_methods: + return False + parts = ( + [value.func.value] + + list(value.args) + + [keyword.value for keyword in value.keywords] + ) + return any( + error_operand_sensitive(part, set(seen)) for part in parts + ) + if isinstance(value.func, (ast.Name, ast.Lambda)): + returned = python_local_call_return_values(value, tree, parents) + if returned: + return any( + error_operand_sensitive(result, set(seen)) + for result in returned + ) + if isinstance(value.func, ast.Name) and ( + error_operand_callable_bases(value.func.id) + & (error_operand_propagators | error_operand_iterators) + or dotted + in { + "itertools.chain", + "itertools.chain.from_iterable", + } + ): + parts = list(value.args) + [ + keyword.value for keyword in value.keywords + ] + return any( + error_operand_sensitive(part, set(seen)) for part in parts + ) + return False + if isinstance(value, ast.Subscript): + if python_dotted_name(value.value) in {"os.environ", "os.environb"}: + return True + return error_operand_sensitive( + value.value, set(seen) + ) or error_operand_sensitive(value.slice, set(seen)) + if isinstance(value, ast.NamedExpr): + return error_operand_sensitive(value.value, set(seen)) + if isinstance(value, (ast.List, ast.Tuple, ast.Set)): + return any( + error_operand_sensitive(item, set(seen)) for item in value.elts + ) + if isinstance(value, ast.Dict): + return any( + error_operand_sensitive(item, set(seen)) + for item in list(value.keys) + list(value.values) + if item is not None + ) + if isinstance(value, ast.IfExp): + return error_operand_sensitive( + value.body, set(seen) + ) or error_operand_sensitive(value.orelse, set(seen)) + if isinstance(value, (ast.BinOp, ast.BoolOp, ast.UnaryOp)): + return any( + error_operand_sensitive(child, set(seen)) + for child in ast.iter_child_nodes(value) + if isinstance(child, ast.expr) + ) + if isinstance(value, (ast.JoinedStr, ast.FormattedValue)): + return any( + error_operand_sensitive(child, set(seen)) + for child in ast.iter_child_nodes(value) + if isinstance(child, ast.expr) + ) + if isinstance(value, ast.Starred): + return error_operand_sensitive(value.value, set(seen)) + if isinstance(value, (ast.Compare, ast.Slice)): + return False + return False def exception_arguments(value, seen_names=None, follow_names=True): if value is None: @@ -21493,6 +21779,17 @@ def python_sensitive_read_violation(tree, parents): "Python credential/environment subscript is not allowed " f"on line {node.lineno}" ) + if ( + isinstance(node, ast.Subscript) + and not isinstance(node.ctx, ast.Store) + and python_dotted_name(node.value) + not in {"os.environ", "os.environb"} + and error_operand_sensitive(node.slice, set()) + ): + return ( + "Python sensitive value is used as a subscript that may surface " + f"in an exception message 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}" @@ -21527,6 +21824,24 @@ def python_sensitive_read_violation(tree, parents): ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) + if ( + dotted in {"int", "float", "builtins.int", "builtins.float"} + or ( + isinstance(node.func, ast.Name) + and ( + python_assigned_callable_alias(node.func.id, "int", tree) + or python_assigned_callable_alias(node.func.id, "float", tree) + ) + ) + ) and any( + error_operand_sensitive(value, set()) + for value in list(node.args) + + [keyword.value for keyword in node.keywords] + ): + return ( + "Python sensitive value is converted by a call whose failure " + f"message exposes it on line {node.lineno}" + ) if dotted in {"os.getenv", "os.getenvb"}: key = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None if key is None or not isinstance(key, str) or credential_environment_name(key): diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 609d6a84..5cea8ba3 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6816,6 +6816,53 @@ def test_python_heredoc_requires_quoted_delimiter(self) -> None: self.assertEqual(1, len(bodies)) self.assertFalse(bodies[0][3].get("quoted")) + def test_sensitive_operands_are_rejected_before_implicit_exception_messages(self) -> None: + for body in ( + 'import os\n{}[str(os.environ)]\n', + 'import os\nsecret = os.environ\n{}[secret]\n', + 'import os\nsecret = str(os.environ)\n[][secret]\n', + 'import os\nsecret = str(os.environ)\nint(secret)\n', + 'import os\nsecret = str(os.environ)\nfloat(secret)\n', + 'import os\nint(str(os.environ))\n', + 'import os\nfor value in os.environ.values():\n {}[value]\n', + 'import os\nsecret = os.environ\nkey = str(secret).strip()\n{}[key]\n', + 'import os\nsecret = str(os.environ)\ndel {}[secret]\n', + 'import os\ndef k():\n return str(os.environ)\n{}[k()]\n', + 'import os\nsecrets = [str(os.environ)]\nfor _, v in enumerate(secrets):\n {}[v]\n', + 'import os\nfor key, value in os.environ.items():\n {}[value]\n', + 'import os\nto_int = int\nsecret = str(os.environ)\nto_int(secret)\n', + 'import os\nto_float = float\nsecret = str(os.environ)\nto_float(secret)\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('d = {"a": 1}\nprint(d["a"])\n')) + self.assertIsNone(self.inspect('print(int("3"))\n')) + self.assertIsNone(self.inspect('items = [1, 2]\nprint(items[0])\n')) + self.assertIsNone(self.inspect('import sys\nprint(sys.argv[1:])\n')) + self.assertIsNone(self.inspect( + 'import os\nsecret = str(os.environ)\nd = {}\nd[secret] = 1\n' + )) + self.assertIsNone(self.inspect( + 'import os\ndefaults = {"PATH": "/x"}\n' + 'for key in os.environ:\n' + ' print(defaults[key] if key in defaults else "none")\n' + )) + self.assertIsNone(self.inspect( + 'import os\ndef other():\n secret = os.environ\n return 1\n' + 'def f():\n secret = 0\n d = {}\n return d[secret]\n' + )) + self.assertIsNone(self.inspect( + 'import os\nenv = dict(os.environ)\ndefaults = {}\n' + 'for key in env:\n defaults[key] = 1\n' + 'total = sum(defaults[key] for key in env)\n' + )) + self.assertIsNone(self.inspect( + 'import os\nfor key in os.environ.keys():\n {}[key]\n' + )) + self.assertIsNone(self.inspect( + 'import os\nfor key in dict(os.environ):\n {}[key]\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' From 25a5e440a7a9994412c60e1ef118f0d8bd756299 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 22:56:17 +0900 Subject: [PATCH 098/123] Reject complex conversions over sensitive values Codex exact-head review of 7638c51 (comment 6169988457, G3) showed 'complex(str(os.environ))' is certified while CPython raises a ValueError whose message includes the full supplied string. The conversion guard covered int/float builtins and their assigned aliases but omitted the adjacent complex builtin. Include complex, builtins.complex and assigned complex aliases in the implicit-exception conversion check. RED: new test fails 2 unsafe assertions pre-fix (accepted). GREEN: test ok in 0.19s (2 unsafe + 2 safe); related 69 pass (14s); self-host scan of 85 small bodies reports 0 flags (20s). Full-suite batch verification runs in the background before push. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 11 ++++++++++- scripts/evidence_packet/issue79_regression_test.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index f9831b74..c7337ad3 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -21825,12 +21825,21 @@ def python_sensitive_read_violation(tree, parents): if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) if ( - dotted in {"int", "float", "builtins.int", "builtins.float"} + dotted + in { + "int", + "float", + "complex", + "builtins.int", + "builtins.float", + "builtins.complex", + } or ( isinstance(node.func, ast.Name) and ( python_assigned_callable_alias(node.func.id, "int", tree) or python_assigned_callable_alias(node.func.id, "float", tree) + or python_assigned_callable_alias(node.func.id, "complex", tree) ) ) ) and any( diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 5cea8ba3..8160560e 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6863,6 +6863,16 @@ def test_sensitive_operands_are_rejected_before_implicit_exception_messages(self 'import os\nfor key in dict(os.environ):\n {}[key]\n' )) + def test_complex_conversions_are_rejected_before_implicit_exception_messages(self) -> None: + for body in ( + 'import os\ncomplex(str(os.environ))\n', + 'import os\nto_complex = complex\nsecret = str(os.environ)\nto_complex(secret)\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect('print(complex(0, 1))\n')) + self.assertIsNone(self.inspect('print(complex("3"))\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' From d9b6a2d8b1d46d3f62d040ea5a674334c2d8812d Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 22:59:41 +0900 Subject: [PATCH 099/123] Track conversion callables through defaults and containers Codex exact-head review of 7638c51 (comment 6169988457, G6) showed 'def f(convert=int): convert(str(os.environ))' and 'converters = [float]; converters[0](str(os.environ))' are certified while each raises a conversion error exposing the inherited environment. The alias branch recognized only ordinary assigned names. Resolve conversion targets structurally: direct int/float/complex names, assigned aliases, parameter defaults (recursive), call-site arguments, and list/tuple containers with constant-index selection (any-element fallback for computed indices). Non-conversion callables (len) in the same positions stay accepted with sensitive arguments. RED: new test fails 4 unsafe assertions pre-fix (accepted). GREEN: test ok in 0.20s (4 unsafe + 2 safe); related 69 pass (14s); self-host scan of 85 small bodies reports 0 flags (19s). Full-suite batch verification runs in the background before push. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 111 +++++++++++++++--- .../issue79_regression_test.py | 16 +++ 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index c7337ad3..ae36b2d2 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -21559,6 +21559,97 @@ def python_sensitive_read_violation(tree, parents): bases.add(binding.id) return bases + error_operand_conversions = { + "int", + "float", + "complex", + "builtins.int", + "builtins.float", + "builtins.complex", + } + + def error_operand_conversion_callable(func, seen): + if func is None or id(func) in seen: + return False + seen.add(id(func)) + if python_dotted_name(func) in error_operand_conversions: + return True + if isinstance(func, ast.Name): + if ( + python_assigned_callable_alias(func.id, "int", tree) + or python_assigned_callable_alias(func.id, "float", tree) + or python_assigned_callable_alias(func.id, "complex", tree) + ): + return True + chain = python_lexical_scope_chain( + python_enclosing_scope(func, parents), parents + ) + for scope in chain: + for binding_scope, binding, _keys in error_operand_bindings.get( + func.id, () + ): + if binding_scope is scope and error_operand_conversion_callable( + binding, set(seen) + ): + return True + for source_scope, source in assignments_by_name.get(func.id, ()): + if source_scope is scope and error_operand_conversion_callable( + source, set(seen) + ): + return True + return False + if isinstance(func, ast.Subscript): + containers = [] + target = func.value + if isinstance(target, (ast.List, ast.Tuple)): + containers.append(target) + elif isinstance(target, ast.Name): + chain = python_lexical_scope_chain( + python_enclosing_scope(target, parents), parents + ) + for scope in chain: + containers.extend( + binding + for binding_scope, binding, _keys in error_operand_bindings.get( + target.id, () + ) + if binding_scope is scope + ) + containers.extend( + source + for source_scope, source in assignments_by_name.get( + target.id, () + ) + if source_scope is scope + ) + for container in containers: + if not isinstance(container, (ast.List, ast.Tuple)): + continue + elements = list(container.elts) + index = func.slice + if ( + isinstance(index, ast.Constant) + and isinstance(index.value, int) + and not isinstance(index.value, bool) + ): + pick = ( + index.value + if index.value >= 0 + else len(elements) + index.value + ) + if 0 <= pick < len(elements): + if error_operand_conversion_callable( + elements[pick], set(seen) + ): + return True + continue + if any( + error_operand_conversion_callable(item, set(seen)) + for item in elements + ): + return True + return False + def error_operand_keys_sensitive(node, seen): if node is None or id(node) in seen: return False @@ -21824,25 +21915,7 @@ def python_sensitive_read_violation(tree, parents): ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) - if ( - dotted - in { - "int", - "float", - "complex", - "builtins.int", - "builtins.float", - "builtins.complex", - } - or ( - isinstance(node.func, ast.Name) - and ( - python_assigned_callable_alias(node.func.id, "int", tree) - or python_assigned_callable_alias(node.func.id, "float", tree) - or python_assigned_callable_alias(node.func.id, "complex", tree) - ) - ) - ) and any( + if error_operand_conversion_callable(node.func, set()) and any( error_operand_sensitive(value, set()) for value in list(node.args) + [keyword.value for keyword in node.keywords] diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 8160560e..6376e3f5 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6873,6 +6873,22 @@ def test_complex_conversions_are_rejected_before_implicit_exception_messages(sel self.assertIsNone(self.inspect('print(complex(0, 1))\n')) self.assertIsNone(self.inspect('print(complex("3"))\n')) + def test_conversion_callables_in_defaults_and_containers_are_tracked(self) -> None: + for body in ( + 'import os\ndef f(convert=int):\n convert(str(os.environ))\nf()\n', + 'import os\nconverters = [float]\nconverters[0](str(os.environ))\n', + 'import os\nto_int = int\ndef f(convert=to_int):\n convert(str(os.environ))\nf()\n', + 'import os\ndef f(convert):\n convert(str(os.environ))\nf(int)\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import os\ndef f(convert=len):\n convert(str(os.environ))\nf()\n' + )) + self.assertIsNone(self.inspect( + 'import os\nhandlers = [len]\nhandlers[0](str(os.environ))\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' From a0849e3403b57a585b10a388007bf5083a3a5f35 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 23:01:55 +0900 Subject: [PATCH 100/123] Propagate sensitive call arguments into parameters Codex exact-head review of 7638c51 (comment 6169988457, G1) showed 'def lookup(key): return {}[key]; lookup(str(os.environ))' is certified while the KeyError renders the inherited environment. Parameter names returned False without consulting the collected call-site bindings. Resolve parameter operands through the union of lexical bindings (defaults, reassignments) and call-site argument mappings (positional, keyword, variadic, star-expansion) in the matching scope; uncalled parameters stay opaque. Closure-captured names keep resolving through the lexical scope chain. RED: new test fails 3 unsafe assertions pre-fix (accepted); closure pin passes pre/post via the scope chain. GREEN: test ok in 0.21s (4 unsafe + 2 safe); related 70 pass (17s); self-host scan of 85 small bodies reports 0 flags (23s). Full-suite batch verification runs in the background before push. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 36 +++++++++++++------ .../issue79_regression_test.py | 17 +++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index ae36b2d2..42768889 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -21692,15 +21692,23 @@ def python_sensitive_read_violation(tree, parents): ) if binding_scope is scope ] - if entries: - return any( + is_parameter = node.id in error_operand_parameters.get(scope, ()) + if entries or is_parameter: + resolutions = [ error_operand_keys_sensitive(binding, set(seen)) if keys_only else error_operand_sensitive(binding, set(seen)) for binding, keys_only in entries - ) - if node.id in error_operand_parameters.get(scope, ()): - return False + ] + if is_parameter: + resolutions.extend( + error_operand_keys_sensitive(source, set(seen)) + for source_scope, source in assignments_by_name.get( + node.id, () + ) + if source_scope is scope + ) + return any(resolutions) return False return error_operand_sensitive(node, set(seen)) @@ -21720,15 +21728,23 @@ def python_sensitive_read_violation(tree, parents): ) if binding_scope is scope ] - if entries: - return any( + is_parameter = value.id in error_operand_parameters.get(scope, ()) + if entries or is_parameter: + resolutions = [ error_operand_keys_sensitive(binding, set(seen)) if keys_only else error_operand_sensitive(binding, set(seen)) for binding, keys_only in entries - ) - if value.id in error_operand_parameters.get(scope, ()): - return False + ] + if is_parameter: + resolutions.extend( + error_operand_sensitive(source, set(seen)) + for source_scope, source in assignments_by_name.get( + value.id, () + ) + if source_scope is scope + ) + return any(resolutions) return False if isinstance(value, ast.Attribute): if python_dotted_name(value) in {"os.environ", "os.environb"}: diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 6376e3f5..4bbf4fe9 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6889,6 +6889,23 @@ def test_conversion_callables_in_defaults_and_containers_are_tracked(self) -> No 'import os\nhandlers = [len]\nhandlers[0](str(os.environ))\n' )) + def test_sensitive_call_arguments_propagate_into_parameters(self) -> None: + for body in ( + 'import os\ndef lookup(key):\n return {}[key]\nlookup(str(os.environ))\n', + 'import os\ndef lookup(key=None):\n return {}[key]\nlookup(key=str(os.environ))\n', + 'import os\ndef lookup(*keys):\n return {}[keys[0]]\nlookup(str(os.environ))\n', + 'import os\ndef outer():\n secret = str(os.environ)\n' + ' def inner():\n return {}[secret]\n return inner()\nouter()\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import os\ndef lookup(key):\n return {}[key]\nlookup("name")\n' + )) + self.assertIsNone(self.inspect( + 'import os\ndef lookup(key):\n return {}[key]\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' From 6caab1ba332201bf13c0f44e38c07918ecdae943 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 23:09:42 +0900 Subject: [PATCH 101/123] Track mutated mapping keys before keys-only exemption Codex exact-head review of 7638c51 (comment 6169988457, G2) showed 'd = {}; d[str(os.environ)] = 1; for key in d: {}[key]' is certified while the KeyError carries the inherited environment. Key sensitivity derived only from the mapping's original assignment and ignored later subscript writes, update, or setdefault. Collect mutated-in key expressions per mapping (subscript stores, |= sources, update/setdefault/__setitem__ arguments, tagged as key or mapping sources) and resolve them in the matching domain when a keys-only loop variable is queried, filtered by visible scopes. Element-preserving iterators (sorted/list/tuple/...) stay in keys-domain; repr-producing calls analyzed sensitively after fixing a same-node seen-set block that swallowed keys-to-sensitive delegation. RED: new test fails 4 unsafe assertions pre-fix (|= pins a case the old binding walk already rejected). GREEN: test ok in 0.21s (7 unsafe + 6 safe); related 71 pass (15s); self-host scan of 85 small bodies reports 0 flags (22s). Full-suite batch verification runs in the background before push. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 124 +++++++++++++++++- .../issue79_regression_test.py | 32 +++++ 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 42768889..d24da602 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -21478,6 +21478,70 @@ def python_sensitive_read_violation(tree, parents): error_operand_bindings.setdefault(name.id, []).append( (binding_scope, value, error_loop_keys_only) ) + error_operand_mutated_keys = {} + for candidate in ast.walk(tree): + if isinstance(candidate, (ast.Assign, ast.AnnAssign)): + if isinstance(candidate, ast.AnnAssign) and candidate.value is None: + continue + targets = ( + candidate.targets + if isinstance(candidate, ast.Assign) + else [candidate.target] + ) + for target in targets: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.ctx, ast.Store) + and isinstance(target.value, ast.Name) + ): + error_operand_mutated_keys.setdefault( + target.value.id, [] + ).append( + ( + python_enclosing_scope(candidate, parents), + target.slice, + False, + ) + ) + elif isinstance(candidate, ast.AugAssign): + if isinstance(candidate.op, ast.BitOr) and isinstance( + candidate.target, ast.Name + ): + error_operand_mutated_keys.setdefault(candidate.target.id, []).append( + (python_enclosing_scope(candidate, parents), candidate.value, True) + ) + elif isinstance(candidate, ast.Call): + func = candidate.func + if not ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.attr in {"update", "setdefault", "__setitem__"} + ): + continue + key_sources = [] + if func.attr == "update": + for argument in candidate.args: + if isinstance(argument, ast.Dict): + key_sources.extend( + (key, False) + for key in argument.keys + if key is not None + ) + else: + key_sources.append((argument, True)) + key_sources.extend( + (keyword.value, True) + for keyword in candidate.keywords + if keyword.arg is None + ) + elif candidate.args: + key_sources.append((candidate.args[0], False)) + if key_sources: + mutation_scope = python_enclosing_scope(candidate, parents) + error_operand_mutated_keys.setdefault(func.value.id, []).extend( + (mutation_scope, key, is_mapping) + for key, is_mapping in key_sources + ) error_operand_parameters = {} for candidate in ast.walk(tree): if not isinstance( @@ -21552,6 +21616,20 @@ def python_sensitive_read_violation(tree, parents): "isnumeric", } + error_operand_element_iterators = { + "sorted", + "reversed", + "list", + "tuple", + "set", + "frozenset", + "iter", + "enumerate", + "next", + "zip", + "chain", + } + def error_operand_callable_bases(name): bases = {name} for _scope, binding, _loop in error_operand_bindings.get(name, ()): @@ -21662,6 +21740,18 @@ def python_sensitive_read_violation(tree, parents): ) if isinstance(node, ast.DictComp): return error_operand_sensitive(node.key, set(seen)) + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + return any( + error_operand_sensitive(item, set(seen)) for item in node.elts + ) + if isinstance(node, ast.BinOp): + return error_operand_keys_sensitive( + node.left, set(seen) + ) or error_operand_keys_sensitive(node.right, set(seen)) + if isinstance(node, ast.IfExp): + return error_operand_keys_sensitive( + node.body, set(seen) + ) or error_operand_keys_sensitive(node.orelse, set(seen)) if python_dotted_name(node) in {"os.environ", "os.environb"}: return False if isinstance(node, ast.Call): @@ -21670,7 +21760,18 @@ def python_sensitive_read_violation(tree, parents): node.args[0] if node.args else None, set(seen) ) if not isinstance(node.func, ast.Attribute): - return error_operand_sensitive(node, set(seen)) + if ( + isinstance(node.func, ast.Name) + and node.func.id in error_operand_element_iterators + ): + return any( + error_operand_keys_sensitive(part, set(seen)) + for part in list(node.args) + + [keyword.value for keyword in node.keywords] + ) + unpoisoned = set(seen) + unpoisoned.discard(id(node)) + return error_operand_sensitive(node, unpoisoned) if node.func.attr in {"keys", "fromkeys"}: return error_operand_keys_sensitive( node.args[0] if node.args else node.func.value, set(seen) @@ -21679,11 +21780,26 @@ def python_sensitive_read_violation(tree, parents): return error_operand_keys_sensitive(node.func.value, set(seen)) if node.func.attr == "items": return error_operand_keys_sensitive(node.func.value, set(seen)) - return error_operand_sensitive(node, set(seen)) + unpoisoned = set(seen) + unpoisoned.discard(id(node)) + return error_operand_sensitive(node, unpoisoned) if isinstance(node, ast.Name): chain = python_lexical_scope_chain( python_enclosing_scope(node, parents), parents ) + visible_scopes = set(chain) + mutated_sensitive = False + for mutation_scope, key, is_mapping in error_operand_mutated_keys.get( + node.id, () + ): + if mutation_scope not in visible_scopes: + continue + if is_mapping: + mutated_sensitive = error_operand_keys_sensitive(key, set(seen)) + else: + mutated_sensitive = error_operand_sensitive(key, set(seen)) + if mutated_sensitive: + return True for scope in chain: entries = [ (binding, keys_only) @@ -21710,7 +21826,9 @@ def python_sensitive_read_violation(tree, parents): ) return any(resolutions) return False - return error_operand_sensitive(node, set(seen)) + unpoisoned = set(seen) + unpoisoned.discard(id(node)) + return error_operand_sensitive(node, unpoisoned) def error_operand_sensitive(value, seen): if value is None or id(value) in seen: diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 4bbf4fe9..f5558f95 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6906,6 +6906,38 @@ def test_sensitive_call_arguments_propagate_into_parameters(self) -> None: 'import os\ndef lookup(key):\n return {}[key]\n' )) + def test_late_inserted_sensitive_keys_lose_keys_only_exemption(self) -> None: + for body in ( + 'import os\nd = {}\nd[str(os.environ)] = 1\nfor key in d:\n {}[key]\n', + 'import os\nd = {}\nd.update({str(os.environ): 1})\nfor key in d:\n {}[key]\n', + 'import os\nd = {}\nd.setdefault(str(os.environ), 1)\nfor key in d:\n {}[key]\n', + 'import os\nd = {}\nd |= {str(os.environ): 1}\nfor key in d:\n {}[key]\n', + 'import os\nd = {}\nd.__setitem__(str(os.environ), 1)\nfor key in d:\n {}[key]\n', + 'import os\nd = {}\nd.update([(str(os.environ), 1)])\nfor key in d:\n {}[key]\n', + 'import os\nfor ch in str(os.environ):\n {}[ch]\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'import os\nd = {}\nd["static"] = 1\nfor key in d:\n {}[key]\n' + )) + self.assertIsNone(self.inspect( + 'import os\nd = {}\nd.update(os.environ)\nfor key in d:\n {}[key]\n' + )) + self.assertIsNone(self.inspect( + 'import os\nd = {}\nd.update(host="x")\nfor key in d:\n {}[key]\n' + )) + self.assertIsNone(self.inspect( + 'import os\ndef f():\n d = {}\n d[str(os.environ)] = 1\n' + 'def g():\n d = {}\n for key in d:\n {}[key]\n' + )) + self.assertIsNone(self.inspect( + 'import os\nfor key in sorted(os.environ):\n {}[key]\n' + )) + self.assertIsNone(self.inspect( + 'import os\nfor key in list(os.environ):\n {}[key]\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' From bdcabb4273b68734e9f93c81d095a1c398c83d92 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 23:13:09 +0900 Subject: [PATCH 102/123] Reject subscript keys with sensitive representations Codex exact-head review of 7638c51 (comment 6169988457, G7) showed '{}[K()]' is certified when K.__repr__ returns str(os.environ), while the KeyError invokes the representation and prints the inherited environment. Index local class definitions once per body and resolve subscript slices through constructions, alias bindings and call-site arguments; when the key is an instance of a local class whose __repr__ (including inherited bases) returns a sensitive value, reject with a dedicated verdict. Classes without custom representations, safe representations, local factory/method calls and non-local constructions stay accepted, matching the packet's established local-analysis boundary. RED: new test fails 4 unsafe assertions pre-fix (accepted). GREEN: test ok in 0.20s (4 unsafe + 4 safe); related 72 pass (16s); self-host scan of 85 small bodies reports 0 flags (22s). Full-suite batch verification runs in the background before push. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 88 +++++++++++++++++++ .../issue79_regression_test.py | 26 ++++++ 2 files changed, 114 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index d24da602..fbf6d1cd 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -21542,6 +21542,12 @@ def python_sensitive_read_violation(tree, parents): (mutation_scope, key, is_mapping) for key, is_mapping in key_sources ) + error_operand_classes = {} + for candidate in ast.walk(tree): + if isinstance(candidate, ast.ClassDef): + error_operand_classes.setdefault(candidate.name, []).append( + (python_enclosing_scope(candidate, parents), candidate) + ) error_operand_parameters = {} for candidate in ast.walk(tree): if not isinstance( @@ -21946,6 +21952,77 @@ def python_sensitive_read_violation(tree, parents): return False return False + def error_operand_repr_from_class(classdef, seen): + if id(classdef) in seen: + return False + seen.add(id(classdef)) + for entry in classdef.body: + if ( + isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) + and entry.name == "__repr__" + ): + for candidate in ast.walk(entry): + if ( + isinstance(candidate, ast.Return) + and candidate.value is not None + and error_operand_sensitive(candidate.value, set(seen)) + ): + return True + for base in classdef.bases: + if isinstance(base, ast.Name): + for _scope, other in error_operand_classes.get(base.id, ()): + if error_operand_repr_from_class(other, set(seen)): + return True + return False + + def error_operand_repr_sensitive(value, seen): + if value is None or id(value) in seen: + return False + seen.add(id(value)) + if isinstance(value, ast.Call): + func = value.func + if not isinstance(func, ast.Name): + return False + chain = set( + python_lexical_scope_chain( + python_enclosing_scope(value, parents), parents + ) + ) + return any( + error_operand_repr_from_class(classdef, set(seen)) + for class_scope, classdef in error_operand_classes.get(func.id, ()) + if class_scope in chain + ) + if isinstance(value, ast.Name): + chain = python_lexical_scope_chain( + python_enclosing_scope(value, parents), parents + ) + for scope in chain: + entries = [ + binding + for binding_scope, binding, _keys in error_operand_bindings.get( + value.id, () + ) + if binding_scope is scope + ] + is_parameter = value.id in error_operand_parameters.get(scope, ()) + if entries or is_parameter: + resolutions = [ + error_operand_repr_sensitive(binding, set(seen)) + for binding in entries + ] + if is_parameter: + resolutions.extend( + error_operand_repr_sensitive(source, set(seen)) + for source_scope, source in assignments_by_name.get( + value.id, () + ) + if source_scope is scope + ) + return any(resolutions) + return False + return False + def exception_arguments(value, seen_names=None, follow_names=True): if value is None: return [] @@ -22015,6 +22092,17 @@ def python_sensitive_read_violation(tree, parents): "Python sensitive value is used as a subscript that may surface " f"in an exception message on line {node.lineno}" ) + if ( + isinstance(node, ast.Subscript) + and not isinstance(node.ctx, ast.Store) + and python_dotted_name(node.value) + not in {"os.environ", "os.environb"} + and error_operand_repr_sensitive(node.slice, set()) + ): + return ( + "Python subscript uses a key whose representation may disclose " + f"sensitive values 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}" diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index f5558f95..4661fd34 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6938,6 +6938,32 @@ def test_late_inserted_sensitive_keys_lose_keys_only_exemption(self) -> None: 'import os\nfor key in list(os.environ):\n {}[key]\n' )) + def test_keys_with_sensitive_representations_are_rejected(self) -> None: + for body in ( + 'import os\nclass K:\n def __repr__(self):\n' + ' return str(os.environ)\n{}[K()]\n', + 'import os\nclass K:\n def __repr__(self):\n' + ' return str(os.environ)\nk = K()\n{}[k]\n', + 'import os\nclass K:\n def __repr__(self):\n' + ' return str(os.environ)\nclass K2(K):\n pass\n{}[K2()]\n', + 'import os\nclass K:\n def __repr__(self):\n' + ' return str(os.environ)\ndef f(k):\n return {}[k]\nf(K())\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'class K:\n pass\n{}[K()]\n' + )) + self.assertIsNone(self.inspect( + 'class K:\n def __repr__(self):\n return "K()"\n{}[K()]\n' + )) + self.assertIsNone(self.inspect( + 'def make():\n return "k"\n{}[make()]\n' + )) + self.assertIsNone(self.inspect( + 'class F:\n def build(self):\n return "k"\n{}[F().build()]\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' From 3eb6aa16ff76bfbb3774693436317fb667ca427f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 23:17:29 +0900 Subject: [PATCH 103/123] Resolve from-import aliases at the call site Codex exact-head review of 7638c51 (comment 6169988457, G5) showed 'from os import removedirs as action; action(...); from shutil import which as action' is certified because a global alias map lets the later import rewrite the earlier call to shutil.which, although the first call executes the destructive OS function. Record from-import bindings with scope and source position and resolve each bare-name call against visible imports: same-scope bindings preceding the call (unioned fail-closed so conditional imports cannot smuggle), else innermost enclosing scope wins for shadowing. Single-import behavior is unchanged. RED: new test fails 3 assertions pre-fix (both unsafe accepted; call-before-import over-rejected by the global map). GREEN: test ok in 0.19s (2 unsafe + 2 safe); related 108 pass (37s); self-host scan of 85 small bodies reports 0 flags (20s). Full-suite batch verification runs in the background before push. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 125 ++++++++++++------ .../issue79_regression_test.py | 24 ++++ 2 files changed, 108 insertions(+), 41 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index fbf6d1cd..01377079 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16161,13 +16161,78 @@ def python_working_directory_change_violation(tree, parents): 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") - from_import_aliases = { - alias.asname or alias.name: f"{node.module}.{alias.name}" + from_import_parents = { + child: node for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) and node.module in {"os", "shutil"} - for alias in node.names - if alias.name != "*" + for child in ast.iter_child_nodes(node) } + from_import_bindings = [] + for node in ast.walk(tree): + if not ( + isinstance(node, ast.ImportFrom) and node.module in {"os", "shutil"} + ): + continue + for alias in node.names: + if alias.name == "*": + continue + from_import_bindings.append( + ( + alias.asname or alias.name, + f"{node.module}.{alias.name}", + python_enclosing_scope(node, from_import_parents), + (node.lineno, node.col_offset), + ) + ) + + def from_import_candidates(name, call): + call_position = (call.lineno, call.col_offset) + chain = python_lexical_scope_chain( + python_enclosing_scope(call, from_import_parents), + from_import_parents, + ) + for depth, scope in enumerate(chain): + matches = sorted( + (position, dotted) + for bound, dotted, scope_node, position in from_import_bindings + if bound == name + and scope_node is scope + and (depth > 0 or position < call_position) + ) + if matches: + return [dotted for _position, dotted in matches] + return [] + + def from_import_call_violation(dotted, resolved_from_import, call): + root, remainder = dotted.split(".", 1) + if root == "shutil" and resolved_from_import: + if ( + dotted == "shutil.which" + or dotted in python_filesystem_mutating_functions + ): + return None + return ( + "Python heredoc calls an unreviewed shutil entry point " + f"{dotted!r} on line {call.lineno}" + ) + if root not in os_names and not resolved_from_import: + return None + 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 {call.lineno}" + ) + if ( + canonical in python_filesystem_mutating_functions + or canonical in python_reviewed_os_calls + or canonical.startswith("os.path.") + or canonical.startswith("os.environ.") + ): + return None + return ( + "Python heredoc contains an unreviewed OS call with possible path/effect " + f"surface {dotted!r} on line {call.lineno}" + ) for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) @@ -16202,45 +16267,23 @@ def python_unknown_os_call_violation(tree): if not isinstance(node, ast.Call): continue dotted = python_dotted_name(node.func) - resolved_from_import = ( - dotted is not None - and "." not in dotted - and dotted in from_import_aliases - ) - if resolved_from_import: - dotted = from_import_aliases[dotted] - if not dotted or "." not in dotted: - continue - root, remainder = dotted.split(".", 1) - if root == "shutil" and resolved_from_import: - if ( - dotted == "shutil.which" - or dotted in python_filesystem_mutating_functions - ): + if dotted is not None and "." not in dotted: + candidates = [ + (candidate_dotted, True) + for candidate_dotted in from_import_candidates(dotted, node) + ] + if not candidates: continue - return ( - "Python heredoc calls an unreviewed shutil entry point " - f"{dotted!r} on line {node.lineno}" - ) - if root not in os_names and not resolved_from_import: + elif not dotted or "." not in dotted: 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}" + else: + candidates = [(dotted, False)] + for candidate_dotted, resolved_from_import in candidates: + violation = from_import_call_violation( + candidate_dotted, resolved_from_import, node ) - 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 - return ( - "Python heredoc contains an unreviewed OS call with possible path/effect " - f"surface {dotted!r} on line {node.lineno}" - ) + if violation is not None: + return violation return None diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 4661fd34..9fbd839e 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6762,6 +6762,30 @@ def test_from_imported_os_and_shutil_calls_use_fail_closed_classification(self) self.assertIsNotNone(self.inspect(body)) self.assertIsNone(self.inspect('from shutil import which\nprint(which("git"))\n')) + def test_from_import_aliases_resolve_at_call_site(self) -> None: + for body in ( + 'from os import removedirs as action\n' + 'action("/tmp/maintainer-owned/a")\n' + 'from shutil import which as action\n', + 'flag = True\n' + 'from os import removedirs as action\n' + 'if flag:\n' + ' from shutil import which as action\n' + 'action("/tmp/maintainer-owned/a")\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'from os import removedirs as action\n' + 'def f():\n' + ' from shutil import which as action\n' + ' action("git")\n' + )) + self.assertIsNone(self.inspect( + 'action("/tmp/maintainer-owned/a")\n' + 'from os import removedirs as action\n' + )) + def test_launchers_bound_through_callable_defaults_stay_classified(self) -> None: for body in ( 'import os\n(lambda launch=os.system: launch("rm -rf /tmp/maintainer-owned"))()\n', From 8ddfa6ac1d7935f823827ab9d88bd677e386d88c Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Wed, 14 Oct 2026 23:58:47 +0900 Subject: [PATCH 104/123] Classify launchers nested in default expressions Codex exact-head review of 7638c51 (comment 6169988457, G4) showed 'def f(launch=os.system if flag else len)' is certified because defaults reduce only through whole-expression dotted names, while the call executes os.system. Walk default expressions for launcher provenance through conditionals, boolean/selection/container forms, named expressions and immediately invoked lambdas (resolving through the import maps), plus from-imported command leaves bound in the tree; defaults that contain a launcher but do not reduce to one name map the parameter to unresolved so calls fail closed. Clean defaults (len/str branches, local helpers, uncalled tainted defaults) stay accepted, and bare leaf names without a matching from-import are not guessed. RED: new test fails 7 unsafe assertions pre-fix (accepted). GREEN: test ok in 0.22s (7 unsafe + 3 safe); F3 regression test ok. Related/self-host/full-suite batch verification follows with the traversal fix (G1-era predicate shape hangs import bindings on large bodies; fixed next). Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 55 +++++++++++++++++++ .../issue79_regression_test.py | 30 ++++++++++ 2 files changed, 85 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 01377079..9ce0a670 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -12799,6 +12799,59 @@ def python_import_bindings(tree): return None return dotted + from_import_command_leaves = { + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module in python_command_modules + for alias in node.names + if alias.name in python_command_leaf_names + } + + def default_launcher_candidates(node, seen): + found = set() + stack = [node] + while stack: + current = stack.pop() + if current is None or id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, ast.IfExp): + stack.extend((current.body, current.orelse)) + elif isinstance(current, ast.BoolOp): + stack.extend(current.values) + elif isinstance(current, ast.NamedExpr): + stack.append(current.value) + elif isinstance(current, (ast.Tuple, ast.List, ast.Set)): + stack.extend(current.elts) + elif isinstance(current, ast.Dict): + stack.extend(key for key in current.keys if key is not None) + stack.extend(current.values) + elif isinstance(current, ast.Subscript): + stack.append(current.value) + elif isinstance(current, ast.Call) and isinstance( + current.func, ast.Lambda + ): + stack.append(current.func.body) + elif isinstance(current, ast.Lambda): + stack.append(current.body) + elif isinstance(current, ast.Call): + stack.append(current.func) + stack.extend(current.args) + stack.extend( + keyword.value for keyword in current.keywords + ) + else: + resolved = resolve_binding(current) + if resolved in python_command_functions: + found.add(resolved) + elif ( + isinstance(current, ast.Name) + and current.id in from_import_command_leaves + ): + found.add(current.id) + return found + # 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 @@ -12844,6 +12897,8 @@ def python_import_bindings(tree): resolved = resolve_binding(default) if resolved in python_command_functions: functions[parameter.arg] = resolved + elif default_launcher_candidates(default, set()): + functions[parameter.arg] = None return modules, functions, unresolved diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 9fbd839e..4c4f6ec2 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -6799,6 +6799,36 @@ def test_launchers_bound_through_callable_defaults_stay_classified(self) -> None self.assertIsNone(self.inspect('def f(fn=len):\n print(fn("reviewed"))\nf()\n')) self.assertIsNone(self.inspect('import os\ndef f(launch=os.system):\n print("reviewed")\nf()\n')) + def test_launchers_nested_in_default_expressions_stay_classified(self) -> None: + for body in ( + 'import os\nflag = True\ndef f(launch=os.system if flag else len):\n' + ' launch("rm -rf /tmp/maintainer-owned")\nf()\n', + 'import os\ndef f(launch=(os.system, len)[0]):\n' + ' launch("rm -rf /tmp/maintainer-owned")\nf()\n', + 'import os\ndef f(launch={"a": os.system}["a"]):\n' + ' launch("rm -rf /tmp/maintainer-owned")\nf()\n', + 'import os\ndef f(launch=len and os.system):\n' + ' launch("rm -rf /tmp/maintainer-owned")\nf()\n', + 'import os\ndef f(launch=(lambda: os.system)()):\n' + ' launch("rm -rf /tmp/maintainer-owned")\nf()\n', + 'import os\ndef f(launch=(runner := os.system)):\n' + ' launch("rm -rf /tmp/maintainer-owned")\nf()\n', + 'from os import system\ndef f(launch=system if True else len):\n' + ' launch("rm -rf /tmp/maintainer-owned")\nf()\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body)) + self.assertIsNone(self.inspect( + 'def f(cb=len if True else str):\n print(cb("8"))\nf()\n' + )) + self.assertIsNone(self.inspect( + 'def run(cmd):\n return cmd\ndef f(cb=run):\n print(cb("8"))\nf()\n' + )) + self.assertIsNone(self.inspect( + 'import os\ndef f(launch=os.system if True else len):\n' + ' print("reviewed")\nf()\n' + )) + def test_python_heredoc_requires_quoted_delimiter(self) -> None: for opener in ( "/opt/homebrew/bin/python3 -I - < Date: Wed, 14 Oct 2026 23:59:18 +0900 Subject: [PATCH 105/123] Analyze operand sensitivity with an iterative worklist The mutually recursive error_operand_sensitive / error_operand_keys_sensitive pair made launcher-index traversal blow up combinatorially once G1 added call-argument edges: every BODY 19 import-bindings pass (smoke, compiler and origin self audits, callback self audit) hung instead of completing. Bisected clean at d9b6a2d (3.3s) to hanging at a0849e3; faulthandler showed iterable_may_contain_launcher cycling through return_values with tens of thousands of redundant resolutions. Replace both predicates with one worklist over (node, domain) pairs that expands each pair once, preserving verdicts: same True sources, same first-match scope shadowing, same per-entry domains, same return/argument expansion rules. No behavior change is intended; the traversal can no longer cycle through operand analysis because no local recursive edges remain. RED: test_current_scanner_self_audit_ast_parameters_are_not_callbacks exceeds 120s pre-fix (G1..G7 states). GREEN: same test ok in 3.8s post-fix; all 10 finding tests ok; related 72 pass (14s); self-host scan of 85 small bodies reports 0 flags (19s). Full suite gates the push. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 410 +++++++++++++-------------- 1 file changed, 202 insertions(+), 208 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 9ce0a670..a64d9308 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -21832,222 +21832,216 @@ def python_sensitive_read_violation(tree, parents): return True return False - def error_operand_keys_sensitive(node, seen): - if node is None or id(node) in seen: - return False - seen.add(id(node)) - if isinstance(node, ast.Dict): - return any( - error_operand_sensitive(key, set(seen)) - for key in node.keys - if key is not None - ) - if isinstance(node, ast.DictComp): - return error_operand_sensitive(node.key, set(seen)) - if isinstance(node, (ast.List, ast.Tuple, ast.Set)): - return any( - error_operand_sensitive(item, set(seen)) for item in node.elts - ) - if isinstance(node, ast.BinOp): - return error_operand_keys_sensitive( - node.left, set(seen) - ) or error_operand_keys_sensitive(node.right, set(seen)) - if isinstance(node, ast.IfExp): - return error_operand_keys_sensitive( - node.body, set(seen) - ) or error_operand_keys_sensitive(node.orelse, set(seen)) - if python_dotted_name(node) in {"os.environ", "os.environb"}: - return False - if isinstance(node, ast.Call): - if isinstance(node.func, ast.Name) and node.func.id == "dict": - return error_operand_keys_sensitive( - node.args[0] if node.args else None, set(seen) - ) - if not isinstance(node.func, ast.Attribute): - if ( - isinstance(node.func, ast.Name) - and node.func.id in error_operand_element_iterators - ): - return any( - error_operand_keys_sensitive(part, set(seen)) - for part in list(node.args) - + [keyword.value for keyword in node.keywords] + def error_operand_sensitive(value, keys_mode=False): + """Iterative operand-sensitivity reachability over (node, domain) pairs. + + A worklist (not mutual recursion) so launcher-index traversal cannot + blow up through call/return edges: each pair expands once. + """ + stack = [(value, keys_mode)] + seen_pairs = set() + while stack: + node, keys_only_mode = stack.pop() + if node is None: + continue + pair = (id(node), keys_only_mode) + if pair in seen_pairs: + continue + seen_pairs.add(pair) + if keys_only_mode: + if isinstance(node, ast.Dict): + stack.extend( + (key, False) for key in node.keys if key is not None ) - unpoisoned = set(seen) - unpoisoned.discard(id(node)) - return error_operand_sensitive(node, unpoisoned) - if node.func.attr in {"keys", "fromkeys"}: - return error_operand_keys_sensitive( - node.args[0] if node.args else node.func.value, set(seen) - ) - if node.func.attr == "copy": - return error_operand_keys_sensitive(node.func.value, set(seen)) - if node.func.attr == "items": - return error_operand_keys_sensitive(node.func.value, set(seen)) - unpoisoned = set(seen) - unpoisoned.discard(id(node)) - return error_operand_sensitive(node, unpoisoned) - if isinstance(node, ast.Name): - chain = python_lexical_scope_chain( - python_enclosing_scope(node, parents), parents - ) - visible_scopes = set(chain) - mutated_sensitive = False - for mutation_scope, key, is_mapping in error_operand_mutated_keys.get( - node.id, () - ): - if mutation_scope not in visible_scopes: + elif isinstance(node, ast.DictComp): + stack.append((node.key, False)) + elif isinstance(node, (ast.List, ast.Tuple, ast.Set)): + stack.extend((item, False) for item in node.elts) + elif isinstance(node, ast.BinOp): + stack.extend(((node.left, True), (node.right, True))) + elif isinstance(node, ast.IfExp): + stack.extend(((node.body, True), (node.orelse, True))) + elif python_dotted_name(node) in {"os.environ", "os.environb"}: continue - if is_mapping: - mutated_sensitive = error_operand_keys_sensitive(key, set(seen)) - else: - mutated_sensitive = error_operand_sensitive(key, set(seen)) - if mutated_sensitive: - return True - for scope in chain: - entries = [ - (binding, keys_only) - for binding_scope, binding, keys_only in error_operand_bindings.get( - node.id, () + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id == "dict": + stack.append( + (node.args[0] if node.args else None, True) + ) + elif not isinstance(node.func, ast.Attribute): + if ( + isinstance(node.func, ast.Name) + and node.func.id in error_operand_element_iterators + ): + stack.extend( + (part, True) + for part in list(node.args) + + [keyword.value for keyword in node.keywords] + ) + else: + stack.append((node, False)) + elif node.func.attr in {"keys", "fromkeys"}: + stack.append( + ( + node.args[0] + if node.args + else node.func.value, + True, + ) + ) + elif node.func.attr == "copy": + stack.append((node.func.value, True)) + elif node.func.attr == "items": + stack.append((node.func.value, True)) + else: + stack.append((node, False)) + elif isinstance(node, ast.Name): + chain = python_lexical_scope_chain( + python_enclosing_scope(node, parents), parents ) - if binding_scope is scope - ] - is_parameter = node.id in error_operand_parameters.get(scope, ()) - if entries or is_parameter: - resolutions = [ - error_operand_keys_sensitive(binding, set(seen)) - if keys_only - else error_operand_sensitive(binding, set(seen)) - for binding, keys_only in entries - ] - if is_parameter: - resolutions.extend( - error_operand_keys_sensitive(source, set(seen)) - for source_scope, source in assignments_by_name.get( + visible_scopes = set(chain) + for ( + mutation_scope, + key, + is_mapping, + ) in error_operand_mutated_keys.get(node.id, ()): + if mutation_scope not in visible_scopes: + continue + stack.append((key, True if is_mapping else False)) + for scope in chain: + entries = [ + (binding, keys_only) + for binding_scope, binding, keys_only in error_operand_bindings.get( node.id, () ) - if source_scope is scope + if binding_scope is scope + ] + is_parameter = node.id in error_operand_parameters.get( + scope, () ) - return any(resolutions) - return False - unpoisoned = set(seen) - unpoisoned.discard(id(node)) - return error_operand_sensitive(node, unpoisoned) - - def error_operand_sensitive(value, seen): - if value is None or id(value) in seen: - return False - seen.add(id(value)) - if isinstance(value, ast.Name): - chain = python_lexical_scope_chain( - python_enclosing_scope(value, parents), parents - ) - for scope in chain: - entries = [ - (binding, keys_only) - for binding_scope, binding, keys_only in error_operand_bindings.get( - value.id, () + if entries or is_parameter: + stack.extend( + (binding, True if keys_only else False) + for binding, keys_only in entries + ) + if is_parameter: + stack.extend( + (source, True) + for source_scope, source in assignments_by_name.get( + node.id, () + ) + if source_scope is scope + ) + break + else: + stack.append((node, False)) + else: + if isinstance(node, ast.Name): + chain = python_lexical_scope_chain( + python_enclosing_scope(node, parents), parents ) - if binding_scope is scope - ] - is_parameter = value.id in error_operand_parameters.get(scope, ()) - if entries or is_parameter: - resolutions = [ - error_operand_keys_sensitive(binding, set(seen)) - if keys_only - else error_operand_sensitive(binding, set(seen)) - for binding, keys_only in entries - ] - if is_parameter: - resolutions.extend( - error_operand_sensitive(source, set(seen)) - for source_scope, source in assignments_by_name.get( - value.id, () + for scope in chain: + entries = [ + (binding, keys_only) + for binding_scope, binding, keys_only in error_operand_bindings.get( + node.id, () ) - if source_scope is scope + if binding_scope is scope + ] + is_parameter = node.id in error_operand_parameters.get( + scope, () ) - return any(resolutions) - return False - if isinstance(value, ast.Attribute): - if python_dotted_name(value) in {"os.environ", "os.environb"}: - return True - return error_operand_sensitive(value.value, set(seen)) - if isinstance(value, ast.Call): - dotted = python_dotted_name(value.func) - if dotted in {"os.getenv", "os.getenvb"}: - return True - if isinstance(value.func, ast.Attribute): - if value.func.attr in error_operand_index_methods: - return False - parts = ( - [value.func.value] - + list(value.args) - + [keyword.value for keyword in value.keywords] - ) - return any( - error_operand_sensitive(part, set(seen)) for part in parts - ) - if isinstance(value.func, (ast.Name, ast.Lambda)): - returned = python_local_call_return_values(value, tree, parents) - if returned: - return any( - error_operand_sensitive(result, set(seen)) - for result in returned + if entries or is_parameter: + stack.extend( + (binding, True if keys_only else False) + for binding, keys_only in entries + ) + if is_parameter: + stack.extend( + (source, False) + for source_scope, source in assignments_by_name.get( + node.id, () + ) + if source_scope is scope + ) + break + elif isinstance(node, ast.Attribute): + if python_dotted_name(node) in {"os.environ", "os.environb"}: + return True + stack.append((node.value, False)) + elif isinstance(node, ast.Call): + dotted = python_dotted_name(node.func) + if dotted in {"os.getenv", "os.getenvb"}: + return True + if isinstance(node.func, ast.Attribute): + if node.func.attr not in error_operand_index_methods: + stack.append((node.func.value, False)) + stack.extend( + (argument, False) for argument in node.args + ) + stack.extend( + (keyword.value, False) + for keyword in node.keywords + ) + elif isinstance(node.func, (ast.Name, ast.Lambda)): + returned = python_local_call_return_values( + node, tree, parents + ) + if returned: + stack.extend( + (result, False) for result in returned + ) + elif isinstance(node.func, ast.Name) and ( + error_operand_callable_bases(node.func.id) + & (error_operand_propagators | error_operand_iterators) + or dotted + in { + "itertools.chain", + "itertools.chain.from_iterable", + } + ): + stack.extend( + (part, False) + for part in list(node.args) + + [keyword.value for keyword in node.keywords] + ) + elif isinstance(node, ast.Subscript): + if python_dotted_name(node.value) in { + "os.environ", + "os.environb", + }: + return True + stack.extend(((node.value, False), (node.slice, False))) + elif isinstance(node, ast.NamedExpr): + stack.append((node.value, False)) + elif isinstance(node, (ast.List, ast.Tuple, ast.Set)): + stack.extend((item, False) for item in node.elts) + elif isinstance(node, ast.Dict): + stack.extend( + (item, False) + for item in list(node.keys) + list(node.values) + if item is not None ) - if isinstance(value.func, ast.Name) and ( - error_operand_callable_bases(value.func.id) - & (error_operand_propagators | error_operand_iterators) - or dotted - in { - "itertools.chain", - "itertools.chain.from_iterable", - } - ): - parts = list(value.args) + [ - keyword.value for keyword in value.keywords - ] - return any( - error_operand_sensitive(part, set(seen)) for part in parts + elif isinstance(node, ast.IfExp): + stack.extend(((node.body, False), (node.orelse, False))) + elif isinstance(node, (ast.BinOp, ast.BoolOp, ast.UnaryOp)): + stack.extend( + (child, False) + for child in ast.iter_child_nodes(node) + if isinstance(child, ast.expr) ) - return False - if isinstance(value, ast.Subscript): - if python_dotted_name(value.value) in {"os.environ", "os.environb"}: - return True - return error_operand_sensitive( - value.value, set(seen) - ) or error_operand_sensitive(value.slice, set(seen)) - if isinstance(value, ast.NamedExpr): - return error_operand_sensitive(value.value, set(seen)) - if isinstance(value, (ast.List, ast.Tuple, ast.Set)): - return any( - error_operand_sensitive(item, set(seen)) for item in value.elts - ) - if isinstance(value, ast.Dict): - return any( - error_operand_sensitive(item, set(seen)) - for item in list(value.keys) + list(value.values) - if item is not None - ) - if isinstance(value, ast.IfExp): - return error_operand_sensitive( - value.body, set(seen) - ) or error_operand_sensitive(value.orelse, set(seen)) - if isinstance(value, (ast.BinOp, ast.BoolOp, ast.UnaryOp)): - return any( - error_operand_sensitive(child, set(seen)) - for child in ast.iter_child_nodes(value) - if isinstance(child, ast.expr) - ) - if isinstance(value, (ast.JoinedStr, ast.FormattedValue)): - return any( - error_operand_sensitive(child, set(seen)) - for child in ast.iter_child_nodes(value) - if isinstance(child, ast.expr) - ) - if isinstance(value, ast.Starred): - return error_operand_sensitive(value.value, set(seen)) - if isinstance(value, (ast.Compare, ast.Slice)): - return False + elif isinstance(node, (ast.JoinedStr, ast.FormattedValue)): + stack.extend( + (child, False) + for child in ast.iter_child_nodes(node) + if isinstance(child, ast.expr) + ) + elif isinstance(node, ast.Starred): + stack.append((node.value, False)) + elif isinstance(node, (ast.Compare, ast.Slice)): + continue + else: + continue return False def error_operand_repr_from_class(classdef, seen): @@ -22063,7 +22057,7 @@ def python_sensitive_read_violation(tree, parents): if ( isinstance(candidate, ast.Return) and candidate.value is not None - and error_operand_sensitive(candidate.value, set(seen)) + and error_operand_sensitive(candidate.value) ): return True for base in classdef.bases: @@ -22184,7 +22178,7 @@ def python_sensitive_read_violation(tree, parents): and not isinstance(node.ctx, ast.Store) and python_dotted_name(node.value) not in {"os.environ", "os.environb"} - and error_operand_sensitive(node.slice, set()) + and error_operand_sensitive(node.slice) ): return ( "Python sensitive value is used as a subscript that may surface " @@ -22236,7 +22230,7 @@ def python_sensitive_read_violation(tree, parents): if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) if error_operand_conversion_callable(node.func, set()) and any( - error_operand_sensitive(value, set()) + error_operand_sensitive(value) for value in list(node.args) + [keyword.value for keyword in node.keywords] ): From 1407f88a58a796a7bec8b1736ca1e179dc8678d3 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Thu, 15 Oct 2026 00:32:25 +0900 Subject: [PATCH 106/123] H1: rename scanner-internal locals colliding with body-global name proofs Self-audit RED: test_current_packet_has_no_static_scanner_violations failed twice after the G-batch. Both failures share one root cause: locals inside new scanner code reuse names that the scanner's scope-less body-global maps key on, flipping verdicts for pre-existing body19 code. 1. G6 conversion-callable Subscript walker bound 'container'/'containers' (for-target + list). python_regex_match_receiver all-quantifies over assignments+iterable_bindings, so the extra 'containers' binding broke the re.match proof for 'container.group(1)' in fence_details, yielding 'unresolved Path receiver for .group'. Renamed to option_container/option_containers (verified absent from body19). 2. G5 from_import_candidates bound 'matches = sorted(...)'. The candidate_aliases fixpoint absorbed the name, writing functions['matches']=None, which turned the pre-existing recursive 'matches(value, {name})' call into an 'unresolved command-capable call'. Renamed to ordered_matches. Evidence: pre/post None-valued functions-key diff isolates the pollution; after the renames only unlooked-up names (never called, never attribute receivers) remain newly-None, confirmed by a current-body lookup scan. GREEN: smoke test passes in 245.7s with 261 shell commands + 86 Python bodies and zero violations. No new unit test: the self-audit tests are the regression pins for this self-hosting invariant. Co-authored-by: Cursor --- docs/evidence/g01-recovery-packet.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a64d9308..6f80dc5b 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -16246,15 +16246,15 @@ def python_unknown_os_call_violation(tree): from_import_parents, ) for depth, scope in enumerate(chain): - matches = sorted( + ordered_matches = sorted( (position, dotted) for bound, dotted, scope_node, position in from_import_bindings if bound == name and scope_node is scope and (depth > 0 or position < call_position) ) - if matches: - return [dotted for _position, dotted in matches] + if ordered_matches: + return [dotted for _position, dotted in ordered_matches] return [] def from_import_call_violation(dotted, resolved_from_import, call): @@ -21781,33 +21781,33 @@ def python_sensitive_read_violation(tree, parents): return True return False if isinstance(func, ast.Subscript): - containers = [] + option_containers = [] target = func.value if isinstance(target, (ast.List, ast.Tuple)): - containers.append(target) + option_containers.append(target) elif isinstance(target, ast.Name): chain = python_lexical_scope_chain( python_enclosing_scope(target, parents), parents ) for scope in chain: - containers.extend( + option_containers.extend( binding for binding_scope, binding, _keys in error_operand_bindings.get( target.id, () ) if binding_scope is scope ) - containers.extend( + option_containers.extend( source for source_scope, source in assignments_by_name.get( target.id, () ) if source_scope is scope ) - for container in containers: - if not isinstance(container, (ast.List, ast.Tuple)): + for option_container in option_containers: + if not isinstance(option_container, (ast.List, ast.Tuple)): continue - elements = list(container.elts) + elements = list(option_container.elts) index = func.slice if ( isinstance(index, ast.Constant) From 0c948107c0a2da390d3632449c54ca09f3d5ea7e Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Thu, 15 Oct 2026 08:04:20 +0900 Subject: [PATCH 107/123] fix(evidence): refuse unsupported scanner AST runtimes --- .../issue79-local-continuation-2026-10-15.md | 71 +++++++++++++++++++ .../issue79_regression_test.py | 34 +++++++++ 2 files changed, 105 insertions(+) create mode 100644 docs/evidence/issue79-local-continuation-2026-10-15.md diff --git a/docs/evidence/issue79-local-continuation-2026-10-15.md b/docs/evidence/issue79-local-continuation-2026-10-15.md new file mode 100644 index 00000000..d6d9986b --- /dev/null +++ b/docs/evidence/issue79-local-continuation-2026-10-15.md @@ -0,0 +1,71 @@ +# Issue #79 local continuation — 2026-10-15 + +Issue: https://github.com/1XP-AI/gh-runner/issues/79 + +## Source and scope + +The resumed local inspection started from Cursor's clean branch head +`1407f88a58a796a7bec8b1736ca1e179dc8678d3`. That head contains 17 commits +after the Go 1.26.9 packet-profile commit `f362d54`. Inspection covered the +changed scanner boundaries and regression specimens, the trusted definition +loader, and the current-packet static scan. This is an implementer's local +review, not an independent review or a GitHub exact-head approval. + +The packet under test is `docs/evidence/g01-recovery-packet.md`, Git blob +`6f80dc5bfe2c351ec39814234d82e07de7e6324c`. This continuation does not change +that packet. Recipes and negative specimens remain inert text/AST; none were +executed. Only inspected trusted harness/scanner definitions ran. No private +credentials, live runners, GitHub writes, push, PR, or merge were involved. + +## Finding and minimal fix + +The harness previously entered scanner analysis on Python 3.9.6 despite missing +structural-pattern AST classes. An initial focused invocation produced 62 +`AttributeError` errors across 11 test methods. That is a runtime setup failure, +not the behavior-change RED evidence below. The scanner also references +`ast.TryStar`, so its required AST surface needs Python 3.11 or later. + +The fix checks the five required AST node classes before extracting/loading +scanner definitions. Both the loader and CLI reject missing support; the CLI +prints a single diagnostic and exits 1. Supported runtimes retain the loader. +No scanner acceptance rule or toolchain pin was relaxed. + +## Actual TDD and verification + +All supported-runtime checks below used Python 3.14.3 with isolated mode `-I`, +no bytecode writes `-B`, and an allowlisted PATH/locale-only environment. + +- RED, before the implementation: `ScannerRuntimeTests` ran 2 methods in + 0.007 seconds, with 5 failing missing-feature subcases. Each reached the mocked + definition loader instead of refusing first. The supported-runtime positive + control passed. +- GREEN, after the implementation: the same 2 methods passed in 0.001 seconds. +- Post-fix focused integration: those 2 methods plus the 11 newly added Cursor + boundary methods passed together (13 methods, 1.708 seconds). Boundaries cover + imported OS aliases, call-site resolution, callable defaults, nested defaults, + quoted heredocs, implicit exception disclosure, complex conversions, + conversion aliases, argument flow, mutated mapping keys, and sensitive key + representations. Negative specimens were inspected, never executed. +- Post-fix packet toolchain profile: 4 tests passed in 0.023 seconds. +- Additional changed-risk controls: reviewed-path readers, concatenated + reflective reader names, and unrelated nested function-name collisions + passed (3 methods, 0.303 seconds). Both rejecting and accepting specimens + were inspected as data. +- Direct unsupported-runtime CLI check on Python 3.9.6: asserted exit 1, + exactly one stderr line naming the missing AST support, and no traceback. +- Current-packet static scan: 1 test passed in 364.828 seconds (wrapper wall + time 365.445 seconds), exit 0; 261 shell commands and 86 Python heredoc bodies + inspected with zero violations. This ran once against the unchanged packet + blob above, using the post-fix harness. Packet examples were not executed. +- `git diff --check`: passed during editing; repeated before the local commit. + +## Limitations and rollback + +The full regression suite, hosted checks, independent review, GitHub Codex +review, and live qualification were not run in this continuation. This local +result does not complete all acceptance criteria, close #79, merge its PR, or +satisfy G01/G02 production gates. The live board had no Ready issues when checked; +historical backlog values do not authorize starting dependent implementation. + +Rollback is a local revert of this continuation's commit. Cursor's existing +packet and all historical evidence remain unchanged. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 4c4f6ec2..daf9b209 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -22,6 +22,7 @@ import time import types import unittest +from unittest import mock from pathlib import Path @@ -222,7 +223,18 @@ def _validated_scanner_statements(module: ast.Module) -> tuple[ast.stmt, ...]: return tuple(module.body) +def _require_scanner_ast_runtime() -> None: + required = ("Match", "MatchAs", "MatchStar", "MatchMapping", "TryStar") + missing = [name for name in required if not isinstance(getattr(ast, name, None), type)] + if missing: + raise RuntimeError( + "scanner regression harness requires Python 3.11+ AST support; missing " + + ", ".join(missing) + ) + + def _scanner_namespace() -> dict[str, object]: + _require_scanner_ast_runtime() source = _scanner_module_source(PACKET_TEXT) module = ast.parse(source, filename="") namespace: dict[str, object] = { @@ -548,6 +560,24 @@ def _isolated_git_shell_command(arguments: list[str]) -> str: ) +class ScannerRuntimeTests(unittest.TestCase): + def test_missing_ast_support_refuses_before_loading_scanner(self) -> None: + for name in ("Match", "MatchAs", "MatchStar", "MatchMapping", "TryStar"): + with self.subTest(feature=name), mock.patch.object(ast, name, None), mock.patch( + __name__ + "._scanner_module_source", + side_effect=AssertionError("scanner definitions must not load"), + ) as source: + with self.assertRaisesRegex(RuntimeError, "Python 3.11"): + _scanner_namespace() + source.assert_not_called() + + def test_supported_ast_runtime_keeps_loader_available(self) -> None: + with mock.patch(__name__ + "._scanner_module_source", return_value="") as source: + namespace = _scanner_namespace() + source.assert_called_once_with(PACKET_TEXT) + self.assertIs(namespace["ast"], ast) + + class Issue79RegressionTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -7187,4 +7217,8 @@ def test_current_packet_has_no_static_scanner_violations(self) -> None: if __name__ == "__main__": + try: + _require_scanner_ast_runtime() + except RuntimeError as error: + sys.exit(str(error)) unittest.main(verbosity=2) From 80ee23053b53f94dacc35fd09d8a51546b2ca5e3 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Thu, 15 Oct 2026 14:59:28 +0900 Subject: [PATCH 108/123] docs: correct issue 79 evidence repository link --- docs/evidence/issue79-local-continuation-2026-10-15.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/evidence/issue79-local-continuation-2026-10-15.md b/docs/evidence/issue79-local-continuation-2026-10-15.md index d6d9986b..f856d247 100644 --- a/docs/evidence/issue79-local-continuation-2026-10-15.md +++ b/docs/evidence/issue79-local-continuation-2026-10-15.md @@ -1,6 +1,6 @@ # Issue #79 local continuation — 2026-10-15 -Issue: https://github.com/1XP-AI/gh-runner/issues/79 +Issue: https://github.com/1XP-AI/gh-runnerd/issues/79 ## Source and scope From f54ce1d40541385a12ad13795af375d0128c4e6f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Thu, 22 Oct 2026 18:59:14 +0900 Subject: [PATCH 109/123] Close PR103 argument, callable and display provenance gaps Reproduce all eight P1 findings from review comment 6172592368 as inert AST fixtures before fail-closed scanner corrections. Include the two coordinator-requested constructor-alias and dictionary-unpack probes, safe controls, and immutable historical F/G finding links. CPython 3.14.3 isolated -I -B with PATH/locale-only environment: final 21 focused tests pass in 0.448s; one final-source packet smoke passes in 537.667s, inspecting 261 shell commands and 86 Python bodies with zero violations. Record interrupted and preceding-source runs separately in the additive ledger. No recipe/specimen execution, live operations, GitHub writes, push or merge. --- docs/evidence/g01-recovery-packet.md | 236 +++++++++++++----- .../issue79-pr103-review-6172592368.md | 151 +++++++++++ .../issue79_regression_test.py | 103 ++++++++ 3 files changed, 433 insertions(+), 57 deletions(-) create mode 100644 docs/evidence/issue79-pr103-review-6172592368.md diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 6f80dc5b..95e8f0ca 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10085,6 +10085,10 @@ def python_local_method_candidates(attribute, call_node, tree, parents): elif isinstance(value, ast.Name) and value.id not in seen_names: seen_names.add(value.id) if value.id != "self": + class_names.update(python_local_class_alias_names( + value.id, attribute.attr, visible_scopes, + methods_by_scope, assignments_by_scope, + )) binding_scopes = set(python_lexical_scope_chain( python_enclosing_scope(value, parents), parents )) or visible_scopes @@ -10122,6 +10126,8 @@ def python_local_bound_method_candidates(name, call_node, tree, parents): python_local_call_return_values(call_node, tree, parents) index = getattr(tree, "_issue79_local_return_index", None) assignments_by_scope, _methods_by_scope = index + if not _methods_by_scope: + return [] visible_scopes = set(python_lexical_scope_chain( python_enclosing_scope(call_node, parents), parents )) @@ -12827,6 +12833,14 @@ def python_import_bindings(tree): elif isinstance(current, ast.Dict): stack.extend(key for key in current.keys if key is not None) stack.extend(current.values) + elif isinstance(current, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)): + if isinstance(current, ast.DictComp): + stack.extend((current.key, current.value)) + else: + stack.append(current.elt) + for generator in current.generators: + stack.append(generator.iter) + stack.extend(generator.ifs) elif isinstance(current, ast.Subscript): stack.append(current.value) elif isinstance(current, ast.Call) and isinstance( @@ -12898,7 +12912,7 @@ def python_import_bindings(tree): if resolved in python_command_functions: functions[parameter.arg] = resolved elif default_launcher_candidates(default, set()): - functions[parameter.arg] = None + functions[parameter.arg] = unresolved_starred_launcher_container return modules, functions, unresolved @@ -12973,7 +12987,19 @@ def python_class_command_attribute_violation(tree, modules, functions): if node.name in python_command_modules: continue for statement in node.body: - if isinstance(statement, ast.Assign): + if isinstance(statement, ast.ImportFrom): + for alias in statement.names: + resolved = f"{statement.module}.{alias.name}" + if resolved in python_command_functions: + attributes[f"{node.name}.{alias.asname or alias.name}"] = resolved + elif isinstance(statement, ast.Import): + for alias in statement.names: + for launcher in python_command_functions: + module = alias.name if alias.asname else alias.name.split('.')[0] + if launcher.startswith(module + '.'): + member = alias.asname or module + attributes[f"{node.name}.{member}.{launcher[len(module) + 1:]}"] = launcher + elif isinstance(statement, ast.Assign): for target in statement.targets: if isinstance(target, ast.Name): resolved = python_resolved_name(statement.value, modules, functions) @@ -13006,6 +13032,8 @@ unresolved_starred_launcher_container = "__g01_unresolved_starred_launcher_conta def python_call_target(node, modules, functions): """Resolve callable dunder invocation back to its launcher receiver.""" + if python_resolved_name(node, modules, functions) == unresolved_starred_launcher_container: + return "unresolved", python_dotted_name(node) or "launcher-bearing default" if isinstance(node, ast.Subscript): resolved_container = python_resolved_name(node.value, modules, functions) if resolved_container == unresolved_starred_launcher_container: @@ -21456,7 +21484,7 @@ def python_sensitive_read_violation(tree, parents): return None for function in ast.walk(tree): - if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): continue positional = helper_parameters(function) default_offset = len(positional) - len(function.args.defaults) @@ -21475,12 +21503,27 @@ def python_sensitive_read_violation(tree, parents): ) for call_node in ast.walk(tree): - if not isinstance(call_node, ast.Call) or not isinstance(call_node.func, ast.Name): + if not isinstance(call_node, ast.Call): continue - for function in python_local_function_candidates( - call_node.func.id, call_node, tree, parents - ): + candidates = [] + if isinstance(call_node.func, ast.Name): + candidates.extend((function, False) for function in ( + python_local_function_candidates(call_node.func.id, call_node, tree, parents) + + python_local_lambda_candidates(call_node.func.id, call_node, tree, parents) + )) + candidates.extend((function, True) for function in + python_local_bound_method_candidates(call_node.func.id, call_node, tree, parents)) + elif isinstance(call_node.func, ast.Lambda): + candidates.append((call_node.func, False)) + elif isinstance(call_node.func, ast.Attribute): + candidates.extend((function, True) for function in + python_local_method_candidates(call_node.func, call_node, tree, parents)) + for function, bound_method in candidates: positional = helper_parameters(function) + is_static = any(python_dotted_name(decorator) == "staticmethod" + for decorator in getattr(function, "decorator_list", ())) + if bound_method and positional and not is_static: + positional = positional[1:] for index, argument in enumerate(call_node.args): if isinstance(argument, ast.Starred): for parameter in positional[index:]: @@ -21640,6 +21683,35 @@ def python_sensitive_read_violation(tree, parents): (mutation_scope, key, is_mapping) for key, is_mapping in key_sources ) + # Mapping identity is shared in both directions across simple scoped aliases. + # Retain every possible binding conservatively; cycles converge by set union. + mutation_aliases = [] + for name, entries in error_operand_bindings.items(): + for scope, binding, keys_only in entries: + if keys_only or not isinstance(binding, ast.Name): + continue + for source_scope in python_lexical_scope_chain(scope, parents): + if any(entry_scope is source_scope for entry_scope, _value, _keys + in error_operand_bindings.get(binding.id, ())): + mutation_aliases.append(((scope, name), (source_scope, binding.id))) + break + scoped_mutations = {} + for name, entries in error_operand_mutated_keys.items(): + for scope, key, is_mapping in entries: + scoped_mutations.setdefault((scope, name), set()).add((key, is_mapping)) + changed = True + while changed: + changed = False + for left, right in mutation_aliases: + merged = scoped_mutations.get(left, set()) | scoped_mutations.get(right, set()) + for endpoint in (left, right): + if scoped_mutations.get(endpoint, set()) != merged: + scoped_mutations[endpoint] = set(merged) + changed = True + error_operand_mutated_keys = {} + for (scope, name), entries in scoped_mutations.items(): + error_operand_mutated_keys.setdefault(name, []).extend( + (scope, key, is_mapping) for key, is_mapping in entries) error_operand_classes = {} for candidate in ast.walk(tree): if isinstance(candidate, ast.ClassDef): @@ -21756,6 +21828,11 @@ def python_sensitive_read_violation(tree, parents): seen.add(id(func)) if python_dotted_name(func) in error_operand_conversions: return True + if isinstance(func, ast.Dict): + # An unpacked dictionary contributes its values to the callable + # container; unknown keys conservatively retain every option. + return any(error_operand_conversion_callable(value, set(seen)) + for value in func.values) if isinstance(func, ast.Name): if ( python_assigned_callable_alias(func.id, "int", tree) @@ -21783,7 +21860,7 @@ def python_sensitive_read_violation(tree, parents): if isinstance(func, ast.Subscript): option_containers = [] target = func.value - if isinstance(target, (ast.List, ast.Tuple)): + if isinstance(target, (ast.List, ast.Tuple, ast.Dict)): option_containers.append(target) elif isinstance(target, ast.Name): chain = python_lexical_scope_chain( @@ -21805,6 +21882,16 @@ def python_sensitive_read_violation(tree, parents): if source_scope is scope ) for option_container in option_containers: + if isinstance(option_container, ast.Dict): + elements = list(option_container.values) + if isinstance(func.slice, ast.Constant) and all( + isinstance(key, ast.Constant) for key in option_container.keys + ): + elements = [value for key, value in zip(option_container.keys, option_container.values) + if key.value == func.slice.value] + if any(error_operand_conversion_callable(item, set(seen)) for item in elements): + return True + continue if not isinstance(option_container, (ast.List, ast.Tuple)): continue elements = list(option_container.elts) @@ -22044,14 +22131,14 @@ def python_sensitive_read_violation(tree, parents): continue return False - def error_operand_repr_from_class(classdef, seen): + def error_operand_repr_from_class(classdef, seen, methods): if id(classdef) in seen: return False seen.add(id(classdef)) for entry in classdef.body: if ( isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) - and entry.name == "__repr__" + and entry.name in methods ): for candidate in ast.walk(entry): if ( @@ -22063,56 +22150,82 @@ def python_sensitive_read_violation(tree, parents): for base in classdef.bases: if isinstance(base, ast.Name): for _scope, other in error_operand_classes.get(base.id, ()): - if error_operand_repr_from_class(other, set(seen)): + if error_operand_repr_from_class(other, set(seen), methods): return True return False - def error_operand_repr_sensitive(value, seen): - if value is None or id(value) in seen: - return False - seen.add(id(value)) - if isinstance(value, ast.Call): - func = value.func - if not isinstance(func, ast.Name): - return False - chain = set( - python_lexical_scope_chain( - python_enclosing_scope(value, parents), parents - ) - ) - return any( - error_operand_repr_from_class(classdef, set(seen)) - for class_scope, classdef in error_operand_classes.get(func.id, ()) - if class_scope in chain - ) - if isinstance(value, ast.Name): - chain = python_lexical_scope_chain( - python_enclosing_scope(value, parents), parents - ) - for scope in chain: - entries = [ - binding - for binding_scope, binding, _keys in error_operand_bindings.get( - value.id, () - ) - if binding_scope is scope - ] - is_parameter = value.id in error_operand_parameters.get(scope, ()) - if entries or is_parameter: - resolutions = [ - error_operand_repr_sensitive(binding, set(seen)) - for binding in entries - ] - if is_parameter: - resolutions.extend( - error_operand_repr_sensitive(source, set(seen)) - for source_scope, source in assignments_by_name.get( - value.id, () - ) - if source_scope is scope - ) - return any(resolutions) + def error_operand_repr_sensitive(value, seen, methods=("__repr__",)): + # Factory/parameter graphs can contain cycles and many shared paths. + # Expand each expression once, rather than cloning a recursive path set. + if not error_operand_classes: return False + stack = [value] + seen = set(seen) + while stack: + value = stack.pop() + if value is None or id(value) in seen: + continue + seen.add(id(value)) + if isinstance(value, ast.Call): + stack.extend(python_local_call_return_values(value, tree, parents)) + if python_dotted_name(value.func) in {"str", "repr", "ascii", "format"}: + stack.extend(value.args) + constructors = [value.func] + seen_constructors = set() + while constructors: + constructor = constructors.pop() + if id(constructor) in seen_constructors: + continue + seen_constructors.add(id(constructor)) + if isinstance(constructor, ast.IfExp): + constructors.extend((constructor.body, constructor.orelse)) + if not isinstance(constructor, ast.Name): + continue + chain = python_lexical_scope_chain( + python_enclosing_scope(constructor, parents), parents) + for scope in chain: + classes = [classdef for class_scope, classdef + in error_operand_classes.get(constructor.id, ()) + if class_scope is scope] + if any(error_operand_repr_from_class(classdef, set(), methods) + for classdef in classes): + return True + entries = [binding for binding_scope, binding, _keys + in error_operand_bindings.get(constructor.id, ()) + if binding_scope is scope] + is_parameter = constructor.id in error_operand_parameters.get(scope, ()) + if classes or entries or is_parameter: + constructors.extend(entries) + if is_parameter: + constructors.extend(source for source_scope, source + in assignments_by_name.get(constructor.id, ()) + if source_scope is scope) + break + elif isinstance(value, ast.Name): + chain = python_lexical_scope_chain( + python_enclosing_scope(value, parents), parents) + for scope in chain: + entries = [binding + for binding_scope, binding, _keys in error_operand_bindings.get(value.id, ()) + if binding_scope is scope] + is_parameter = value.id in error_operand_parameters.get(scope, ()) + if entries or is_parameter: + stack.extend(entries) + if is_parameter: + stack.extend(source + for source_scope, source in assignments_by_name.get(value.id, ()) + if source_scope is scope) + break + elif isinstance(value, (ast.List, ast.Tuple, ast.Set)): + stack.extend(value.elts) + elif isinstance(value, ast.Dict): + stack.extend(list(value.keys) + list(value.values)) + elif isinstance(value, ast.IfExp): + stack.extend((value.body, value.orelse)) + elif isinstance(value, ast.JoinedStr): + stack.extend(value.values) + elif isinstance(value, ast.FormattedValue): + stack.append(value.value) return False def exception_arguments(value, seen_names=None, follow_names=True): @@ -22229,6 +22342,14 @@ def python_sensitive_read_violation(tree, parents): ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) + if len(node.args) >= 2 and ( + dotted in {"getattr", "delattr", "builtins.getattr", "builtins.delattr"} + or isinstance(node.func, ast.Name) and any( + python_assigned_callable_alias(node.func.id, lookup, tree) + for lookup in ("getattr", "delattr")) + ) and (error_operand_sensitive(node.args[1]) or error_operand_repr_sensitive(node.args[1], set())): + return ("Python sensitive attribute name may surface in an exception " + f"message on line {node.lineno}") if error_operand_conversion_callable(node.func, set()) and any( error_operand_sensitive(value) for value in list(node.args) @@ -22292,7 +22413,8 @@ def python_sensitive_read_violation(tree, parents): f"{dotted or ''!r} on line {node.lineno}" ) if python_sensitive_output_sink(node, tree) and any( - python_sensitive_value_expression( + error_operand_repr_sensitive(argument, set(), ("__str__", "__repr__", "__format__")) + or python_sensitive_value_expression( argument, sensitive_names, tree, parents ) or ( diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md new file mode 100644 index 00000000..f02f4833 --- /dev/null +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -0,0 +1,151 @@ +# Issue #79 — PR #103 blocking-fix ledger + +This additive writer ledger covers the eight P1 findings in the +[Codex review comment](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6172592368) +against immutable source `1407f88a58a796a7bec8b1736ca1e179dc8678d3`. +The local correction starts at `80ee23053b53f94dacc35fd09d8a51546b2ca5e3`. +Authorship follows the current explicit override: Codex `gpt-6.1-sol`, medium. +This is writer evidence, not independent or exact-head GitHub review approval. +Prior finding dispositions remain in the packet's historical evidence at the +immutable starting head; the [local continuation](issue79-local-continuation-2026-10-15.md) +records the earlier runtime correction. This ledger adds the current review +delta and does not replace those records or assert new approval of them. + +Every finding below is blocking P1, reproduced using inert source passed to +`inspect_python_heredoc`, then corrected locally. No specimen, packet recipe, +malicious string or returned callable was executed. Only inspected trusted +scanner/harness definitions executed, under CPython 3.14.3 with `-I -B` and an +allowlisted environment containing only `PATH` and `LC_ALL`. No credentials, +live runner operations, GitHub writes, push, PR mutation or merge were used. + +| Finding in the linked immutable review | Red evidence | Local resolution and focused evidence | +|---|---|---| +| Propagate arguments into lambda and method parameters | Inline and assigned lambda keys, instance method keys and static method keys were accepted. | Bind arguments to visible lambdas and resolved methods, preserving keyword/default/variadic flow and static versus bound parameters. `test_pr103_lambda_method_error_arguments` rejects all four forms and accepts a literal-key lambda. | +| Follow mutations through mapping aliases | Five mutation forms through cyclic aliases were accepted before iterating the original mapping. | Compute a scoped, bidirectional alias closure for recorded keys, with set union terminating cycles. `test_pr103_mapping_alias_mutations` covers subscript store, `update`, `setdefault`, `__setitem__` and `|=`; literal keys remain accepted. Existing unrelated-function and environment-key positives remain green. | +| Track conversion callables stored in dictionaries | Dictionary-held `int`, `float` and `complex` conversions of a sensitive operand were accepted. | Resolve literal dictionary keys precisely; conservatively inspect all values for unresolved keys/unpacks. `test_pr103_dictionary_conversion_callables` rejects all three conversions and accepts literal input and a known nonconversion dictionary entry. | +| Follow factory-returned keys into repr checks | Direct and chained factory-returned custom keys were accepted. | Follow local function, lambda and method returns before inspecting class representation methods, with visited-node guards. `test_pr103_factory_returned_repr_keys` rejects both factory depths and accepts a safe representation. Existing direct/inherited/parameter key controls remain green. | +| Traverse comprehensions in launcher defaults | List, set, generator and dictionary comprehensions containing launchers were accepted. | Inspect yielded expressions, iterable sources and filters; mark launcher-bearing unresolved defaults so direct and subscript calls fail closed. `test_pr103_comprehension_launcher_defaults` covers six forms, including subprocess workflow argv, and accepts a `len` default. Existing direct/nested default controls remain green. | +| Reject sensitive names passed to attribute lookup | Direct `getattr` and `delattr` sensitive names were accepted. | Apply operand sensitivity to attribute-name arguments, including assigned callable aliases. `test_pr103_sensitive_attribute_names` rejects both direct and alias forms and accepts a literal name. | +| Classify launchers imported into class namespaces | Class-qualified from-import launcher aliases were accepted. | Retain qualified launcher provenance for class `ImportFrom` and `Import` bindings. `test_pr103_class_namespace_launchers` rejects OS and subprocess forms, retains the already-rejected class module form, and accepts an allowed `re` module member. | +| Trace custom display methods at output sinks | Custom display objects at `print`, `sys.exit` and factory-result sinks were accepted. | Follow local display-method returns, inherited methods, factories, container entries and explicit string/format wrappers at sinks. `test_pr103_display_methods_at_sinks` covers `__str__`, `__repr__`, `__format__`, direct/factory arguments and string/f-string wrappers; safe display remains accepted. All three protocols are conservatively considered at these sinks, even when a particular sink would choose only one. | + +## Historical findings carried forward + +These are historical P1 resolution records, not tests rerun by this writer. +The source and correction identities below were verified against local Git; +RED/GREEN summaries are attributed to the correction commit bodies. Exact +original command lines are unavailable in those bodies. Preserve the original +model/authorship records in those commits; the current model override does not +retroactively apply to them. Historical batch evidence is linked separately in +[F batch evidence](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6169860543) +and [G batch evidence](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6172431649). + +F1–F4 share the immutable finding URL +[review comment 6167225325](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6167225325) +and source `db79bde7b40a17740e27f4671e0838bcaaeaa310`. + +| ID / finding | Correction and historical evidence | Disposition | +|---|---|---| +| F1 unlisted from-import OS mutators | [cea81e7f2349c449099ba744ad80b2d4e6b785cb](https://github.com/1XP-AI/gh-runnerd/commit/cea81e7f2349c449099ba744ad80b2d4e6b785cb): 4 unsafe RED assertions; focused GREEN 0.19s; related 105 tests, 31s; packet smoke 240s, 261 commands / 86 bodies, zero violations. | Historically corrected; retain fail-closed imported-call classification. | +| F2 implicit exception operands | [7638c514ff941fc8d9ec0040670ea651c5b9d350](https://github.com/1XP-AI/gh-runnerd/commit/7638c514ff941fc8d9ec0040670ea651c5b9d350): 14 unsafe RED assertions, 10 safe controls; focused GREEN 0.23s; related 68 tests, 13s; recorded full suite 180 pass in 829s and zero-violation packet smoke. | Historically corrected; current review adds separate lambda/method/dictionary/attribute/display extensions. | +| F3 callable launcher defaults | [7712a2f3979a16a21e442051cc01d0f62a49e1a9](https://github.com/1XP-AI/gh-runnerd/commit/7712a2f3979a16a21e442051cc01d0f62a49e1a9): 3 unsafe RED assertions; focused GREEN 0.19s; related 66 tests, 23s; packet smoke 224s, zero violations. | Historically corrected; current comprehension extension is recorded above. | +| F4 quoted heredoc delimiters | [6f61a40945b9277454c5fe8d68b4b4068a0f2827](https://github.com/1XP-AI/gh-runnerd/commit/6f61a40945b9277454c5fe8d68b4b4068a0f2827): 3 RED assertions; focused GREEN 0.19s; related 51 tests, 11s; packet smoke 223s, zero violations. | Historically corrected; quoted delimiter requirement retained. | + +G1–G7 share the immutable finding URL +[review comment 6169988457](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6169988457) +and source `7638c514ff941fc8d9ec0040670ea651c5b9d350`. +Their commit bodies describe the later batch verification as pending at commit +time; do not infer full-suite passes from those statements. + +| ID / finding | Correction and historical evidence | Disposition | +|---|---|---| +| G1 sensitive helper parameters | [a0849e3403b57a585b10a388007bf5083a3a5f35](https://github.com/1XP-AI/gh-runnerd/commit/a0849e3403b57a585b10a388007bf5083a3a5f35): 3 unsafe RED assertions; focused GREEN 0.21s (4 unsafe / 2 safe); related 70 pass, 17s; 85 small-body self-host probes, zero flags, 23s. | Historically corrected; current lambda/method extension above. | +| G2 late-inserted mapping keys | [6caab1ba332201bf13c0f44e38c07918ecdae943](https://github.com/1XP-AI/gh-runnerd/commit/6caab1ba332201bf13c0f44e38c07918ecdae943): 4 unsafe RED assertions; focused GREEN 0.21s (7 unsafe / 6 safe); related 71 pass, 15s; small-body self-host zero flags, 22s. | Historically corrected; current scoped alias closure above. | +| G3 complex conversions | [25a5e440a7a9994412c60e1ef118f0d8bd756299](https://github.com/1XP-AI/gh-runnerd/commit/25a5e440a7a9994412c60e1ef118f0d8bd756299): 2 unsafe RED assertions; focused GREEN 0.19s (2 unsafe / 2 safe); related 69 pass, 14s; small-body self-host zero flags, 20s. | Historically corrected; current dictionary conversion extension above. | +| G4 nested launcher defaults | [8ddfa6ac1d7935f823827ab9d88bd677e386d88c](https://github.com/1XP-AI/gh-runnerd/commit/8ddfa6ac1d7935f823827ab9d88bd677e386d88c): 7 unsafe RED assertions; focused GREEN 0.22s (7 unsafe / 3 safe), F3 regression green; broader traversal verification deferred in that commit. | Historically corrected; existing G4 regression is included in this writer's focused controls. | +| G5 from-import scope/order | [3eb6aa16ff76bfbb3774693436317fb667ca427f](https://github.com/1XP-AI/gh-runnerd/commit/3eb6aa16ff76bfbb3774693436317fb667ca427f): 3 RED assertions, including a safe call-before-import; focused GREEN 0.19s (2 unsafe / 2 safe); related 108 pass, 37s; small-body self-host zero flags, 20s. | Historically corrected; no source change to this resolution in the current delta. | +| G6 default/container conversion callables | [d9b6a2d8b1d46d3f62d040ea5a674334c2d8812d](https://github.com/1XP-AI/gh-runnerd/commit/d9b6a2d8b1d46d3f62d040ea5a674334c2d8812d): 4 unsafe RED assertions; focused GREEN 0.20s (4 unsafe / 2 safe); related 69 pass, 14s; small-body self-host zero flags, 19s. | Historically corrected; current dictionary extension above. | +| G7 custom key representations | [bdcabb4273b68734e9f93c81d095a1c398c83d92](https://github.com/1XP-AI/gh-runnerd/commit/bdcabb4273b68734e9f93c81d095a1c398c83d92): 4 unsafe RED assertions; focused GREEN 0.20s (4 unsafe / 4 safe); related 72 pass, 16s; small-body self-host zero flags, 22s. | Historically corrected; current factory/display extensions above. | + +## Actual validation of this correction + +Commands below use this common prefix, with a PATH/locale-only environment: + +`env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py` + +- Initial RED: `-k test_pr103` ran 8 methods in 0.391s and reported 34 + failures: 33 negative subcases plus one invalid positive control importing an + already-disallowed module. That control was corrected to an allowed `re` + class member; import policy was not relaxed. The class module-import negative + was already rejected. Direct attribute-name assertions failed before the + alias assertions could run. +- First correction: the same 8 methods passed in 0.343s. Valid set/generator + launcher invocations then replaced placeholder indexing, and extra explicit + string/f-string display probes produced 6 RED subcases in 2 methods (0.252s). + Those wrappers were then included in display traversal. +- The first broader run exposed 7 regressions in existing nested launcher + defaults after introducing the container marker. Direct calls to that marker + were corrected; it already rejected subscript calls. +- Two broader invocations were stopped by targeting only their owned test + processes after the scanner self-audit became slow; no final timing or pass + is claimed for those interrupted invocations (both exit 143). The second + process was observed at 2m27s elapsed before termination. Factory/display + traversal was changed to a worklist so shared return/parameter graphs expand + each node once, then given an early return when no local class exists. +- Focused run before that worklist refactor: 19 methods passed in 0.454s. + Exact selectors are the eight + `test_pr103_*` methods above plus: + `test_launchers_bound_through_callable_defaults_stay_classified`, + `test_launchers_nested_in_default_expressions_stay_classified`, + `test_sensitive_operands_are_rejected_before_implicit_exception_messages`, + `test_complex_conversions_are_rejected_before_implicit_exception_messages`, + `test_conversion_callables_in_defaults_and_containers_are_tracked`, + `test_sensitive_call_arguments_propagate_into_parameters`, + `test_late_inserted_sensitive_keys_lose_keys_only_exemption`, + `test_keys_with_sensitive_representations_are_rejected`, + `test_sensitive_values_are_tainted_into_method_and_lambda_parameters`, + `test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters`, + `test_sensitive_mapping_return_survives_unrelated_nested_name_collision`. + All selectors are methods of `Issue79RegressionTests`. +- After the worklist/no-class and no-local-method fast returns, those 19 + focused methods passed in 0.462s. The method fast return avoids searching + factory/alias graphs when the indexed tree contains no local method at all; + it does not remove any possible local method candidate. +- Coordinator-requested adjacent probes both reproduced RED: constructor alias + `Alias = K; print(Alias())` and an unpacked conversion dictionary, + 2 methods / 2 failures in 0.233s. The constructor resolver now follows scoped + class aliases, and the conversion resolver follows dictionary unpack values. + `test_pr103_display_constructor_aliases` and + `test_pr103_unpacked_dictionary_converters` include safe counterparts. + All 10 `test_pr103_*` methods then passed in 0.560s. +- Final focused run after the adjacent corrections: 21 methods passed in + 0.448s, using the 10 `test_pr103_*` methods plus the 11 prior selectors listed + above. The longer preceding-source invocation also completed: 20 methods + passed in 528.091s, including + `test_current_scanner_compiler_body_remains_self_audit_compatible`. That run + loaded source before the no-local-method fast return and the two adjacent + corrections; it is not final-source evidence. It was allowed to finish. + +The final-source packet smoke uses selector +`Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations` +with the same isolated/environment prefix. Its inputs were frozen at packet Git +blob `95e8f0cac90165e45827e645800acf67b8abe566` and harness Git blob +`18aff0dfeb6b89998ed3ae37850d6b22737b7661`; later ledger edits do not alter either +input. This selector inspects packet recipes as data and never runs them. +It ran once on those inputs and passed: 1 test in 537.667s, exit 0; +261 shell commands and 86 Python heredoc bodies inspected, zero violations. +The final hashes were checked again after completion. `git diff --check` and +scans of the introduced delta/ledger for personal machine paths and credential +patterns passed. No full regression suite was run or repeated by this writer. + +## Remaining gates and rollback + +All eight findings have local corrections and focused reproduction evidence; +they still require independent review and GitHub Codex review of the final +immutable candidate. Coordinator owns push, hosted PR quick check, review +requests, final finding triage and exact-head merge authorization. No full +regression suite, Go suite, hosted check or live qualification is claimed here. +These checks do not complete G01/G02 or authorize production or live work. + +Rollback is a reviewed revert of the local correction commit. Historical packet +evidence, toolchain pins, Go code and shared CI are unchanged. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index daf9b209..18aff0df 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -7164,6 +7164,109 @@ def test_reviewed_evidence_path_assignment_must_be_unique(self) -> None: with self.assertRaises(AssertionError): _top_level_assignment(duplicate, "issue79_reviewed_evidence_paths") + def test_pr103_lambda_method_error_arguments(self) -> None: + for body in ( + '(lambda key: {}[key])(str(os.environ))\n', + 'lookup = lambda key: {}[key]\nlookup(str(os.environ))\n', + 'class C:\n def lookup(self, key):\n return {}[key]\nC().lookup(str(os.environ))\n', + 'class C:\n @staticmethod\n def lookup(key):\n return {}[key]\nC.lookup(str(os.environ))\n', + ): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect('import os\n' + body)) + self.assertIsNone(self.inspect('(lambda key: {}[key])("reviewed")\n')) + + def test_pr103_mapping_alias_mutations(self) -> None: + for mutation in ( + 'a[str(os.environ)] = 1', + 'a.update({str(os.environ): 1})', + 'a.setdefault(str(os.environ), 1)', + 'a.__setitem__(str(os.environ), 1)', + 'a |= {str(os.environ): 1}', + ): + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect( + 'import os\nd = {}\na = d\nb = a\na = b\n' + mutation + '\nfor key in d:\n {}[key]\n' + )) + self.assertIsNone(self.inspect('d = {}\na = d\na["reviewed"] = 1\nfor key in d:\n {}[key]\n')) + + def test_pr103_dictionary_conversion_callables(self) -> None: + for conversion in ('int', 'float', 'complex'): + with self.subTest(conversion=conversion): + self.assertIsNotNone(self.inspect( + f'import os\nconverters = {{"x": {conversion}}}\nconverters["x"](str(os.environ))\n' + )) + self.assertIsNone(self.inspect('converters = {"x": complex}\nconverters["x"]("1")\n')) + self.assertIsNone(self.inspect('import os\nconverters = {"x": complex, "safe": len}\nconverters["safe"](str(os.environ))\n')) + + def test_pr103_factory_returned_repr_keys(self) -> None: + for result in ('K()', 'make()'): + with self.subTest(result=result): + self.assertIsNotNone(self.inspect( + 'import os\nclass K:\n def __repr__(self):\n return str(os.environ)\n' + 'def make():\n return K()\ndef again():\n return ' + result + '\n{}[again()]\n' + )) + self.assertIsNone(self.inspect( + 'class K:\n def __repr__(self):\n return "reviewed"\ndef make():\n return K()\n{}[make()]\n' + )) + + def test_pr103_comprehension_launcher_defaults(self) -> None: + for default, call in ( + ('[x for x in [os.system]]', 'box[0]("synthetic-command")'), + ('{x for x in [os.system]}', 'next(iter(box))("synthetic-command")'), + ('(x for x in [os.system])', 'next(box)("synthetic-command")'), + ('{"x": x for x in [os.system]}', 'box["x"]("synthetic-command")'), + ('[os.system for x in [0]]', 'box[0]("synthetic-command")'), + ('[x for x in [subprocess.run]]', 'box[0](["gh", "workflow", "run", "synthetic"])'), + ): + with self.subTest(default=default): + self.assertIsNotNone(self.inspect( + 'import os, subprocess\ndef f(box=' + default + '):\n ' + call + '\nf()\n' + )) + self.assertIsNone(self.inspect('def f(box=[x for x in [len]]):\n box[0]("reviewed")\nf()\n')) + + def test_pr103_sensitive_attribute_names(self) -> None: + for lookup in ('getattr', 'delattr'): + with self.subTest(lookup=lookup): + self.assertIsNotNone(self.inspect(f'import os\n{lookup}(object(), str(os.environ))\n')) + self.assertIsNotNone(self.inspect(f'import os\nlookup = {lookup}\nlookup(object(), str(os.environ))\n')) + self.assertIsNone(self.inspect('getattr(object(), "reviewed", None)\n')) + + def test_pr103_class_namespace_launchers(self) -> None: + for member, call in ( + ('from os import system as go', 'C.go("synthetic-command")'), + ('import os as tools', 'C.tools.system("synthetic-command")'), + ('from subprocess import run as go', 'C.go(["gh", "workflow", "run", "synthetic"])'), + ): + with self.subTest(member=member): + self.assertIsNotNone(self.inspect('class C:\n ' + member + '\n' + call + '\n')) + self.assertIsNone(self.inspect('class C:\n import re as tools\nprint(C.tools.escape("reviewed"))\n')) + + def test_pr103_display_methods_at_sinks(self) -> None: + for method in ('__str__', '__repr__', '__format__'): + for sink in ('print(K())', 'sys.exit(K())', 'print(make())', 'print(str(K()))', 'print(f"{K()}")'): + with self.subTest(method=method, sink=sink): + self.assertIsNotNone(self.inspect( + 'import os, sys\nclass K:\n def ' + method + '(self, *args):\n return str(os.environ)\n' + 'def make():\n return K()\n' + sink + '\n' + )) + self.assertIsNone(self.inspect('class K:\n def __str__(self):\n return "reviewed"\nprint(K())\n')) + + def test_pr103_display_constructor_aliases(self) -> None: + self.assertIsNotNone(self.inspect( + 'import os\nclass K:\n def __str__(self):\n return str(os.environ)\nAlias = K\nprint(Alias())\n' + )) + self.assertIsNone(self.inspect( + 'class K:\n def __str__(self):\n return "reviewed"\nAlias = K\nprint(Alias())\n' + )) + + def test_pr103_unpacked_dictionary_converters(self) -> None: + self.assertIsNotNone(self.inspect( + 'import os\nconverters = {**{"x": complex}}\nconverters["x"](str(os.environ))\n' + )) + self.assertIsNone(self.inspect( + 'import os\nconverters = {**{"x": len}}\nconverters["x"](str(os.environ))\n' + )) + def test_current_packet_has_no_static_scanner_violations(self) -> None: matches: list[str] = [] shell_command_count = 0 From 34968872cd1d1f832c9ee02dcf2119badc18bacc Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Thu, 22 Oct 2026 19:54:43 +0900 Subject: [PATCH 110/123] fix(evidence): preserve alias and implicit-error provenance Correct issue #79 review classes from immutable ca6af56, including the completed GitHub review additions and scoped adjacent probes. Keep specimens inert and preserve existing fail-closed policies. Validation: 52 focused methods passed in 17.712s; final packet selector passed in 542.202s, inspecting 261 shell commands and 86 Python bodies with zero violations. Additive ledger records actual RED/GREEN, failed and interrupted intermediate scans, input blob identities, aggregate collision preflight, and remaining review gates. --- docs/evidence/g01-recovery-packet.md | 468 +++++++++++++++--- .../issue79-pr103-review-6172592368.md | 259 ++++++++++ .../issue79_regression_test.py | 191 +++++++ 3 files changed, 840 insertions(+), 78 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 95e8f0ca..2e9a3f38 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10119,6 +10119,35 @@ def python_local_method_candidates(attribute, call_node, tree, parents): ] +def python_local_method_is_bound(call_node, function, tree, parents): + """Only instance receivers (or classmethod descriptors) consume self.""" + if any(python_dotted_name(item) == "classmethod" + for item in getattr(function, "decorator_list", ())): + return True + index = getattr(tree, "_issue79_local_return_index", None) + if index is None: + return True + assignments, local_method_index = index + scopes = python_lexical_scope_chain(python_enclosing_scope(call_node, parents), parents) + pending = [call_node.func] + seen = set() + while pending: + target = pending.pop() + if id(target) in seen: + continue + seen.add(id(target)) + if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name): + if python_local_class_alias_names(target.value.id, function.name, scopes, local_method_index, assignments): + return False + elif isinstance(target, ast.Name): + for scope in scopes: + values = assignments.get((id(scope), target.id), ()) + if values: + pending.extend(values) + break + return True + + def python_local_bound_method_candidates(name, call_node, tree, parents): """Resolve assigned bound methods, including static getattr aliases.""" index = getattr(tree, "_issue79_local_return_index", None) @@ -10773,7 +10802,8 @@ def python_sensitive_value_names(tree, parents): ) for decorator in getattr(function, "decorator_list", ()) ) - if bound_method and positional_parameters and not is_static_method: + if (bound_method and positional_parameters and not is_static_method + and python_local_method_is_bound(call_node, function, tree, parents)): positional_parameters = positional_parameters[1:] parameters = positional_parameters + list(function.args.kwonlyargs) bound = [] @@ -12850,6 +12880,7 @@ def python_import_bindings(tree): elif isinstance(current, ast.Lambda): stack.append(current.body) elif isinstance(current, ast.Call): + stack.extend(python_local_call_return_values(current, tree, parents)) stack.append(current.func) stack.extend(current.args) stack.extend( @@ -12864,6 +12895,8 @@ def python_import_bindings(tree): and current.id in from_import_command_leaves ): found.add(current.id) + if isinstance(current, ast.Name): + stack.extend(assigned_values.get(current.id, ())) return found # Resolve alias chains such as `launch = subprocess.run` and @@ -12931,9 +12964,23 @@ def python_resolved_name(node, modules, functions): return dotted +def python_class_namespace_owner(node, parents): + """Find class-suite bindings without crossing a method or nested scope.""" + current = parents.get(node) + while current is not None: + if isinstance(current, ast.ClassDef): + return current + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.Module)): + return None + current = parents.get(current) + return None + + def python_class_command_attribute_violation(tree, modules, functions): """Reject unresolved conditional and instance/class launcher attributes.""" attributes = {} + parents = {child: parent for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent)} def record(target, value): if not isinstance(target, ast.Attribute): @@ -12986,8 +13033,13 @@ def python_class_command_attribute_violation(tree, modules, functions): elif isinstance(node, ast.ClassDef): if node.name in python_command_modules: continue - for statement in node.body: + for statement in ast.walk(node): + if python_class_namespace_owner(statement, parents) is not node: + continue if isinstance(statement, ast.ImportFrom): + if statement.module == "os" and any( + alias.name in {"environ", "environb"} for alias in statement.names): + return "Python class namespace exports an environment mapping" for alias in statement.names: resolved = f"{statement.module}.{alias.name}" if resolved in python_command_functions: @@ -13010,6 +13062,37 @@ def python_class_command_attribute_violation(tree, modules, functions): if resolved in python_command_functions: attributes[f"{node.name}.{statement.target.id}"] = resolved + class_bindings = {} + alias_bindings = {} + for candidate in ast.walk(tree): + scope = python_enclosing_scope(candidate, parents) + if isinstance(candidate, ast.ClassDef): + class_bindings.setdefault((scope, candidate.name), []).append(candidate) + elif isinstance(candidate, (ast.Assign, ast.AnnAssign)): + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + for target in targets: + if isinstance(target, ast.Name) and candidate.value is not None: + alias_bindings.setdefault((scope, target.id), []).append(candidate.value) + + def receiver_paths(value, seen): + if id(value) in seen: + return set() + seen.add(id(value)) + if isinstance(value, ast.Attribute): + return {path + "." + value.attr for path in receiver_paths(value.value, set(seen))} + if isinstance(value, ast.Call): + return receiver_paths(value.func, set(seen)) + if isinstance(value, ast.Name): + for scope in python_lexical_scope_chain(python_enclosing_scope(value, parents), parents): + classes = class_bindings.get((scope, value.id), ()) + aliases = alias_bindings.get((scope, value.id), ()) + if classes or aliases: + paths = {item.name for item in classes} + for alias in aliases: + paths.update(receiver_paths(alias, set(seen))) + return paths + return set() + for node in ast.walk(tree): if not isinstance(node, ast.Call): continue @@ -13019,7 +13102,10 @@ def python_class_command_attribute_violation(tree, modules, functions): f"on line {node.lineno}" ) dotted = python_dotted_name(node.func) - if dotted in attributes: + paths = receiver_paths(node.func, set()) if isinstance(node.func, ast.Attribute) else set() + matched = next((path for path in paths if path in attributes), None) + if dotted in attributes or matched is not None: + dotted = matched or dotted return ( "Python process launcher stored on a class attribute is not allowed " f"({dotted!r} resolves to {attributes[dotted]!r}) on line {node.lineno}" @@ -21522,7 +21608,8 @@ def python_sensitive_read_violation(tree, parents): positional = helper_parameters(function) is_static = any(python_dotted_name(decorator) == "staticmethod" for decorator in getattr(function, "decorator_list", ())) - if bound_method and positional and not is_static: + if (bound_method and positional and not is_static + and python_local_method_is_bound(call_node, function, tree, parents)): positional = positional[1:] for index, argument in enumerate(call_node.args): if isinstance(argument, ast.Starred): @@ -21576,6 +21663,20 @@ def python_sensitive_read_violation(tree, parents): ) } error_operand_bindings = {} + def error_operand_binding_pairs(target, value): + if isinstance(target, ast.Name): + return [(target, value)] + if isinstance(target, (ast.Tuple, ast.List)): + pairs = [] + for index, item in enumerate(target.elts): + source = (value.elts[index] if isinstance(value, (ast.Tuple, ast.List)) + and index < len(value.elts) else value) + pairs.extend(error_operand_binding_pairs(item, source)) + return pairs + if isinstance(target, ast.Starred): + return error_operand_binding_pairs(target.value, value) + return [] + for candidate in ast.walk(tree): if isinstance(candidate, ast.Assign): targets, value = candidate.targets, candidate.value @@ -21609,15 +21710,15 @@ def python_sensitive_read_violation(tree, parents): (binding_scope, value, keys_only) ) continue - for name in ast.walk(target): - if isinstance(name, ast.Name) and isinstance(name.ctx, ast.Store): + for name, source in error_operand_binding_pairs(target, value): + if isinstance(name.ctx, ast.Store): error_loop_keys_only = loop_iter is not None if isinstance(loop_iter, ast.Call) and isinstance( loop_iter.func, ast.Attribute ): error_loop_keys_only = loop_iter.func.attr == "keys" error_operand_bindings.setdefault(name.id, []).append( - (binding_scope, value, error_loop_keys_only) + (binding_scope, source, error_loop_keys_only) ) error_operand_mutated_keys = {} for candidate in ast.walk(tree): @@ -21685,19 +21786,40 @@ def python_sensitive_read_violation(tree, parents): ) # Mapping identity is shared in both directions across simple scoped aliases. # Retain every possible binding conservatively; cycles converge by set union. + def mutation_binding_scope(scope, name): + for owner in python_lexical_scope_chain(scope, parents): + if any(entry_scope is owner for entry_scope, _value, _keys + in error_operand_bindings.get(name, ())): + return owner + if isinstance(owner, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + parameters = helper_parameters(owner) + list(owner.args.kwonlyargs) + if any(parameter.arg == name for parameter in parameters): + return owner + return scope + mutation_aliases = [] for name, entries in error_operand_bindings.items(): for scope, binding, keys_only in entries: if keys_only or not isinstance(binding, ast.Name): continue - for source_scope in python_lexical_scope_chain(scope, parents): - if any(entry_scope is source_scope for entry_scope, _value, _keys - in error_operand_bindings.get(binding.id, ())): - mutation_aliases.append(((scope, name), (source_scope, binding.id))) - break + source_scope = mutation_binding_scope(scope, binding.id) + mutation_aliases.append(((scope, name), (source_scope, binding.id))) + # Call-bound parameters share the caller's mapping identity; their source + # Name retains its original lexical parent, including captured receivers. + for name, entries in assignments_by_name.items(): + for scope, source in entries: + if not isinstance(source, ast.Name) or not isinstance( + scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + if name not in {parameter.arg for parameter in + helper_parameters(scope) + list(scope.args.kwonlyargs)}: + continue + source_scope = mutation_binding_scope(python_enclosing_scope(source, parents), source.id) + mutation_aliases.append(((scope, name), (source_scope, source.id))) scoped_mutations = {} for name, entries in error_operand_mutated_keys.items(): for scope, key, is_mapping in entries: + scope = mutation_binding_scope(scope, name) scoped_mutations.setdefault((scope, name), set()).add((key, is_mapping)) changed = True while changed: @@ -21822,6 +21944,102 @@ def python_sensitive_read_violation(tree, parents): "builtins.complex", } + stored_mapping_values = {} + for candidate in ast.walk(tree): + scope = python_enclosing_scope(candidate, parents) + stores = [] + if isinstance(candidate, (ast.Assign, ast.AnnAssign)) and candidate.value is not None: + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + stores.extend((target.value, target.slice, candidate.value) for target in targets + if isinstance(target, ast.Subscript)) + elif (isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Attribute) + and candidate.func.attr in {"update", "setdefault", "__setitem__"}): + receiver = candidate.func.value + if candidate.func.attr == "update": + for argument in candidate.args: + if isinstance(argument, ast.Dict): + stores.extend((receiver, key, value) for key, value + in zip(argument.keys, argument.values)) + else: + stores.append((receiver, None, argument)) + stores.extend((receiver, ast.Constant(value=keyword.arg) if keyword.arg is not None else None, + keyword.value) for keyword in candidate.keywords) + elif len(candidate.args) >= 2: + stores.append((receiver, candidate.args[0], candidate.args[1])) + elif isinstance(candidate, ast.AugAssign) and isinstance(candidate.op, ast.BitOr): + if isinstance(candidate.value, ast.Dict): + stores.extend((candidate.target, key, value) for key, value + in zip(candidate.value.keys, candidate.value.values)) + for receiver, key, value in stores: + if isinstance(receiver, ast.Name): + owner = mutation_binding_scope(scope, receiver.id) + stored_mapping_values.setdefault((owner, receiver.id), set()).add((key, value)) + changed = True + while changed: + changed = False + for left, right in mutation_aliases: + merged = stored_mapping_values.get(left, set()) | stored_mapping_values.get(right, set()) + for endpoint in (left, right): + if stored_mapping_values.get(endpoint, set()) != merged: + stored_mapping_values[endpoint] = set(merged) + changed = True + + def error_operand_subscript_values(expression, seen_expressions=None): + """Follow scoped container aliases before selecting literal entries.""" + if seen_expressions is None: + seen_expressions = set() + if id(expression) in seen_expressions: + return [] + seen_expressions.add(id(expression)) + pending = [expression.value] + seen = set() + values = [] + while pending: + operand_container = pending.pop() + if operand_container is None or id(operand_container) in seen: + continue + seen.add(id(operand_container)) + if isinstance(operand_container, ast.Name): + for scope in python_lexical_scope_chain( + python_enclosing_scope(operand_container, parents), parents): + entries = [binding for binding_scope, binding, _keys + in error_operand_bindings.get(operand_container.id, ()) if binding_scope is scope] + is_parameter = operand_container.id in error_operand_parameters.get(scope, ()) + stores = stored_mapping_values.get((scope, operand_container.id), ()) + for key, value in stores: + if key is None: + pending.append(value) + elif (not isinstance(expression.slice, ast.Constant) + or not isinstance(key, ast.Constant) or key.value == expression.slice.value): + values.append(value) + if is_parameter: + entries.extend(source for source_scope, source + in assignments_by_name.get(operand_container.id, ()) if source_scope is scope) + if entries or is_parameter or stores: + pending.extend(entries) + break + elif isinstance(operand_container, ast.Dict): + for key, item in zip(operand_container.keys, operand_container.values): + if key is None: + pending.append(item) + elif (not isinstance(expression.slice, ast.Constant) + or not isinstance(key, ast.Constant) + or key.value == expression.slice.value): + values.append(item) + elif isinstance(operand_container, (ast.Tuple, ast.List)): + index = expression.slice + if (isinstance(index, ast.Constant) and isinstance(index.value, int) + and not isinstance(index.value, bool) + and -len(operand_container.elts) <= index.value < len(operand_container.elts)): + values.append(operand_container.elts[index.value]) + else: + values.extend(operand_container.elts) + elif isinstance(operand_container, ast.IfExp): + pending.extend((operand_container.body, operand_container.orelse)) + elif isinstance(operand_container, ast.Subscript): + pending.extend(error_operand_subscript_values(operand_container, seen_expressions)) + return values + def error_operand_conversion_callable(func, seen): if func is None or id(func) in seen: return False @@ -21858,65 +22076,8 @@ def python_sensitive_read_violation(tree, parents): return True return False if isinstance(func, ast.Subscript): - option_containers = [] - target = func.value - if isinstance(target, (ast.List, ast.Tuple, ast.Dict)): - option_containers.append(target) - elif isinstance(target, ast.Name): - chain = python_lexical_scope_chain( - python_enclosing_scope(target, parents), parents - ) - for scope in chain: - option_containers.extend( - binding - for binding_scope, binding, _keys in error_operand_bindings.get( - target.id, () - ) - if binding_scope is scope - ) - option_containers.extend( - source - for source_scope, source in assignments_by_name.get( - target.id, () - ) - if source_scope is scope - ) - for option_container in option_containers: - if isinstance(option_container, ast.Dict): - elements = list(option_container.values) - if isinstance(func.slice, ast.Constant) and all( - isinstance(key, ast.Constant) for key in option_container.keys - ): - elements = [value for key, value in zip(option_container.keys, option_container.values) - if key.value == func.slice.value] - if any(error_operand_conversion_callable(item, set(seen)) for item in elements): - return True - continue - if not isinstance(option_container, (ast.List, ast.Tuple)): - continue - elements = list(option_container.elts) - index = func.slice - if ( - isinstance(index, ast.Constant) - and isinstance(index.value, int) - and not isinstance(index.value, bool) - ): - pick = ( - index.value - if index.value >= 0 - else len(elements) + index.value - ) - if 0 <= pick < len(elements): - if error_operand_conversion_callable( - elements[pick], set(seen) - ): - return True - continue - if any( - error_operand_conversion_callable(item, set(seen)) - for item in elements - ): - return True + return any(error_operand_conversion_callable(item, set(seen)) + for item in error_operand_subscript_values(func)) return False def error_operand_sensitive(value, keys_mode=False): @@ -22135,6 +22296,9 @@ def python_sensitive_read_violation(tree, parents): if id(classdef) in seen: return False seen.add(id(classdef)) + for method, implementation in assigned_display_methods.get(classdef, ()): + if method in methods and display_implementation_sensitive(implementation, set()): + return True for entry in classdef.body: if ( isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) @@ -22154,6 +22318,64 @@ def python_sensitive_read_violation(tree, parents): return True return False + def display_implementation_sensitive(value, seen): + if value is None or id(value) in seen: + return False + seen.add(id(value)) + functions = [] + if isinstance(value, ast.Lambda): + return error_operand_sensitive(value.body) + if isinstance(value, ast.Name): + functions = (python_local_function_candidates(value.id, value, tree, parents) + + python_local_lambda_candidates(value.id, value, tree, parents)) + for scope in python_lexical_scope_chain(python_enclosing_scope(value, parents), parents): + entries = [binding for binding_scope, binding, _keys + in error_operand_bindings.get(value.id, ()) if binding_scope is scope] + if entries: + if any(display_implementation_sensitive(entry, seen) for entry in entries): + return True + break + for function in functions: + returns = ([function.body] if isinstance(function, ast.Lambda) else + [node.value for node in ast.walk(function) if isinstance(node, ast.Return)]) + if any(error_operand_sensitive(result) for result in returns): + return True + return False + + def display_class_targets(value, seen): + if id(value) in seen: + return [] + seen.add(id(value)) + if not isinstance(value, ast.Name): + return [] + for scope in python_lexical_scope_chain(python_enclosing_scope(value, parents), parents): + classes = [classdef for class_scope, classdef + in error_operand_classes.get(value.id, ()) if class_scope is scope] + entries = [binding for binding_scope, binding, _keys + in error_operand_bindings.get(value.id, ()) if binding_scope is scope] + if classes or entries: + return classes + [classdef for entry in entries + for classdef in display_class_targets(entry, seen)] + return [] + + assigned_display_methods = {} + if error_operand_classes: + for candidate in ast.walk(tree): + if not isinstance(candidate, (ast.Assign, ast.AnnAssign)) or candidate.value is None: + continue + targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] + for target in targets: + class_scope = python_class_namespace_owner(candidate, parents) + if isinstance(target, ast.Name) and class_scope is not None: + classes, method = [class_scope], target.id + elif isinstance(target, ast.Attribute): + classes, method = display_class_targets(target.value, set()), target.attr + else: + continue + if method in {"__str__", "__repr__", "__format__"}: + for classdef in classes: + assigned_display_methods.setdefault(classdef, []).append((method, candidate.value)) + def error_operand_repr_sensitive(value, seen, methods=("__repr__",)): # Factory/parameter graphs can contain cycles and many shared paths. # Expand each expression once, rather than cloning a recursive path set. @@ -22168,8 +22390,12 @@ def python_sensitive_read_violation(tree, parents): seen.add(id(value)) if isinstance(value, ast.Call): stack.extend(python_local_call_return_values(value, tree, parents)) - if python_dotted_name(value.func) in {"str", "repr", "ascii", "format"}: + if (python_dotted_name(value.func) in {"str", "repr", "ascii", "format"} + or isinstance(value.func, ast.Name) and python_assigned_format_alias(value.func.id, tree)): + stack.extend(value.args) + elif isinstance(value.func, ast.Attribute) and value.func.attr in {"format", "format_map"}: stack.extend(value.args) + stack.extend(keyword.value for keyword in value.keywords) constructors = [value.func] seen_constructors = set() while constructors: @@ -22179,6 +22405,8 @@ def python_sensitive_read_violation(tree, parents): seen_constructors.add(id(constructor)) if isinstance(constructor, ast.IfExp): constructors.extend((constructor.body, constructor.orelse)) + elif isinstance(constructor, ast.Subscript): + constructors.extend(error_operand_subscript_values(constructor)) if not isinstance(constructor, ast.Name): continue chain = python_lexical_scope_chain( @@ -22216,6 +22444,8 @@ def python_sensitive_read_violation(tree, parents): for source_scope, source in assignments_by_name.get(value.id, ()) if source_scope is scope) break + elif isinstance(value, ast.Subscript): + stack.extend(error_operand_subscript_values(value)) elif isinstance(value, (ast.List, ast.Tuple, ast.Set)): stack.extend(value.elts) elif isinstance(value, ast.Dict): @@ -22226,6 +22456,8 @@ def python_sensitive_read_violation(tree, parents): stack.extend(value.values) elif isinstance(value, ast.FormattedValue): stack.append(value.value) + elif isinstance(value, ast.BinOp) and isinstance(value.op, ast.Mod): + stack.append(value.right) return False def exception_arguments(value, seen_names=None, follow_names=True): @@ -22342,11 +22574,37 @@ def python_sensitive_read_violation(tree, parents): ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) + if (isinstance(node.func, ast.Attribute) and node.func.attr == "relative_to" + and python_resolved_local_path_expression( + node.func.value, tree, parents, assignments_by_name)): + current = node + handled = False + while current in parents: + parent = parents[current] + if isinstance(parent, ast.Try) and current in parent.body: + for handler in parent.handlers: + if python_dotted_name(handler.type) != "ValueError": + continue + if any((isinstance(part, ast.Raise) and not ( + isinstance(part.cause, ast.Constant) and part.cause.value is None + or part.cause is None and isinstance(part.exc, ast.Call) + and python_unshadowed_builtin_call(part.exc, {"SystemExit"}, tree, parents))) + or (handler.name is not None and isinstance(part, ast.Name) + and part.id == handler.name and isinstance(part.ctx, ast.Load)) + for statement in handler.body for part in ast.walk(statement)): + continue + handled = True + if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + break + current = parent + if not handled: + return ("Python local path may surface through an implicit pathlib exception " + f"on line {node.lineno}") if len(node.args) >= 2 and ( - dotted in {"getattr", "delattr", "builtins.getattr", "builtins.delattr"} + dotted in {"getattr", "delattr", "setattr", "builtins.getattr", "builtins.delattr", "builtins.setattr"} or isinstance(node.func, ast.Name) and any( python_assigned_callable_alias(node.func.id, lookup, tree) - for lookup in ("getattr", "delattr")) + for lookup in ("getattr", "delattr", "setattr")) ) and (error_operand_sensitive(node.args[1]) or error_operand_repr_sensitive(node.args[1], set())): return ("Python sensitive attribute name may surface in an exception " f"message on line {node.lineno}") @@ -22682,8 +22940,17 @@ def python_callback_command_violation(tree, modules, functions): def python_open_aliases(tree): """Resolve aliases of builtin/io open/open_code for path classification.""" + # Alias/return/container edges cannot create a reader without a source. + if not any((isinstance(node, ast.Name) and node.id == "open") + or (isinstance(node, ast.Attribute) and node.attr in {"open", "open_code"}) + or (isinstance(node, ast.ImportFrom) and node.module in {"builtins", "io"} + and any(item.name in {"open", "open_code"} for item in node.names)) + for node in ast.walk(tree)): + return set() aliases = set() assignments = [] + parents = {child: parent for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent)} for node in ast.walk(tree): if isinstance(node, ast.Assign): assignments.extend((target, node.value) for target in node.targets) @@ -22695,15 +22962,60 @@ def python_open_aliases(tree): for imported in node.names: if imported.name in {"open", "open_code"}: aliases.add(imported.asname or imported.name) + def open_reference(value, seen): + if value is None or id(value) in seen: + return False + seen.add(id(value)) + dotted = python_dotted_name(value) + if dotted in {"open", "builtins.open", "io.open", "io.open_code"}: + return True + if isinstance(value, ast.Name): + if value.id in aliases: + return True + return any(open_reference(source, seen) for target, source in assignments + if isinstance(target, ast.Name) and target.id == value.id) + if isinstance(value, ast.Call): + return any(open_reference(source, seen) + for source in python_local_call_return_values(value, tree, parents)) + if isinstance(value, ast.Subscript): + return any(open_reference(source, seen) + for source in selected_open_values(value.value, value.slice, set())) + if isinstance(value, ast.IfExp): + return any(open_reference(source, seen) for source in (value.body, value.orelse)) + if isinstance(value, ast.BoolOp): + return any(open_reference(source, seen) for source in value.values) + return False + + def selected_open_values(container, key, seen): + if id(container) in seen: + return [] + seen.add(id(container)) + if isinstance(container, ast.Name): + return [value for target, source in assignments + if isinstance(target, ast.Name) and target.id == container.id + for value in selected_open_values(source, key, seen)] + if isinstance(container, (ast.Tuple, ast.List)): + if (isinstance(key, ast.Constant) and isinstance(key.value, int) + and not isinstance(key.value, bool) and -len(container.elts) <= key.value < len(container.elts)): + return [container.elts[key.value]] + return list(container.elts) + if isinstance(container, ast.Dict): + values = [] + for item, value in zip(container.keys, container.values): + if item is None: + values.extend(selected_open_values(value, key, seen)) + elif (not isinstance(item, ast.Constant) or not isinstance(key, ast.Constant) + or item.value == key.value): + values.append(value) + return values + return [] + for _ in range(len(assignments) + 1): changed = False for target, value in assignments: if not isinstance(target, ast.Name): continue - dotted = python_dotted_name(value) - is_open = dotted in {"builtins.open", "io.open", "io.open_code"} or ( - isinstance(value, ast.Name) and value.id in {"open", *aliases} - ) + is_open = open_reference(value, set()) if is_open and target.id not in aliases: aliases.add(target.id) changed = True diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index f02f4833..aca0c727 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -149,3 +149,262 @@ These checks do not complete G01/G02 or authorize production or live work. Rollback is a reviewed revert of the local correction commit. Historical packet evidence, toolchain pins, Go code and shared CI are unchanged. + +## Additive correction from `ca6af5676548ffe716ca3a2d35fea07f7f71c64d` + +The seven blocking P1 classes in immutable +[review comment 6338125927](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6338125927) +were reproduced and corrected by the sole writer using the current explicit +Codex `gpt-6.1-sol`, medium override. The preceding eight-plus-two corrections, +21-method focused pass and 537.667s packet scan above remain historical evidence +for their recorded inputs; they did not establish closure of these seven gaps. +The starting head had an independent HOLD despite a passed hosted quick check. +This additive section records writer evidence, not review or merge approval. + +| Class | Reproduction and correction | Controls retained | +|---|---|---| +| A: unbound method arguments | `test_pr103_delta_unbound_method_arguments` reproduces both an explicit `None` receiver followed by a sensitive key and a sensitive explicit `self`. Class receivers retain the first argument; instance receivers and classmethod descriptors consume their implicit receiver, while static methods retain all explicit arguments. Assigned method aliases use the same receiver check. | Literal-key bound/unbound calls and static methods; existing sensitive method/lambda controls. | +| B: mapping identity across helper/closure scopes | `test_pr103_delta_mapping_identity` reproduces five helper-parameter mutation forms and a direct outer-mapping closure mutation. Scoped identity edges now include call-bound parameters and resolve direct mutation receivers to their lexical binding. A second RED exposed a helper-local alias of its parameter; that alias now joins the same identity graph. | A mutation of another mapping, unrelated same-name function scopes, and the existing closure-local alias. | +| C: named comprehension defaults | `test_pr103_delta_named_comprehension_defaults` follows a comprehension through `box` and another alias into a default, covering list/set/generator/dict forms. The existing launcher-default traversal now follows assigned Name sources with its visited guard. | Named `len` comprehension/default; set/generator unsafe forms already refused at baseline. | +| D: subscript constructor aliases | `test_pr103_delta_display_subscripts` reproduces tuple-extracted class aliases, including a named tuple. Constructor traversal now follows selected container entries. | Identical constructors returning a literal display string. | +| E: container-held display objects | The same method reproduces list and dictionary/subscript object sinks, including a container alias. Display traversal follows the selected entries before inspecting display methods. | Identical object/container shapes with literal display strings. | +| F: conversion dictionary alias chains | `test_pr103_delta_conversion_dictionary_aliases` reproduces `int`, `float`, and `complex` behind two aliases, both directly and through dictionary unpacking. A scoped container selector follows aliases before selecting keys; unresolved keys retain all alternatives. | Numeric literal input and known nonconversion keys; existing unpack/default/container controls. | +| G: class-imported launcher receivers | `test_pr103_delta_class_import_receivers` reproduces imported OS/subprocess launchers through class aliases, direct instances and named instances. Class-attribute classification resolves these receiver aliases before matching launcher provenance. | Equivalent `re.escape` class/instance calls and existing direct class-import controls. | + +The container selector is shared by the existing conversion and display checks; +it adds no callable, module, path, variable-name or recipe allowlist exception. +The correction preserves conservative alternative bindings. Its subscript +recursion shares a visited-expression guard: self/mutual container-subscript +cycles terminate without discarding a reachable conversion alternative. +`test_pr103_delta_subscript_cycles_retain_alternatives` covers both sensitive +alternatives and a safe `len` cycle. + +All commands in this section use the same PATH/locale-only prefix: + +`env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py` + +- Baseline RED, prefix plus `-k test_pr103_delta`: six methods, 26 failed unsafe + assertions in 0.460s, exit 1. Safe counterparts remained accepted; the set and + generator launcher cases were already refused. D/E share one test method. +- First GREEN, same command: six methods passed in 0.419s. Prefix plus + `-k test_pr103` then passed all 16 methods in 0.594s. +- After the shared cycle guard was added during source inspection, a first + adjacent-control run passed 27 methods in 0.905s. A subsequently added + helper-local parameter alias reproduced RED with prefix plus + `-k test_pr103_delta_mapping_identity`: one failure in 0.252s. Its scoped + alias correction passed the same method in 0.431s. +- Coordinator independently identified the subscript-cycle risk before freeze. + To record meaningful RED for the new cycle regression, only the recursive + call's shared guard argument was temporarily removed, restoring fresh-set + recursion. Prefix plus + `-k test_pr103_delta_subscript_cycles_retain_alternatives` produced three + `RecursionError` results in one method, 0.403s, exit 1. The shared guard was + restored before final verification; this is incremental-source RED, not a + claim that the original baseline contained this newly introduced resolver. +- Pre-freeze focused GREEN: 28 methods passed in 1.079s, exit 0. Exact suffix: + +```text +-k test_pr103 +-k test_launchers_bound_through_callable_defaults_stay_classified +-k test_launchers_nested_in_default_expressions_stay_classified +-k test_sensitive_operands_are_rejected_before_implicit_exception_messages +-k test_complex_conversions_are_rejected_before_implicit_exception_messages +-k test_conversion_callables_in_defaults_and_containers_are_tracked +-k test_sensitive_call_arguments_propagate_into_parameters +-k test_late_inserted_sensitive_keys_lose_keys_only_exemption +-k test_keys_with_sensitive_representations_are_rejected +-k test_sensitive_values_are_tainted_into_method_and_lambda_parameters +-k test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters +-k test_sensitive_mapping_return_survives_unrelated_nested_name_collision +``` + +Before source freeze, the coordinator confirmed that exact-head GitHub review +was still pending and instructed the writer to finish the cycle/control checks +and run one final packet selector. Later GitHub findings require their own +source-attributed triage; they are not implicitly resolved by this batch. +First frozen test inputs were packet Git blob +`9e5daa9dc1f147dd93d77b4c3a2634405d05c653` and harness Git blob +`798c72b14109c45d8ec12bf1c332e154ae271746`. +The final packet selector is prefix plus +`Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations`. +That invocation completed with one failure in 47.134s, exit 1: the scanner's own +heredoc obtained an unreviewed runtime type. The new container selector used +`type(index.value) is int`; it now uses the existing `isinstance(..., int)` plus +explicit boolean exclusion instead. Runtime-type policy was not widened. +The same 28-method focused command then passed in 0.596s, exit 0, on corrected +packet blob `a3bc6e2f59dd0c43157f0936810435a584099517`; the harness blob is +unchanged. The actual packet failure justifies one rerun of the identical packet +selector on these corrected inputs. Its result is recorded below after +completion; ledger-only edits do not alter either test input. + +Every new specimen remained inert AST data. No recipe, specimen, private +credential, live operation, GitHub write, push or merge was executed. No full +regression or Go suite was requested or run. Independent delta/security review, +GitHub Codex review, the hosted quick check and merge remain coordinator-owned; +these writer results do not complete G01/G02 or authorize live work. Rollback is +a reviewed revert of this additive correction commit. + +## Completed GitHub review additions on the same starting head + +Before the correction was committed, GitHub Codex completed its review of +`ca6af5676548ffe716ca3a2d35fea07f7f71c64d` with eleven P1 findings in +[comment 6338293554](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6338293554). +The coordinator classified one duplicate of class A and ten distinct additions; +all eleven remain blocking until the corrected candidate receives its required +review. The writer read the complete comment, including via +`gh api repos/1XP-AI/gh-runnerd/issues/comments/6338293554 --jq .body`. + +The previous packet rerun on blob `a3bc6e2f59dd0c43157f0936810435a584099517` +was still running when these findings arrived. The coordinator authorized +stopping only the exact owned test because its inputs now needed to change. +The writer verified the framework interpreter, `-I -B`, harness, exact packet +selector, and owned parent/session; that process was observed at 4m56s, then +terminated by its single PID. It settled with exit 143 and is **inconclusive**, +not a pass and not evidence for the combined correction. No broad process kill +or live operation occurred. + +| GitHub finding, in comment order | Disposition and regression evidence | +|---|---| +| 1: `setattr` sensitive names | Corrected the existing attribute-name guard to include direct/aliased `setattr` and qualified builtin forms. `test_pr103_gh_setattr_names` retains a literal-name positive. | +| 2: nested class launcher imports | Corrected class-suite traversal to include control-flow suites while stopping at nested method/class scope ownership. `test_pr103_gh_nested_class_launchers` covers `if`, loop, `try` and `with` imports; allowed `re.escape` remains accepted. | +| 3: destructuring mapping identity | Deterministic tuple/list target/source pairs now feed the existing scoped binding/alias graph. `test_pr103_gh_destructured_mapping_aliases` covers tuple, list and nested destructuring and retains literal keys. | +| 4: late-inserted conversion callables | The scoped mapping identity graph now also carries stored key/value entries from subscript stores, `update`, `setdefault`, `__setitem__` and union assignment. Subscript resolution selects these entries along with initial values. `test_pr103_gh_inserted_conversion_callables` covers all three numeric converters, aliases, numeric inputs and a known safe key. | +| 5: assigned display implementations | The existing display check resolves local function/lambda implementations assigned in a class suite or installed later on a class, including simple class aliases. `test_pr103_gh_assigned_display_methods` covers both installation forms with literal-return positives. | +| 6: bound/percent formatting | Display traversal expands bound `format`/`format_map` arguments, known formatter aliases and `%` operands. `test_pr103_gh_bound_and_percent_display` covers all three display protocols with safe display counterparts. | +| 7: unbound method binding | Duplicate of A. `test_pr103_delta_unbound_method_arguments` additionally includes the exact `C.lookup(C(), sensitive)` shape; this is resolved by the same class/instance binding correction, not a separate fix. | +| 8: implicit pathlib errors | Resolved local paths used in `relative_to` are refused unless a visible `ValueError` handler suppresses the exception without reading its exception binding. Raises must explicitly suppress context (`from None`) or call unshadowed builtin `SystemExit` without a cause; ordinary replacement exceptions and bare re-raises remain refused. `test_pr103_gh_implicit_path_errors` covers direct/aliased paths, a relative literal, suppression, re-raise and error output. Existing containment/reader controls remain selected. This correction does not claim every possible pathlib failure is modeled. | +| 9: helper-return launcher defaults | Existing launcher-default traversal follows local return values with its visited guard. `test_pr103_gh_helper_launcher_defaults` covers an OS launcher and a safe `len` factory. | +| 10: wrapped open readers | Open provenance follows selected literal containers, boolean/conditional values, aliases and local helper returns before existing path/mode checks apply. `test_pr103_gh_wrapped_open_readers` covers tuple/dictionary selection, a boolean expression and a factory, with safe `len` and known nonreader entries. A no-reader-source fast return avoids traversing return graphs when no open provenance can exist. | +| 11: class namespace environment imports | Class-suite exports of `os.environ`/`environb` are refused, including nested suites, rather than allowing unqualified seeds to lose their owning class. `test_pr103_gh_class_sensitive_imports` covers direct, class-alias and instance reads; `re.escape` remains accepted. | + +Actual incremental results, using the identical isolated PATH/locale prefix +above (CPython 3.14.3): + +- Prefix plus `-k test_pr103_gh` first reproduced 34 failures in ten methods, + 0.298s, exit 1, on the seven-class working correction. Several methods stopped + at their first failed unsafe assertion; not all later subcases ran in RED. + Three union-assignment converter cases were already rejected. +- First implementation run: ten methods, nine remaining failures, 0.378s. + The existing general scope helper deliberately excludes class scopes; + class-suite ownership was therefore handled explicitly instead of changing + that helper's semantics. The same ten methods then passed in 0.437s. +- Expanded controls (explicit unbound receiver, `with` class import, caught and + rethrown path errors, and mixed known-key reader containers) plus adjacent + existing selectors passed 42 methods in 0.759s. +- Coordinator-requested adjacent probes reproduced two `update` source gaps + (keyword entries and an aliased mapping) and a bound formatter alias gap: + prefix plus `-k test_pr103_gh_adjacent` failed three assertions in two methods, + 0.217s. `update` now contributes keyword entries and mapping sources to the + existing selection traversal; display traversal reuses the existing formatter + alias resolver. The same two methods passed in 0.217s, including numeric and + literal-display positives. These are extensions of findings 4/6, not new + unrelated scope. + +The combined final focused and packet results follow below. They supersede no +historical input identity. Every added witness was inspected as inert AST data; +no witness, helper from a witness, packet recipe or private credential was run. + +A final coordinator probe within finding 8 showed that a replacement +`RuntimeError("safe")` still implicitly chains the original path-bearing +exception. Prefix plus `-k test_pr103_gh_implicit_path_errors` reproduced one +failure in 0.302s; the handler rule above then passed the same method in 0.233s, +including suppression, safe `SystemExit`, explicit `from None`, and error-output +controls. This is incremental-source RED against the new handler proof. + +The combined focused command uses the 28-method suffix above (whose +`-k test_pr103` now also selects the GitHub and adjacent methods), plus: + +```text +-k test_path_filesystem_readers_require_reviewed_paths +-k test_resolved_local_paths_are_not_disclosed_to_output_sinks +-k test_resolved_paths_cross_expanded_keyword_helpers_and_formatting +-k test_container_and_conditional_class_aliases_preserve_return_taint +-k test_sensitive_taint_reaches_string_format_arguments +-k test_format_callable_aliases_preserve_sensitive_taint +-k test_user_defined_format_alias_returning_constant_is_safe +-k test_markdown_link_containment_guard_must_be_direct_and_reachable +-k test_package_containment_try_requires_direct_reachable_body +-k test_git_show_output_and_reader_option_paths_are_reviewed +``` + +Before the handler-context correction this command passed 50 methods in +16.145s. After that correction it passed the same 50 methods in 13.991s, exit 0. +The first combined packet scan froze packet blob +`3d51248b73b936ee3d98ffaa3d552693ff5e6e35` and harness blob +`6a0ee8afa9541f072014924004f307a733fb9a69`, using the identical packet selector +and isolated prefix above. Source was frozen after all coordinator findings +and adjacent probes had been consumed; ledger-only edits leave these inputs +unchanged. That scan completed with one failure in 347.108s, exit 1: the scanner +heredoc's `container.group` in `fence_details` lost its regex provenance. The +new operand selector used a local assignment named `container`; the existing +regex provenance collector considers assignments across functions, so that +unrelated assignment invalidated the existing regex proof. The correction +renames only the new internal local to `operand_container`. It does not relax +the regex policy or add a scanner exemption. + +`test_pr103_current_scanner_regex_binding_control` parses the current scanner +source as data and checks the existing non-Path reader proof for its actual +`fence_details` group calls. Prefix plus that `-k` selector reproduced one +failure in 0.680s before the rename, then passed in 1.090s after it. The actual +packet failure and changed inputs justify the next final packet invocation; +no unchanged-source smoke or full suite is repeated. + +After that internal-local rename, the identical combined focused command passed +51 methods in 14.112s, exit 0 (the additional method is the actual-scanner regex +control selected by `-k test_pr103`). Final input blobs are packet +`af6b910170829bba9162efea7c87751a23129181` and harness +`b7c3fa2119d0d45593acc1d68902ffa37b58b63e`. The packet selector and environment +prefix are unchanged; these inputs were frozen before its final invocation. + +That invocation completed with one failure in 560.246s, exit 1: a scanner-local +destructured binding `assignments, methods = index` introduced by the receiver +check collided with the existing callable helper `methods(...)`. The existing +conservative destructuring policy classified that name as command-capable. +`test_pr103_current_scanner_method_helper_is_not_launcher_alias` parses the +actual scanner source and inspects its command-binding map: prefix plus its +`-k` selector reproduced one failure in 3.564s. Renaming only the new binding to +`local_method_index` passed the same method in 3.637s; its assertion was also +made concise to avoid dumping the entire binding map on failure. Command +classification policy is unchanged. This actual failed run and input correction +justify the next packet invocation; neither a timeout nor speculative runtime +concern triggered the repeat. + +After the second internal binding rename, the identical combined focused command +passed 52 methods in 17.712s, exit 0, including both actual-scanner compatibility +regressions selected by `-k test_pr103`. Final frozen inputs are packet blob +`2e9a3f3854e8f7042bd193e28e982acbdbffc693` and harness blob +`cdf10042026bec7b17036b68a47e243b092c2240`. The final packet invocation uses the +same isolated prefix and selector, with only later ledger edits permitted. + +The coordinator requested a bounded aggregate collision preflight after the +packet scan had already started. A temporary inspected harness method compared +`python_import_bindings` on the trusted baseline/current scanner ASTs, flagged +newly classified names intersecting baseline helper definitions and current +direct calls, and compared all previously proven regex-group receiver keys. +The baseline was read only with isolated local Git `show` of immutable +`ca6af5676548ffe716ca3a2d35fea07f7f71c64d`; no remote or recipe was run. +Prefix plus `-k test_pr103_temporary_aggregate_preflight` passed one method in +13.959s: 318 called helper names compared, seven baseline regex proofs retained, +zero new collisions or lost proofs. Its temporary harness blob was +`984f7c9746ae2c2f6a09a3448c1a40cc34e6507b`, with the packet unchanged. +The temporary method was then removed using `apply_patch`; the harness returned +byte-for-byte to `cdf10042026bec7b17036b68a47e243b092c2240` and the packet +remained `2e9a3f3854e8f7042bd193e28e982acbdbffc693`. No defect was found, so the +running packet scan was retained. Its result applies to the original frozen +inputs, not the temporary preflight harness. The two actual-scanner compatibility +regressions remain in the final harness; the aggregate probe is separate writer +evidence rather than a newly supported repository-Git test path. + +Final packet result on the restored frozen inputs: **PASS**, one test in +542.202s, exit 0; 261 shell commands and 86 Python heredoc bodies inspected, +zero violations. Both input blob hashes were rechecked after completion. +`git diff --check` and introduced-delta scans for personal machine paths and +credential patterns passed; only the three writer-owned files changed. +No full regression suite, compiler sweep, Go/hosted check or live qualification +is claimed. The 52-method focused pass and this source-matched packet scan are +writer evidence for the reported families and controls, not comprehensive +Python safety proof or review approval. The three completed intermediate packet +failures and one authorized interrupted scan above remain part of the record. +Coordinator owns independent delta/security review, fresh exact-head GitHub +Codex review, hosted quick check, finding triage, push and merge; the correction +has not passed those gates merely because its local tests are green. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 18aff0df..cdf10042 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -7267,6 +7267,197 @@ def test_pr103_unpacked_dictionary_converters(self) -> None: 'import os\nconverters = {**{"x": len}}\nconverters["x"](str(os.environ))\n' )) + def test_pr103_delta_unbound_method_arguments(self) -> None: + for parameters, call in (("self, key", 'C.lookup(None, str(os.environ))'), + ("self, key", 'C.lookup(C(), str(os.environ))'), + ("self", 'C.lookup(str(os.environ))')): + key = "key" if "," in parameters else "self" + with self.subTest(call=call): + self.assertIsNotNone(self.inspect('import os\nclass C:\n def lookup(' + parameters + '):\n return {}[' + key + ']\n' + call + '\n')) + for call in ('C.lookup(None, "safe")', 'C().lookup("safe")'): + self.assertIsNone(self.inspect('class C:\n def lookup(self, key):\n return {}[key]\n' + call + '\n')) + self.assertIsNone(self.inspect('class C:\n @staticmethod\n def lookup(key):\n return {}[key]\nC.lookup("safe")\n')) + + def test_pr103_delta_mapping_identity(self) -> None: + for mutation in ('a[str(os.environ)] = 1', 'a.update({str(os.environ): 1})', + 'a.setdefault(str(os.environ), 1)', 'a.__setitem__(str(os.environ), 1)', + 'a |= {str(os.environ): 1}'): + with self.subTest(mutation=mutation): + self.assertIsNotNone(self.inspect('import os\nd = {}\ndef mutate(a):\n ' + mutation + '\nmutate(d)\nfor key in d:\n {}[key]\n')) + for receiver in ('d', 'alias'): + with self.subTest(receiver=receiver): + alias = ' alias = d\n' if receiver == 'alias' else '' + self.assertIsNotNone(self.inspect('import os\ndef outer():\n d = {}\n def mutate():\n' + alias + ' ' + receiver + '[str(os.environ)] = 1\n mutate()\n for key in d:\n {}[key]\nouter()\n')) + self.assertIsNotNone(self.inspect('import os\nd = {}\ndef mutate(a):\n alias = a\n alias[str(os.environ)] = 1\nmutate(d)\nfor key in d:\n {}[key]\n')) + self.assertIsNone(self.inspect('import os\ndef mutate(a):\n a[str(os.environ)] = 1\nd = {}\nother = {}\nmutate(other)\nfor key in d:\n {}[key]\n')) + self.assertIsNone(self.inspect('import os\ndef other():\n d = {}\n d[str(os.environ)] = 1\ndef safe():\n d = {}\n for key in d:\n {}[key]\nsafe()\n')) + + def test_pr103_delta_named_comprehension_defaults(self) -> None: + for expression, call in (('[x for x in [os.system]]', 'arg[0]("synthetic-command")'), + ('{x for x in [os.system]}', 'next(iter(arg))("synthetic-command")'), + ('(x for x in [os.system])', 'next(arg)("synthetic-command")'), + ('{"x": x for x in [os.system]}', 'arg["x"]("synthetic-command")')): + with self.subTest(expression=expression): + self.assertIsNotNone(self.inspect('import os\nbox = ' + expression + '\nalias = box\ndef f(arg=alias):\n ' + call + '\nf()\n')) + self.assertIsNone(self.inspect('box = [x for x in [len]]\nalias = box\ndef f(arg=alias):\n print(arg[0]("safe"))\nf()\n')) + + def test_pr103_delta_display_subscripts(self) -> None: + for expression in ('Alias = (K,)[0]\nprint(Alias())', + 'classes = (K,)\nAlias = classes[0]\nprint(Alias())', + 'box = [K()]\nprint(box[0])', + 'box = {"x": K()}\nalias = box\nprint(alias["x"])'): + for sensitive in (True, False): + with self.subTest(expression=expression, sensitive=sensitive): + result = 'str(os.environ)' if sensitive else '"safe"' + violation = self.inspect('import os\nclass K:\n def __str__(self):\n return ' + result + '\n' + expression + '\n') + if sensitive: + self.assertIsNotNone(violation) + else: + self.assertIsNone(violation) + + def test_pr103_delta_conversion_dictionary_aliases(self) -> None: + for conversion in ('int', 'float', 'complex'): + for container in ('a', '{**a}'): + with self.subTest(conversion=conversion, container=container): + prefix = 'import os\na = {"x": ' + conversion + ', "safe": len}\nb = ' + container + '\nc = b\n' + self.assertIsNotNone(self.inspect(prefix + 'c["x"](str(os.environ))\n')) + self.assertIsNone(self.inspect(prefix + 'c["x"]("1")\n')) + if container == 'a': + self.assertIsNone(self.inspect(prefix + 'c["safe"](str(os.environ))\n')) + + def test_pr103_delta_class_import_receivers(self) -> None: + for imported in ('from os import system as go', 'from subprocess import run as go'): + for receiver in ('D', 'C()', 'instance'): + with self.subTest(imported=imported, receiver=receiver): + self.assertIsNotNone(self.inspect('class C:\n ' + imported + '\nD = C\ninstance = C()\n' + receiver + '.go("synthetic-command")\n')) + for receiver in ('D', 'C()', 'instance'): + self.assertIsNone(self.inspect('class C:\n from re import escape as go\nD = C\ninstance = C()\nprint(' + receiver + '.go("safe"))\n')) + + def test_pr103_delta_subscript_cycles_retain_alternatives(self) -> None: + for bindings in ('box = [complex]\nbox = box[0]\n', + 'box = other[0]\nother = box\nbox = [complex]\n'): + with self.subTest(bindings=bindings): + self.assertIsNotNone(self.inspect('import os\n' + bindings + 'box[0](str(os.environ))\n')) + self.assertIsNone(self.inspect('box = other[0]\nother = box\nbox = [len]\nprint(box[0]("safe"))\n')) + + def test_pr103_gh_setattr_names(self) -> None: + for target in ('setattr', 'setter'): + self.assertIsNotNone(self.inspect('import os\nsetter = setattr\n' + target + '(object(), str(os.environ), 1)\n')) + self.assertIsNone(self.inspect('class C: pass\nsetattr(C(), "safe", 1)\n')) + + def test_pr103_gh_nested_class_launchers(self) -> None: + for suite in (' if True:\n ', ' for item in [1]:\n ', + ' try:\n ', ' with contextlib.nullcontext():\n '): + ending = ' finally:\n pass\n' if 'try:' in suite else '' + with self.subTest(suite=suite): + self.assertIsNotNone(self.inspect('import contextlib\nclass C:\n' + suite + 'from os import system as go\n' + ending + 'C.go("synthetic-command")\n')) + self.assertIsNone(self.inspect('class C:\n if True:\n from re import escape as go\nprint(C.go("safe"))\n')) + + def test_pr103_gh_destructured_mapping_aliases(self) -> None: + for alias in ('a, = (d,)', '[a] = [d]', '(a, [b]) = (d, [d])'): + self.assertIsNotNone(self.inspect('import os\nd = {}\n' + alias + '\na[str(os.environ)] = 1\nfor key in d:\n {}[key]\n')) + self.assertIsNone(self.inspect('d = {}\na, = (d,)\na["safe"] = 1\nfor key in d:\n {}[key]\n')) + + def test_pr103_gh_inserted_conversion_callables(self) -> None: + for conversion in ('int', 'float', 'complex'): + for store in ('d["x"] = CONVERT', 'd.update({"x": CONVERT})', + 'd.setdefault("x", CONVERT)', 'd.__setitem__("x", CONVERT)', + 'd |= {"x": CONVERT}'): + prefix = 'import os\nd = {}\n' + store.replace('CONVERT', conversion) + '\na = d\n' + with self.subTest(conversion=conversion, store=store): + self.assertIsNotNone(self.inspect(prefix + 'a["x"](str(os.environ))\n')) + self.assertIsNone(self.inspect(prefix + 'a["x"]("1")\n')) + self.assertIsNone(self.inspect('import os\nd = {}\nd["x"] = complex\nd["safe"] = len\nd["safe"](str(os.environ))\n')) + + def test_pr103_gh_assigned_display_methods(self) -> None: + for implementation in ('def leak(self):\n return RESULT\n', 'leak = lambda self: RESULT\n'): + for install in ('class K:\n __str__ = leak\n', 'class K: pass\nK.__str__ = leak\n'): + for sensitive in (True, False): + code = 'import os\n' + implementation.replace('RESULT', 'str(os.environ)' if sensitive else '"safe"') + install + 'print(K())\n' + with self.subTest(implementation=implementation, install=install, sensitive=sensitive): + if sensitive: + self.assertIsNotNone(self.inspect(code)) + else: + self.assertIsNone(self.inspect(code)) + + def test_pr103_gh_bound_and_percent_display(self) -> None: + for protocol, expression in (('__format__', '"{}".format(K())'), + ('__str__', '"%s" % K()'), ('__repr__', '"%r" % K()')): + for sensitive in (True, False): + code = 'import os\nclass K:\n def ' + protocol + '(self, *args):\n return ' + ('str(os.environ)' if sensitive else '"safe"') + '\nprint(' + expression + ')\n' + with self.subTest(protocol=protocol, sensitive=sensitive): + if sensitive: + self.assertIsNotNone(self.inspect(code)) + else: + self.assertIsNone(self.inspect(code)) + + def test_pr103_gh_implicit_path_errors(self) -> None: + for expression in ('Path.cwd().relative_to("/definitely-no")', 'root.relative_to("/definitely-no")'): + self.assertIsNotNone(self.inspect('from pathlib import Path\nroot = Path.cwd()\n' + expression + '\n')) + self.assertIsNone(self.inspect('from pathlib import Path\nPath("safe/child").relative_to("safe")\n')) + self.assertIsNone(self.inspect('from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError:\n pass\n')) + self.assertIsNotNone(self.inspect('from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError:\n raise\n')) + self.assertIsNotNone(self.inspect('from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError as error:\n print(error)\n')) + self.assertIsNotNone(self.inspect('from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError:\n raise RuntimeError("safe")\n')) + self.assertIsNone(self.inspect('from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError:\n raise SystemExit("safe")\n')) + self.assertIsNone(self.inspect('from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError:\n raise RuntimeError("safe") from None\n')) + + def test_pr103_gh_helper_launcher_defaults(self) -> None: + for launcher in ('os.system', 'len'): + code = 'import os\ndef choose():\n return ' + launcher + '\ndef f(launch=choose()):\n launch("synthetic-command")\nf()\n' + if launcher == 'os.system': + self.assertIsNotNone(self.inspect(code)) + else: + self.assertIsNone(self.inspect(code)) + + def test_pr103_gh_wrapped_open_readers(self) -> None: + for expression in ('(open,)[0]', '{"reader": open}["reader"]', 'True and open', 'choose()'): + code = 'def choose():\n return open\nreader = ' + expression + '\nprint(reader("/tmp/synthetic-private").read())\n' + with self.subTest(expression=expression): + self.assertIsNotNone(self.inspect(code)) + self.assertIsNone(self.inspect('reader = (len,)[0]\nprint(reader("safe"))\n')) + self.assertIsNone(self.inspect('reader = (len, open)[0]\nprint(reader("safe"))\n')) + self.assertIsNone(self.inspect('reader = {"safe": len, "reader": open}["safe"]\nprint(reader("safe"))\n')) + + def test_pr103_gh_class_sensitive_imports(self) -> None: + for member in ('environ', 'environb'): + for receiver in ('C', 'D', 'C()'): + with self.subTest(member=member, receiver=receiver): + self.assertIsNotNone(self.inspect('class C:\n from os import ' + member + ' as inherited\nD = C\nprint(' + receiver + '.inherited)\n')) + self.assertIsNone(self.inspect('class C:\n from re import escape as inherited\nprint(C.inherited("safe"))\n')) + + def test_pr103_gh_adjacent_update_sources(self) -> None: + for store in ('d.update(x=complex)', 'updates = {"x": complex}\nd.update(updates)'): + prefix = 'import os\nd = {}\n' + store + '\n' + with self.subTest(store=store): + self.assertIsNotNone(self.inspect(prefix + 'd["x"](str(os.environ))\n')) + self.assertIsNone(self.inspect(prefix + 'd["x"]("1")\n')) + + def test_pr103_gh_adjacent_formatter_alias(self) -> None: + for sensitive in (True, False): + code = 'import os\nclass K:\n def __format__(self, spec):\n return ' + ('str(os.environ)' if sensitive else '"safe"') + '\nformatter = "{}".format\nprint(formatter(K()))\n' + with self.subTest(sensitive=sensitive): + if sensitive: + self.assertIsNotNone(self.inspect(code)) + else: + self.assertIsNone(self.inspect(code)) + + def test_pr103_current_scanner_regex_binding_control(self) -> None: + module = ast.parse(_scanner_module_source(PACKET_TEXT)) + fence = next(node for node in module.body + if isinstance(node, ast.FunctionDef) and node.name == "fence_details") + calls = [node for node in ast.walk(fence) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr == "group"] + self.assertTrue(calls) + for call in calls: + self.assertTrue(self.scanner["python_known_non_path_reader_call"](call, module)) # type: ignore[operator] + + def test_pr103_current_scanner_method_helper_is_not_launcher_alias(self) -> None: + module = ast.parse(_scanner_module_source(PACKET_TEXT)) + _modules, functions, _unresolved = self.scanner["python_import_bindings"](module) # type: ignore[operator] + self.assertTrue("methods" not in functions, "local methods helper acquired launcher provenance") + def test_current_packet_has_no_static_scanner_violations(self) -> None: matches: list[str] = [] shell_command_count = 0 From 91e0d30aee73b2aa6ea86bba529d619ed8768c38 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Thu, 22 Oct 2026 20:54:47 +0900 Subject: [PATCH 111/123] fix(evidence): close issue 79 handler and provenance review gaps --- docs/evidence/g01-recovery-packet.md | 243 +++++++++- .../issue79-pr103-review-6172592368.md | 449 ++++++++++++++++++ .../issue79_regression_test.py | 216 +++++++++ 3 files changed, 888 insertions(+), 20 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 2e9a3f38..025be857 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -5681,11 +5681,12 @@ for name in files: errors.append(f"{name}: tracked Markdown path was a symlink") continue source_path = (repository_root / source).resolve() + errors.append(f"{name}: tracked Markdown path escapes repository") try: source_path.relative_to(repository_root) except ValueError: - errors.append(f"{name}: tracked Markdown path escapes repository") continue + errors.pop() markdown = "\n".join(markdown_outside_fences(source_path.read_text(encoding="utf-8"))) for match in link.finditer(markdown): target = match.group(1).strip().strip("<>") @@ -5701,11 +5702,12 @@ for name in files: continue fragment = fragment if separator else None path = (source.parent / target).resolve() + errors.append(f"{name}: local link target escapes repository {target}") try: path.relative_to(repository_root) except ValueError: - errors.append(f"{name}: local link target escapes repository {target}") continue + errors.pop() checked += 1 if not path.is_file(): errors.append(f"{name}: missing target {target}") @@ -10850,13 +10852,13 @@ def python_sensitive_value_names(tree, parents): # 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)) + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)) 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)): + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): 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 [] @@ -21663,15 +21665,59 @@ def python_sensitive_read_violation(tree, parents): ) } error_operand_bindings = {} + def error_operand_starred_sources(value, seen): + if value is None or id(value) in seen: + return [] + seen.add(id(value)) + if isinstance(value, ast.Starred): + return error_operand_starred_sources(value.value, seen) + if isinstance(value, ast.Name): + for scope in python_lexical_scope_chain(python_enclosing_scope(value, parents), parents): + expanded_sources = [source for source_scope, source + in assignments_by_name.get(value.id, ()) if source_scope is scope] + if expanded_sources: + return [element for source in expanded_sources + for element in error_operand_starred_sources(source, seen)] + return [value] + if isinstance(value, (ast.Tuple, ast.List)): + return [element for source in value.elts for element in ( + error_operand_starred_sources(source, seen) + if isinstance(source, ast.Starred) else [source])] + if isinstance(value, ast.Call): + return [element for source in python_local_call_return_values(value, tree, parents) + for element in error_operand_starred_sources(source, seen)] + return [value] + def error_operand_binding_pairs(target, value): if isinstance(target, ast.Name): return [(target, value)] if isinstance(target, (ast.Tuple, ast.List)): pairs = [] + starred_positions = [position for position, element in enumerate(target.elts) + if isinstance(element, ast.Starred)] for index, item in enumerate(target.elts): - source = (value.elts[index] if isinstance(value, (ast.Tuple, ast.List)) - and index < len(value.elts) else value) - pairs.extend(error_operand_binding_pairs(item, source)) + if (isinstance(value, (ast.Tuple, ast.List)) and len(starred_positions) <= 1 + and not any(isinstance(element, ast.Starred) for element in value.elts)): + if starred_positions: + star_position = starred_positions[0] + tail_count = len(target.elts) - star_position - 1 + if isinstance(item, ast.Starred): + for source in value.elts[star_position:len(value.elts) - tail_count]: + pairs.extend(error_operand_binding_pairs(item, source)) + continue + source_index = index if index < star_position else index - len(target.elts) + else: + source_index = index + if -len(value.elts) <= source_index < len(value.elts): + pairs.extend(error_operand_binding_pairs(item, value.elts[source_index])) + continue + # Unresolved destructuring retains every possible source identity. + sources = value.elts if isinstance(value, (ast.Tuple, ast.List)) else [value] + for source in sources: + alternatives = (error_operand_starred_sources(source, set()) + if isinstance(source, ast.Starred) else [source]) + for alternative in alternatives: + pairs.extend(error_operand_binding_pairs(item, alternative)) return pairs if isinstance(target, ast.Starred): return error_operand_binding_pairs(target.value, value) @@ -22306,7 +22352,7 @@ def python_sensitive_read_violation(tree, parents): ): for candidate in ast.walk(entry): if ( - isinstance(candidate, ast.Return) + isinstance(candidate, (ast.Return, ast.Yield, ast.YieldFrom)) and candidate.value is not None and error_operand_sensitive(candidate.value) ): @@ -22325,6 +22371,12 @@ def python_sensitive_read_violation(tree, parents): functions = [] if isinstance(value, ast.Lambda): return error_operand_sensitive(value.body) + if isinstance(value, ast.Call): + return any(display_implementation_sensitive(returned, seen) + for returned in python_local_call_return_values(value, tree, parents)) + if isinstance(value, ast.Subscript): + return any(display_implementation_sensitive(selected, seen) + for selected in error_operand_subscript_values(value)) if isinstance(value, ast.Name): functions = (python_local_function_candidates(value.id, value, tree, parents) + python_local_lambda_candidates(value.id, value, tree, parents)) @@ -22337,7 +22389,8 @@ def python_sensitive_read_violation(tree, parents): break for function in functions: returns = ([function.body] if isinstance(function, ast.Lambda) else - [node.value for node in ast.walk(function) if isinstance(node, ast.Return)]) + [node.value for node in ast.walk(function) + if isinstance(node, (ast.Return, ast.Yield, ast.YieldFrom))]) if any(error_operand_sensitive(result) for result in returns): return True return False @@ -22358,6 +22411,112 @@ def python_sensitive_read_violation(tree, parents): for classdef in display_class_targets(entry, seen)] return [] + def path_handler_builtin_reference(expression, names): + if not python_unshadowed_builtin_reference(expression, names, tree, parents): + return False + python_sensitive_imported_function_alias(expression, tree, parents) + constructor_index = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + return not any(constructor_index.get((id(scope), expression.id), ()) + for scope in python_sensitive_alias_scope_chain(expression, parents)) + + def path_handler_suppresses_context(handler): + """Prove only inert control flow or literal builtin context suppression.""" + def inert_message(expression, seen): + if id(expression) in seen: + return False + seen.add(id(expression)) + if isinstance(expression, ast.Constant): + return True + if isinstance(expression, ast.JoinedStr): + return all(isinstance(part, ast.Constant) or ( + isinstance(part, ast.FormattedValue) and part.format_spec is None + and inert_message(part.value, set(seen))) for part in expression.values) + if isinstance(expression, ast.Name): + if any(isinstance(part, ast.Name) and part.id == expression.id + and isinstance(part.ctx, ast.Del) for part in ast.walk(tree)): + return False + # Reuse the complete binder inventory: except targets clear + # implicitly, and for/with/match/import/parameter binders are + # not evidence of an available primitive message. + python_sensitive_imported_function_alias(expression, tree, parents) + message_index = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in python_sensitive_alias_scope_chain(expression, parents): + message_bindings = message_index.get((id(scope), expression.id), ()) + if message_bindings: + if any(kind != "assignment" or binding.value is None + for kind, binding in message_bindings): + return False + message_sources = [binding.value for _kind, binding in message_bindings] + if any(not isinstance(parents.get(binding), (ast.Assign, ast.AnnAssign)) + or parents.get(parents.get(binding)) is not scope + or binding.lineno >= handler.lineno for binding in message_sources): + return False + return all(inert_message(binding, set(seen)) or ( + isinstance(binding, ast.Subscript) + and python_dotted_name(binding.value) == "sys.argv" + and isinstance(binding.slice, ast.Constant) + and isinstance(binding.slice.value, int) + and python_imported_module_alias_is_stable("sys", "sys", tree) + and all(isinstance(parents.get(part), ast.Subscript) + and parents[part].value is part + or isinstance(parents.get(part), ast.Call) + and python_unshadowed_builtin_call(parents[part], {"len"}, tree, parents) + for part in ast.walk(tree) + if isinstance(part, ast.Attribute) + and python_dotted_name(part) == "sys.argv") + and not any(isinstance(part, (ast.Attribute, ast.Subscript)) + and isinstance(part.ctx, (ast.Store, ast.Del)) + and (python_dotted_name(part) == "sys.argv" + or isinstance(part, ast.Subscript) + and python_dotted_name(part.value) == "sys.argv") + for part in ast.walk(tree)) + ) for binding in message_sources) + if expression.id in error_operand_parameters.get(scope, ()): + return False + return False + + for statement in handler.body: + if isinstance(statement, (ast.Pass, ast.Continue, ast.Break)): + continue + if isinstance(statement, ast.Return) and ( + statement.value is None or isinstance(statement.value, ast.Constant) + ): + continue + if isinstance(statement, ast.Raise) and isinstance(statement.exc, ast.Call): + replacement = statement.exc + if (path_handler_builtin_reference(replacement.func, + {"RuntimeError", "ValueError", "Exception", "SystemExit"}) + and not replacement.keywords + and all(inert_message(argument, set()) for argument in replacement.args) + and (isinstance(statement.cause, ast.Constant) and statement.cause.value is None + or statement.cause is None and path_handler_builtin_reference( + replacement.func, {"SystemExit"}))): + continue + return False + return True + + def path_exit_context_is_contained(handler): + """An uncaused SystemExit must not be replaced during propagation.""" + carries_context = any(isinstance(effect, ast.Raise) and effect.cause is None + and python_unshadowed_builtin_call(effect.exc, {"SystemExit"}, tree, parents) + for effect in handler.body) + if not carries_context: + return True + current = handler + while current in parents: + enclosing = parents[current] + if isinstance(enclosing, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + break + if isinstance(enclosing, (ast.With, ast.AsyncWith)): + return False + if isinstance(enclosing, (ast.Try, ast.TryStar)): + if any(not isinstance(effect, ast.Pass) for effect in enclosing.finalbody): + return False + if enclosing is not parents.get(handler) and current in enclosing.body and enclosing.handlers: + return False + current = enclosing + return True + assigned_display_methods = {} if error_operand_classes: for candidate in ast.walk(tree): @@ -22372,7 +22531,7 @@ def python_sensitive_read_violation(tree, parents): classes, method = display_class_targets(target.value, set()), target.attr else: continue - if method in {"__str__", "__repr__", "__format__"}: + if method in {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__"}: for classdef in classes: assigned_display_methods.setdefault(classdef, []).append((method, candidate.value)) @@ -22583,23 +22742,50 @@ def python_sensitive_read_violation(tree, parents): parent = parents[current] if isinstance(parent, ast.Try) and current in parent.body: for handler in parent.handlers: - if python_dotted_name(handler.type) != "ValueError": - continue - if any((isinstance(part, ast.Raise) and not ( - isinstance(part.cause, ast.Constant) and part.cause.value is None - or part.cause is None and isinstance(part.exc, ast.Call) - and python_unshadowed_builtin_call(part.exc, {"SystemExit"}, tree, parents))) - or (handler.name is not None and isinstance(part, ast.Name) - and part.id == handler.name and isinstance(part.ctx, ast.Load)) - for statement in handler.body for part in ast.walk(statement)): + caught_types = (handler.type.elts if isinstance(handler.type, ast.Tuple) + else [handler.type]) + caught_names = [python_dotted_name(caught) for caught in caught_types] + # Unknown types may match: never skip them to a later safe handler. + if handler.type is not None and all(name in { + "TypeError", "KeyError", "IndexError", "RuntimeError", + "OSError", "SystemExit", "KeyboardInterrupt", + } and path_handler_builtin_reference(caught, {name}) + for caught, name in zip(caught_types, caught_names)): continue - handled = True + handled = (any(name in {"ValueError", "Exception", "BaseException"} + and path_handler_builtin_reference(caught, {name}) + for caught, name in zip(caught_types, caught_names)) or handler.type is None) + if handler.type is not None and not all( + path_handler_builtin_reference(caught, { + "ValueError", "Exception", "BaseException", "TypeError", "KeyError", + "IndexError", "RuntimeError", "OSError", "SystemExit", "KeyboardInterrupt", + }) for caught in caught_types): + handled = False + if handled: + handled = (path_handler_suppresses_context(handler) + and path_exit_context_is_contained(handler)) + if any(not isinstance(effect, ast.Pass) for effect in parent.finalbody): + handled = False + break + # The first applicable try owns the exception; unsafe handlers + # cannot be rescued by an outer or later suppression proof. + break if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): break current = parent if not handled: return ("Python local path may surface through an implicit pathlib exception " f"on line {node.lineno}") + if (error_operand_classes and ( + python_unshadowed_builtin_reference(node.func, + {"dict", "list", "tuple", "set", "frozenset", "iter", "next"}, tree, parents) + or isinstance(node.func, ast.Name) and any( + python_assigned_callable_alias(node.func.id, collection_name, tree) + for collection_name in ("dict", "list", "tuple", "set", "frozenset", "iter", "next"))) + and any(error_operand_repr_sensitive(argument, set(), + ("__iter__", "__next__", "__getitem__")) for argument in node.args)): + return ("Python custom iterator may expose sensitive protocol values " + f"on line {node.lineno}") if len(node.args) >= 2 and ( dotted in {"getattr", "delattr", "setattr", "builtins.getattr", "builtins.delattr", "builtins.setattr"} or isinstance(node.func, ast.Name) and any( @@ -22863,7 +23049,18 @@ def reviewed_python_synthetic_shell_input(node, value, tree, parents): def python_computed_callable_violation(tree): """Reject calls whose callable target is returned by another call.""" + hook_parents = {child: parent for parent in ast.walk(tree) + for child in ast.iter_child_nodes(parent)} for node in ast.walk(tree): + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + hook_targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(target, ast.Attribute) and target.attr == "breakpointhook" + for target in hook_targets): + return f"Python breakpoint hook rebinding is not reviewed on line {node.lineno}" + if isinstance(node, ast.Call) and python_unshadowed_builtin_reference( + node.func, {"breakpoint"}, tree, hook_parents + ): + return f"Python indirect breakpoint execution is not reviewed on line {node.lineno}" if isinstance(node, ast.Call) and isinstance(node.func, ast.Call): return ( "Python heredoc contains an unresolved computed callable target " @@ -22962,6 +23159,12 @@ def python_open_aliases(tree): for imported in node.names: if imported.name in {"open", "open_code"}: aliases.add(imported.asname or imported.name) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + reader_parameters = list(node.args.posonlyargs) + list(node.args.args) + reader_defaults = list(zip(reader_parameters[-len(node.args.defaults):], node.args.defaults)) if node.args.defaults else [] + reader_defaults.extend(zip(node.args.kwonlyargs, node.args.kw_defaults)) + assignments.extend((ast.Name(id=parameter.arg, ctx=ast.Store()), default) + for parameter, default in reader_defaults if default is not None) def open_reference(value, seen): if value is None or id(value) in seen: return False diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index aca0c727..4681a04e 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -408,3 +408,452 @@ failures and one authorized interrupted scan above remain part of the record. Coordinator owns independent delta/security review, fresh exact-head GitHub Codex review, hosted quick check, finding triage, push and merge; the correction has not passed those gates merely because its local tests are green. + +## Correction of the `3496887` review delta and final GitHub additions + +This sole-writer batch starts at clean immutable local head +`34968872cd1d1f832c9ee02dcf2119badc18bacc`, using the current explicit +Codex `gpt-6.1-sol`, medium override. The four independent P1 findings are +recorded in immutable [comment 6339148730](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6339148730) +against that local head. During this dispatch the coordinator extended the same +batch to the four additional P1 findings in completed GitHub +[comment 6338442487](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6338442487), +whose original review source is +`ca6af5676548ffe716ca3a2d35fea07f7f71c64d`. The writer read both complete +comments through read-only `gh api`, including +`gh api repos/1XP-AI/gh-runnerd/issues/comments/6338442487 --jq '{html_url,body}'`. +The earlier eleven-finding checkpoint preceded these four GitHub additions; +the prior ledger remains historical evidence, not their disposition. + +All eight findings are locally corrected P1 blockers, with no rebuttal or +deferral. They remain subject to the coordinator's independent delta/security +review, fresh exact-head GitHub Codex review and same-head hosted quick check. +The remote `ca6af56` quick-check result does not qualify this local correction. + +| Finding and source comment | Correction and bounded safe controls | +|---|---| +| Handler order, 6339148730 | The first potentially matching handler owns the proof. Unshadowed known nonmatching builtin exceptions may be skipped; unknown handler types fail closed, including unknown tuple elements. An earlier broad unsafe handler cannot be rescued by a later `ValueError: pass`. `test_pr103_review_handler_order` retains a nonmatching `TypeError` followed by safe suppression and a safe first handler followed by unreachable unsafe output. | +| Handler suppression, 6339148730 | Replace the absence-of-explicit-raise heuristic with a narrow positive proof: inert control flow, constant returns, or unshadowed builtin replacement exceptions with proven inert operands and explicit `from None` (or builtin `SystemExit`). Arbitrary expressions/calls, helper errors, operand-evaluation errors, unresolved names, nested `try/finally`, and noninert enclosing `finally` effects reject. `test_pr103_review_handler_suppression` retains `pass`, literal `from None`, and literal `SystemExit`. `test_pr103_review_handler_adjacent_proof` covers unknown tuple types, missing/late/unreachable/deleted names and an escaped CLI-argument alias. Existing packet `SystemExit` diagnostics retain acceptance through direct, preceding, same-suite primitive bindings: literal aliases or stable indexed CLI strings with no raw argument-list escape/store. This is a narrow proof, not arbitrary handler-code safety. | +| Starred mapping identity, 6339148730 | Literal destructuring binds prefix targets from the front and suffix targets from the tail; starred middle alternatives conservatively join the identity graph. Nested targets recurse through the same resolver. Unresolved source expansion retains all possible source identities instead of guessing a position. `test_pr103_review_starred_mapping_identity` covers prefix/tail, nested and starred-source shapes with literal-key positives. | +| Assigned display provenance, 6339148730 | Reuse `python_local_call_return_values` and the existing bounded subscript/container selector before inspecting assigned implementations. `test_pr103_review_assigned_display_provenance` covers direct/chained factory returns, tuple selection and named dictionaries for both class-suite and later installation, retaining literal-return counterparts. Shared visited guards preserve termination. | +| Lambda sensitive defaults, 6338442487 | Include lambdas in the existing fixed-point default-taint propagation and its iteration bound. `test_pr103_final_lambda_sensitive_defaults` covers positional and keyword-only defaults, with literal defaults accepted. | +| Open callable defaults, 6338442487 | Feed positional and keyword-only function/lambda defaults into the existing open-provenance assignment traversal. `test_pr103_final_open_callable_defaults` rejects private synthetic paths while preserving the same reviewed repository-relative reader and an unrelated `len` default. No reader/path allowlist was widened. | +| Breakpoint hook indirection, 6338442487 | Conservatively refuse breakpoint-hook attribute rebinding and direct unshadowed builtin `breakpoint` calls. Hook installation therefore rejects before a direct or aliased breakpoint call can hide it. `test_pr103_final_breakpoint_hook` retains an ordinary locally defined function named `breakpoint`; no debugger or hook specimen executes. | +| Custom iteration/sequence values, 6338442487 | Reuse the local class/assigned-method provenance graph for `__iter__`, `__next__` and `__getitem__`, inspecting return/yield expressions before collection/iterator consumption, including existing callable aliases. `test_pr103_final_custom_iterator_values` covers assigned lambdas, generator yields and sequence lookup with literal collection positives. `test_pr103_final_iterator_adjacent_provenance` retains safe self-iterator and assigned-generator alternatives. This conservatively rejects sensitive consumption without claiming a complete Python iterator model. | + +Actual commands use the unchanged isolated, PATH/locale-only prefix: + +`env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py` + +Only inspected trusted scanner/harness definitions execute. Every added source +example is inert AST data; no specimen, returned callable, generator, recipe, +canary or live operation executes. The interpreter remains CPython 3.14.3. + +- Prefix plus `-k test_pr103_review`: four methods, 19 failed assertions in + 0.356s, exit 1, before implementing the original four classes. Nested target + negatives were already rejected; the baseline tail examples were accepted. +- First correction, prefix plus `-k test_pr103_review + -k test_pr103_gh_implicit_path_errors + -k test_markdown_link_containment_guard_must_be_direct_and_reachable + -k test_package_containment_try_requires_direct_reachable_body`: seven + methods in 7.727s, exit 1. The original four new methods passed, but the + strict literal-only suppression proof refused an existing package-containment + `SystemExit` f-string. Adding the bounded preceding-string proof preserved + that acceptance: prefix plus `-k test_pr103_review + -k test_package_containment_try_requires_direct_reachable_body` passed five + methods in 20.784s, exit 0. Later guards reject uncertain/deleted bindings and + raw CLI argument-list escapes rather than treating arbitrary messages as safe. +- Prefix plus `-k test_pr103_final`: four methods, 16 failed unsafe assertions + in 0.289s, exit 1, before implementing the final GitHub classes. Safe + counterparts remained accepted. Prefix plus `-k test_pr103_final + -k test_pr103_review` then passed eight methods in 0.420s, exit 0. +- The existing combined focused command documented above (the 28-method suffix + plus the ten additional selectors, with `-k test_pr103` selecting new methods) + passed 60 methods in 27.745s, exit 0. It includes all 52 prior methods. +- Two adjacent methods reproduced five failures in 0.247s, exit 1, with prefix + plus `-k test_pr103_review_handler_adjacent + -k test_pr103_final_iterator_adjacent`: unknown tuple handler types, deleted + and CLI-aliased message sources, aliased collection construction, and assigned + generator yields. The same command passed in 0.227s after correction. + Late/unreachable name and finally-effect controls were also retained. +- An additional starred-source shape reproduced one failure in 0.283s with + prefix plus `-k test_pr103_review_starred_mapping_identity`; conservative + unresolved-source alternatives then passed that method in 0.319s, exit 0. +- Final combined focused command, with the identical historical suffixes, + passed **62 methods in 21.298s**, exit 0: all 52 prior methods plus ten new + methods. No full regression suite or compiler self-audit sweep ran. + +A separate bounded compatibility preflight loaded the inspected trusted harness +with isolated `runpy.run_path`, read the baseline packet with literal local +`git show 34968872cd1d1f832c9ee02dcf2119badc18bacc:docs/evidence/g01-recovery-packet.md`, +and parsed both scanner sources as AST data. It compared command-binding maps +for newly classified names intersecting called baseline helpers, and retained +the previously proven regex-group receiver keys. It passed in 19.386s, exit 0: +206 called baseline helpers, seven retained regex proofs, zero new collisions +or lost proofs. No temporary repository test was added, no source changed, and +this was not the full scanner/compiler self-audit. An initial isolated helper +discovery invocation only listed loader names and is not counted as validation. + +Final frozen input blobs are packet +`2a2feef8a76d5df0b8970320b868e280e5499005` and harness +`bf68601bb675a70d9154cff701217e5c7a66d7a1`. The final packet command is the +same prefix plus +`Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations`. +It inspects the packet as data. Source is frozen during this invocation; +ledger-only edits do not change its inputs. The actual result is recorded +after completion below. + +While this invocation was running, independent contract review reproduced a +remaining RHS-starred mapping gap on those frozen inputs: unpacking a named +tuple into the RHS left an `ast.Starred` node disconnected from mapping identity. +The coordinator placed a source read barrier pending the second review and +authorized stopping only this owned now-obsolete packet test. The writer +verified the single framework CPython 3.14.3 process, `-I -B`, harness, exact +selector and owned parent; it was observed at 3m10s before termination by its +single PID. The session settled with exit 143: **interrupted/inconclusive**, +neither pass nor completed scanner failure. Packet/harness remained frozen +during review; no broad process kill or live operation occurred. Further +source correction requires a new frozen-input result, not reuse of this scan. + +### Follow-up to the first frozen-source HOLD + +The coordinator recorded all three blocking review-delta classes in immutable +[comment 6339408911](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6339408911), +against packet `2a2feef8a76d5df0b8970320b868e280e5499005` and harness +`bf68601bb675a70d9154cff701217e5c7a66d7a1`. The writer read that full comment +using read-only `gh api repos/1XP-AI/gh-runnerd/issues/103/comments --paginate +--jq '.[-1] | {html_url,body}'`. The source barrier was released only after +both reviewers settled; the source changes below followed that release. + +| Reproduced P1 | Local disposition and controls | +|---|---| +| RHS-starred mapping identity | Expand starred operands through the existing scoped assignment and local-return provenance before joining possible mapping identities. An explicit visited-expression set bounds alias/return cycles. `test_pr103_recheck_rhs_starred_mapping` rejects named-tuple RHS expansion, nested targets and a cyclic alias with a reachable mapping alternative; identical literal-key controls pass. | +| Exception-target rebinding and implicit clearing | Message proofs now reuse the complete existing sensitive-alias binder inventory, accepting only direct, preceding same-suite primitive assignments. Exception targets, implicit clear, with/for/match/import/parameter/global/nonlocal and other unknown binders fail closed. `test_pr103_recheck_handler_message_binders` covers the two current-handler replacements, an earlier cleared exception target and adjacent with/for/match/import binding shapes, while preserving an ordinary literal message. This replaces the incomplete error-operand assignment inventory for this proof; no new binder-policy exception was added. | +| Enclosing cleanup exposes SystemExit context | Uncaused builtin `SystemExit` is accepted only when lexical propagation has no noninert finally, enclosing with cleanup or outer exception handler that could replace it. The scan stops at the current function boundary; it is not interprocedural arbitrary-exception safety. Explicit `from None` remains separately proven. `test_pr103_recheck_enclosing_cleanup_context` rejects the outer-finally and outer-handler replacement shapes and preserves pass-finally and explicit-context-suppression controls. | + +Prefix plus `-k test_pr103_recheck` reproduced **ten failed assertions across +three methods in 0.309s**, exit 1. The for-target case was already refused; +other listed unsafe binders reproduced acceptance. After the minimal corrections +the same command passed all three methods in 0.256s, exit 0. Cyclic RHS alias +controls were then added before source freeze. The identical combined focused +command passed **65 methods in 19.732s**, exit 0: all prior 52 plus the ten +first-round and three recheck methods. The identical bounded compatibility +preflight passed in 14.812s, exit 0, retaining 206 called baseline helpers and +seven regex proofs with zero new collisions or lost proofs. + +The second frozen inputs are packet +`f0042480cc679adb1e45896b9442535feb415929` and harness +`f02c227840ba602ef8366d84014565d4a1cf719b`. The writer reported these hashes +to the coordinator and held the next packet invocation pending provisional +independent delta review. Only ledger edits are permitted during that read +barrier; the previous interrupted invocation supplies no packet pass. + +### Complete replacement-constructor identity proof + +Before the next packet invocation, the writer flagged that the existing generic +builtin-shadow helper omitted import and exception-target binding forms. +Independent security review confirmed the constructor case, recorded in +immutable [comment 6339545745](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6339545745) +against the second frozen inputs above. Provisional review approval was +withdrawn; no packet scan had started on those inputs. The writer read the full +comment through the same read-only latest-comment `gh api` command and waited +for the coordinator to release source ownership before correcting it. + +`test_pr103_recheck_replacement_constructor_bindings` initially reported two +failed assertions in 0.231s, exit 1. The imported regex compiler was a valid +unsafe witness; the second assertion used an earlier cleared builtin name, +which may fall back to the genuine builtin and therefore was an overly +conservative refusal expectation, not an unsafe witness. That case was replaced +with a current `except ValueError as RuntimeError` binder, which actually +rebinds the constructor to the active exception. The corrected test command, +prefix plus `-k test_pr103_recheck_replacement_constructor_bindings`, reproduced +two failed unsafe assertions in 0.217s, exit 1. It then passed in 0.214s after +the minimal fix, retaining the literal unshadowed builtin positive. + +The handler-local builtin proof now checks the complete existing scoped binder +inventory for both replacement constructors and caught exception types. Any +competing binder fails closed. This does not change the generic builtin-shadow +helper or widen module, callable, path or recipe policy. Constructor evaluation +must be proven before explicit `from None` can suppress the original context. + +After this correction, the identical combined focused command passed **66 +methods in 19.887s**, exit 0, including all prior 52 and fourteen new methods. +The identical bounded compatibility preflight passed in 14.560s, exit 0: +206 called baseline helpers, seven retained regex proofs, zero collisions or +lost proofs. `git diff --check` passed. + +Third frozen inputs are packet +`08377ae9947f9623e2695f4866f8840b45dd9fcb` and harness +`f0bc9ac3543856e4f7e06fbd5d8333ccaff4b996`. These identities were reported +for provisional independent delta/security review before permitting the final +packet selector. No packet result is attributed to the second frozen inputs. + +The coordinator reported provisional independent contract and security approval +of these exact third-input blobs (Orca messages `msg_5d7f8e6bf0f5` and +`msg_531db4430ba9`), then released the final packet invocation gate. The writer +rechecked both hashes and ran the unchanged isolated prefix plus +`Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations` +once on those inputs. Final commit-bound review approval, GitHub Codex review +and the same-head hosted quick check remain coordinator-owned gates. + +The approved-input packet invocation completed with **one failure in 720.470s**, +exit 1. Its sole violation was the Markdown-checking heredoc at packet line +5603, body line 82: the existing path-containment handler appends a formatted +diagnostic before `continue`, so the narrow handler proof cannot establish that +an error in that effect would suppress the original path context. Both frozen +input hashes were rechecked unchanged. The writer reported this actual failure +to the coordinator before any source edit; provisional source approval cannot +be carried across a correction. This completed failure is distinct from the +earlier interrupted invocation. No pass or final zero-violation count is claimed +for these inputs. + +### Preserve Markdown diagnostics outside active exception context + +The writer added an actual-body regression rather than relaxing the scanner. +Prefix plus `-k test_pr103_current_markdown_diagnostics_suppress_context` +reproduced the exact canonical-body refusal in 0.878s, exit 1. Each of the two +Markdown containment checks now appends its diagnostic immediately before the +`try`, uses only `continue` in `except ValueError`, and pops the just-added +entry immediately after successful containment. The coordinator approved this +aggregation-preserving approach instead of the initially suggested fail-fast +alternative. No handler-call exemption, allowlist or reader-proof change was +made; both direct containment checks and diagnostic texts are retained. + +This is sequential stack bookkeeping on the canonical builtin list and Path +flow. A successful check removes only the entry it just appended. A failed +check continues its own loop before the pop, retaining its diagnostic. Thus +prior errors, multiple failures and their order are preserved; successful +checks do not remove earlier errors. Fallible message construction and append +now occur before the path exception can become active. No runtime success or +exception behavior of a recipe was executed or fabricated. + +Prefix plus `-k test_pr103_current_markdown_diagnostics_suppress_context +-k test_markdown_link_containment_guard_must_be_direct_and_reachable +-k test_pr103_review_handler_suppression` passed three methods in 0.768s, +exit 0. The combined focused command first passed 67 methods in 25.276s on +packet `025be857c0a0a7c83ab8cb32ae21a073312fb5b8` and harness +`1692b517745e245c8bfaae6364f03a39dbe78b95`; the same bounded compatibility +preflight passed in 17.085s, with 206 helpers/seven regex proofs/zero collisions. + +Structural assertions were then added to prove the two adjacent +append/containment-only-try/sole-continue/pop shapes as AST data. An intermediate +edit briefly placed those assertions in the unrelated canonical package test; +the 0.358s targeted pass and a 25.990s combined pass did not exercise them and +are not structural-proof evidence. The block was moved to the intended +Markdown method, restoring the unrelated test. The corrected actual-body +method, including the structural assertions, passed in 0.756s, exit 0. +Final focused evidence and frozen identities follow below. + +The final combined focused command passed **67 methods in 28.435s**, exit 0, +on packet `025be857c0a0a7c83ab8cb32ae21a073312fb5b8` and harness +`6bd8f562689dcf22a9ab56eee5d20232665442f4`, including the correctly located +structural checks. The same bounded compatibility preflight passed in 19.666s, +exit 0, with 206 called baseline helpers, seven retained regex proofs and zero +collisions or lost proofs. These are the final frozen source inputs. + +### Composed packet coverage after the body-only correction + +The coordinator inspected the complete packet selector and authorized an +evidence-reuse proof before deciding whether another full invocation was +necessary. The completed 720.470s run accumulated violations over **all** shell +commands and Python bodies before asserting; it reported exactly the one body +failure above. The final correction changes only that canonical Markdown body +and its regression. No final whole-packet process has been rerun, and no +single-process whole-packet PASS is claimed. + +An isolated trusted proof command used the same PATH/locale environment and +`python3.14 -I -B -` heredoc invocation, loading only the inspected harness +with `runpy.run_path`. It reconstructed the old packet **in memory** by reversing +only the two exact append/continue/pop edits, then computed the Git blob ID as +SHA-1 of the Git `blob \0` header plus UTF-8 bytes. The result was +exactly `08377ae9947f9623e2695f4866f8840b45dd9fcb`. Removing only the new +Markdown regression method similarly reconstructed exact old harness +`f0bc9ac3543856e4f7e06fbd5d8333ccaff4b996`. No recipe was imported, compiled +or executed by either reconstruction. + +That proof passed in 0.846s, exit 0, establishing: + +- Old/current `_scanner_module_source` bytes are identical. +- The harness prefix containing loader/validation code is byte-identical, and + `ast.get_source_segment` for the full packet selector is byte-identical. +- Ordered extraction yields 86 Python bodies with identical invocation + dictionaries and safe markers; 85 body strings are byte-identical. Exactly + body index 14, at packet line 5603, changed. +- All 261 current shell command admissions and static checks passed, and the + corrected body passed full `inspect_python_heredoc` inspection with canonical + interpreter, isolated mode and quoted delimiter admission. + +Its initial ancillary packet-global dependency check used the generic builtin +shadow inventory, which includes module bindings, and was insufficient to +establish the claimed locality. That ancillary conclusion was superseded by a +separate complete-binder audit: each `source` load was resolved through the +existing scoped binding inventory, treating reaching module scope as a global +read. It reported `Global source reads: []`, exit 0. This does not assert that +the scanner is generally stateless. + +Independent security review identified the three shell-derived mutable sets, +so a further isolated proof used **separate fresh trusted scanner namespaces** +for reconstructed old/current packet inputs. It consumed both shell generators +in lockstep and ran each namespace's shell checks while its own extraction +state was active, matching the actual selector's streaming order. This passed +in **1.473s**, exit 0: + +- All 261 complete ordered command strings are byte-identical; their only line + number deltas are 0 or 2, corresponding to the two added body lines. +- `shell_owned_path_variables`, `shell_pending_owned_bindings` and + `shell_sensitive_variable_names` match at every yielded command, after every + pair of old/current checks, and at completion. Both old/current streaming + shell checks have zero violations. +- Final sets are respectively `{"pair_fragment_tmp"}`, + `{"pair_fragment_tmp"}`, and the empty set. The corrected body passes with + those final sets and leaves them unchanged. +- AST reference inventory shows the shell mutation helpers are referenced only + by `shell_commands`, and no scanner-definition function references + `shell_commands`. This agrees with the independent mutable-receiver audit: + Python-body inspection has no route to those shell-state mutation helpers. + Python AST caches are attached to each fresh body tree; marker, invocation, + scanner and shell-context equality preserve the other 85 body results. + +This composes complete current static coverage from the unchanged 85 successful +body inspections in the recorded full failed run, the corrected body's full +successful inspection, and all 261 current shell checks with matched context. +The resulting coverage is 261 shell commands / 86 Python bodies / zero remaining +violations **by composition**, subject to independent approval of this reuse +proof. It is not a replacement claim that the 720.470s invocation passed. + +For independent reproduction, the following consolidated trusted proof combines +the identity, ordered-state and corrected global-source audits above. It reads +the current trusted harness and packet, constructs only inert old-source bytes, +and invokes trusted static-check functions; it never executes a recipe. + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B - <<'PY' +import ast +import hashlib +import runpy +import time +from pathlib import Path + +started = time.monotonic() +h = runpy.run_path("scripts/evidence_packet/issue79_regression_test.py") +current = h["PACKET_TEXT"] +edits = ( + (' errors.append(f"{name}: tracked Markdown path escapes repository")\n try:\n source_path.relative_to(repository_root)\n except ValueError:\n continue\n errors.pop()', + ' try:\n source_path.relative_to(repository_root)\n except ValueError:\n errors.append(f"{name}: tracked Markdown path escapes repository")\n continue'), + (' errors.append(f"{name}: local link target escapes repository {target}")\n try:\n path.relative_to(repository_root)\n except ValueError:\n continue\n errors.pop()', + ' try:\n path.relative_to(repository_root)\n except ValueError:\n errors.append(f"{name}: local link target escapes repository {target}")\n continue'), +) +old = current +for after, before in edits: + assert old.count(after) == 1 + old = old.replace(after, before, 1) +def blob(value): + data = value.encode() + return hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest() +assert blob(old) == "08377ae9947f9623e2695f4866f8840b45dd9fcb" +now_harness = Path("scripts/evidence_packet/issue79_regression_test.py").read_text() +start = now_harness.index(" def test_pr103_current_markdown_diagnostics_suppress_context") +end = now_harness.index(" def test_pr103_current_scanner_method_helper_is_not_launcher_alias", start) +old_harness = now_harness[:start] + now_harness[end:] +assert blob(old_harness) == "f0bc9ac3543856e4f7e06fbd5d8333ccaff4b996" +assert h["_scanner_module_source"](old) == h["_scanner_module_source"](current) +prefix_end = now_harness.index("class Issue79RegressionTests(") +assert now_harness[:prefix_end] == old_harness[:prefix_end] +def selector(source): + node = next(node for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.FunctionDef) + and node.name == "test_current_packet_has_no_static_scanner_violations") + return ast.get_source_segment(source, node) +assert selector(now_harness) == selector(old_harness) +a, b = h["_scanner_namespace"](), h["_scanner_namespace"]() +a["source"], b["source"] = old, current +state_names = ("shell_owned_path_variables", "shell_pending_owned_bindings", + "shell_sensitive_variable_names") +def state(namespace): + return tuple(frozenset(namespace[name]) for name in state_names) +def check(namespace, command): + assert not namespace["shell_process_substitution"](command) + for segment in namespace["shell_token_segments"](command): + if namespace["python_stdin_command"](segment): + assert namespace["reviewed_python_heredoc_segment"](command, segment) + else: + assert namespace["forbidden_shell_command"](segment) is None +count, offsets = 0, set() +for (left, left_line), (right, right_line) in zip( + a["shell_commands"](old), b["shell_commands"](current), strict=True): + count += 1 + assert left == right and state(a) == state(b) + offsets.add(right_line - left_line) + check(a, left) + check(b, right) + assert state(a) == state(b) +assert count == 261 and offsets == {0, 2} +final_state = state(b) +old_bodies = list(a["python_heredoc_bodies"](old)) +new_bodies = list(b["python_heredoc_bodies"](current)) +assert len(old_bodies) == len(new_bodies) == 86 +changed = [] +for index, (left, right) in enumerate(zip(old_bodies, new_bodies, strict=True)): + assert left[2:] == right[2:] + if left[1] != right[1]: + changed.append(index) +assert changed == [14] +number, body, marker, invocation = new_bodies[14] +assert invocation["isolated"] and invocation["quoted"] +assert invocation["interpreter"] == "/opt/homebrew/bin/python3" +assert b["inspect_python_heredoc"](body, marker) is None +assert state(b) == final_state +module = ast.parse(h["_scanner_module_source"](current)) +parents = {child: parent for parent in ast.walk(module) + for child in ast.iter_child_nodes(parent)} +for node in ast.walk(module): + if not isinstance(node, ast.Name) or node.id != "source" or not isinstance(node.ctx, ast.Load): + continue + b["python_sensitive_imported_function_alias"](node, module, parents) + index = getattr(module, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in b["python_sensitive_alias_scope_chain"](node, parents): + assert not isinstance(scope, ast.Module), "global packet-source read" + bindings = index.get((id(scope), "source"), ()) + assert not any(kind == "global" for kind, _ in bindings) + if bindings: + break +mutators = {"shell_commands", "shell_record_sensitive_assignments", "shell_owned_directory_proof"} +references = [] +for function in module.body: + if isinstance(function, ast.FunctionDef): + for node in ast.walk(function): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in mutators: + references.append((function.name, node.id)) +assert sorted(references) == [ + ("shell_commands", "shell_owned_directory_proof"), + ("shell_commands", "shell_record_sensitive_assignments"), +] +print("Old packet/harness:", blob(old), blob(old_harness)) +print("Current packet/harness:", blob(current), blob(now_harness)) +print("Final shell states:", {name: sorted(b[name]) for name in state_names}) +print(f"PASS composed coverage proof: 261 streaming shell checks; 85 identical bodies reused; " + f"changed body {changed[0]} at line {number} PASS; exact identity/context checks; " + f"{time.monotonic() - started:.3f}s") + +PY +``` + +The consolidated reproduction above passed in **1.478s**, exit 0, on the same +frozen inputs, including exact old/current Git identities and shell-state +values printed above. The coordinator reported provisional contract approval +`msg_6fcc2223896a` and security approval `msg_5fe37a821774` of these exact +inputs and the composed-coverage route, and explicitly accepted this evidence +instead of another whole-packet invocation. Source stayed frozen; only this +ledger changed afterward. + +All reported P1 findings and the actual Markdown compatibility failure have +local resolutions and bounded evidence. No unresolved finding is deferred or +rebutted by the writer. `git diff --check` and introduced-delta path/credential +pattern checks passed before the scoped local commit; only the three assigned +files changed. Coordinator owns +commit-bound independent attestation, fresh exact-head GitHub Codex review, +hosted quick checks, push, PR changes and merge. No final whole-packet-process +PASS, full regression/compiler sweep, Go/hosted check, canary or live result is +claimed. These results do not complete G01/G02 or prove arbitrary Python safety. +No credentials, runner operations, Docker operation or GitHub write occurred. +Rollback is a reviewed revert of this scoped local correction. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index cdf10042..6bd8f562 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -7442,6 +7442,181 @@ def test_pr103_gh_adjacent_formatter_alias(self) -> None: else: self.assertIsNone(self.inspect(code)) + def test_pr103_review_handler_order(self) -> None: + prefix = 'from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\n' + for first in ('except Exception as err:\n print(err)\n', + 'except (TypeError, ValueError) as err:\n print(err)\n', + 'except BaseException as err:\n print(err)\n'): + with self.subTest(first=first): + self.assertIsNotNone(self.inspect(prefix + first + 'except ValueError:\n pass\n')) + self.assertIsNone(self.inspect(prefix + 'except TypeError:\n {}["safe"]\nexcept ValueError:\n pass\n')) + self.assertIsNone(self.inspect(prefix + 'except ValueError:\n pass\nexcept Exception as err:\n print(err)\n')) + + def test_pr103_review_handler_suppression(self) -> None: + prefix = ('from pathlib import Path\ndef fail():\n raise RuntimeError("safe")\n' + 'try:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError:\n') + for effect in ('{}["safe"]', '1 / 0', 'fail()', + 'raise RuntimeError({}["safe"]) from None', + 'try:\n raise RuntimeError("safe") from None\n finally:\n 1 / 0'): + with self.subTest(effect=effect): + self.assertIsNotNone(self.inspect(prefix + ' ' + effect + '\n')) + for effect in ('pass', 'raise RuntimeError("safe") from None', 'raise SystemExit("safe")'): + with self.subTest(safe_effect=effect): + self.assertIsNone(self.inspect(prefix + ' ' + effect + '\n')) + self.assertIsNotNone(self.inspect('from pathlib import Path\ndef f():\n try:\n Path.cwd().relative_to("/definitely-no")\n except ValueError:\n return {}["safe"]\nf()\n')) + + def test_pr103_review_starred_mapping_identity(self) -> None: + for binding in ('a, *rest, b = (other, other, other, d)', + '*rest, b = (other, other, d)', + '(a, *rest, b), = ((other, other, d),)', + 'a, *rest, (b,) = (other, other, (d,))', + 'a, *rest, b = (other, d, *empty)'): + for key in ('str(os.environ)', '"safe"'): + code = 'import os\nd = {}\nother = {}\nempty = ()\n' + binding + '\nb[' + key + '] = 1\nfor key in d:\n {}[key]\n' + with self.subTest(binding=binding, key=key): + if key == '"safe"': + self.assertIsNone(self.inspect(code)) + else: + self.assertIsNotNone(self.inspect(code)) + self.assertIsNotNone(self.inspect('import os\nd = {}\nother = {}\na, *rest, b = (d, other, other)\na[str(os.environ)] = 1\nfor key in d:\n {}[key]\n')) + + def test_pr103_review_handler_adjacent_proof(self) -> None: + prefix = 'from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\n' + for handlers in ('except (missing, ValueError):\n pass\n', + 'except ValueError:\n raise SystemExit("safe")\nfinally:\n 1 / 0\n', + 'except ValueError:\n raise RuntimeError(f"{missing}") from None\n'): + with self.subTest(handlers=handlers): + self.assertIsNotNone(self.inspect(prefix + handlers)) + for setup in ('if False:\n msg = "safe"\n', 'msg = "safe"\ndel msg\n', + 'args = sys.argv\nargs[0] = object()\nmsg = sys.argv[0]\n'): + with self.subTest(setup=setup): + self.assertIsNotNone(self.inspect('import sys\n' + setup + prefix + + 'except ValueError:\n raise SystemExit(f"{msg}")\n')) + self.assertIsNotNone(self.inspect(prefix + + 'except ValueError:\n raise RuntimeError(msg) from None\nmsg = "safe"\n')) + self.assertIsNone(self.inspect('msg = "safe"\n' + prefix + + 'except ValueError:\n raise RuntimeError(f"{msg}") from None\n')) + + def test_pr103_review_assigned_display_provenance(self) -> None: + for expression in ('make()', '(leak,)[0]', 'box["display"]', 'again()'): + for install in ('class K:\n __str__ = IMPL\n', 'class K: pass\nK.__str__ = IMPL\n'): + for result in ('str(os.environ)', '"safe"'): + code = ('import os\ndef leak(self):\n return ' + result + '\n' + 'def make():\n return leak\ndef again():\n return make()\n' + 'box = {"display": leak}\n' + install.replace('IMPL', expression) + 'print(K())\n') + with self.subTest(expression=expression, install=install, result=result): + if result == '"safe"': + self.assertIsNone(self.inspect(code)) + else: + self.assertIsNotNone(self.inspect(code)) + + def test_pr103_recheck_rhs_starred_mapping(self) -> None: + for binding in ('a, *rest, b = (other, *bundle)', + '(a, *rest, b), = ((other, *bundle),)', + 'alias = bundle\nbundle = alias\na, *rest, b = (other, *alias)'): + for key in ('str(os.environ)', '"safe"'): + code = ('import os\nd = {}\nother = {}\nbundle = (other, d)\n' + + binding + '\nb[' + key + '] = 1\nfor key in d:\n {}[key]\n') + with self.subTest(binding=binding, key=key): + if key == '"safe"': + self.assertIsNone(self.inspect(code)) + else: + self.assertIsNotNone(self.inspect(code)) + + def test_pr103_recheck_handler_message_binders(self) -> None: + prefix = 'from pathlib import Path\nmsg = "safe"\n' + body = 'try:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError' + for replacement in ('SystemExit(msg)', 'RuntimeError(f"{msg}") from None'): + with self.subTest(replacement=replacement): + self.assertIsNotNone(self.inspect(prefix + body + ' as msg:\n raise ' + replacement + '\n')) + for binder in ('try:\n 1 / 0\nexcept ZeroDivisionError as msg:\n pass\n', + 'with object() as msg:\n pass\n', + 'for msg in [object()]:\n pass\n', + 'match object():\n case msg:\n pass\n', + 'import re as msg\n'): + with self.subTest(binder=binder): + self.assertIsNotNone(self.inspect(prefix + binder + body + + ':\n raise RuntimeError(f"{msg}") from None\n')) + self.assertIsNone(self.inspect(prefix + body + ':\n raise RuntimeError(f"{msg}") from None\n')) + + def test_pr103_recheck_enclosing_cleanup_context(self) -> None: + for replacement in ('SystemExit("safe")', 'RuntimeError("safe") from None'): + for effect in ('pass', '1 / 0'): + code = ('from pathlib import Path\ntry:\n try:\n' + ' Path.cwd().relative_to("/definitely-no")\n' + ' except ValueError:\n raise ' + replacement + + '\nfinally:\n ' + effect + '\n') + with self.subTest(replacement=replacement, effect=effect): + if replacement.startswith('SystemExit') and effect != 'pass': + self.assertIsNotNone(self.inspect(code)) + else: + self.assertIsNone(self.inspect(code)) + self.assertIsNotNone(self.inspect('from pathlib import Path\ntry:\n try:\n Path.cwd().relative_to("/definitely-no")\n except ValueError:\n raise SystemExit("safe")\nexcept BaseException:\n raise RuntimeError("replacement")\n')) + + def test_pr103_recheck_replacement_constructor_bindings(self) -> None: + for setup, handler in (('from re import compile as RuntimeError\n', 'except ValueError'), + ('', 'except ValueError as RuntimeError')): + with self.subTest(setup=setup, handler=handler): + self.assertIsNotNone(self.inspect('from pathlib import Path\n' + setup + + 'try:\n Path.cwd().relative_to("/definitely-no")\n' + + handler + ':\n raise RuntimeError("[") from None\n')) + self.assertIsNone(self.inspect('from pathlib import Path\ntry:\n Path.cwd().relative_to("/definitely-no")\nexcept ValueError:\n raise RuntimeError("[") from None\n')) + + def test_pr103_final_lambda_sensitive_defaults(self) -> None: + for definition in ('f = lambda value=VALUE: print(value)', + 'f = lambda *, value=VALUE: print(value)'): + for value in ('os.environ', '"safe"'): + with self.subTest(definition=definition, value=value): + violation = self.inspect('import os\n' + definition.replace('VALUE', value) + '\nf()\n') + if value == '"safe"': + self.assertIsNone(violation) + else: + self.assertIsNotNone(violation) + + def test_pr103_final_open_callable_defaults(self) -> None: + for definition in ('def f(reader=SOURCE):\n print(reader(PATH).read())', + 'f = lambda reader=SOURCE: print(reader(PATH).read())', + 'def f(*, reader=SOURCE):\n print(reader(PATH).read())'): + for path in ('"/tmp/synthetic-private"', '"docs/backlog.json"'): + code = definition.replace('SOURCE', 'open').replace('PATH', path) + '\nf()\n' + with self.subTest(definition=definition, path=path): + if path == '"docs/backlog.json"': + self.assertIsNone(self.inspect(code)) + else: + self.assertIsNotNone(self.inspect(code)) + self.assertIsNone(self.inspect('def f(reader=len):\n print(reader("safe"))\nf()\n')) + + def test_pr103_final_breakpoint_hook(self) -> None: + for call in ('breakpoint("synthetic-command")', 'hook = breakpoint\nhook("synthetic-command")'): + with self.subTest(call=call): + self.assertIsNotNone(self.inspect('import os, sys\nsys.breakpointhook = os.system\n' + call + '\n')) + self.assertIsNone(self.inspect('def breakpoint(value):\n return len(value)\nprint(breakpoint("safe"))\n')) + + def test_pr103_final_custom_iterator_values(self) -> None: + for implementation in ('__iter__ = lambda self: iter(VALUE)', + 'def __iter__(self):\n yield from VALUE', + 'def __getitem__(self, index):\n return VALUE[index]'): + for value in ('os.environ.items()', '[("safe", "value")]'): + for constructor in ('dict', 'list', 'tuple'): + code = ('import os\nclass K:\n ' + implementation.replace('VALUE', value) + + '\nprint(' + constructor + '(K()))\n') + with self.subTest(implementation=implementation, value=value, constructor=constructor): + if value.startswith('os.'): + self.assertIsNotNone(self.inspect(code)) + else: + self.assertIsNone(self.inspect(code)) + + def test_pr103_final_iterator_adjacent_provenance(self) -> None: + for implementation in ('def __iter__(self):\n return self\n def __next__(self):\n return VALUE', + 'def emit(self):\n yield VALUE\nclass K:\n __iter__ = emit'): + for value in ('str(os.environ)', '"safe"'): + code = 'import os\n' + (('class K:\n ' + implementation) if implementation.startswith('def __iter__') else implementation).replace('VALUE', value) + '\ncollect = list\nprint(collect(K()))\n' + with self.subTest(implementation=implementation, value=value): + if value == '"safe"': + self.assertIsNone(self.inspect(code)) + else: + self.assertIsNotNone(self.inspect(code)) + def test_pr103_current_scanner_regex_binding_control(self) -> None: module = ast.parse(_scanner_module_source(PACKET_TEXT)) fence = next(node for node in module.body @@ -7453,6 +7628,47 @@ def test_pr103_current_scanner_regex_binding_control(self) -> None: for call in calls: self.assertTrue(self.scanner["python_known_non_path_reader_call"](call, module)) # type: ignore[operator] + def test_pr103_current_markdown_diagnostics_suppress_context(self) -> None: + bodies = [body for _line, body, _marker, _invocation + in self.scanner["python_heredoc_bodies"](PACKET_TEXT) # type: ignore[operator] + if 'def markdown_outside_fences(markdown):' in body + and 'local markdown link/anchor check: passed' in body] + self.assertEqual(len(bodies), 1) + module = ast.parse(bodies[0]) + parents = {child: parent for parent in ast.walk(module) + for child in ast.iter_child_nodes(parent)} + checks = [node for node in ast.walk(module) if isinstance(node, ast.Try) + and len(node.body) == 1 and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Call) + and isinstance(node.body[0].value.func, ast.Attribute) + and node.body[0].value.func.attr == "relative_to"] + self.assertEqual(len(checks), 2) + # Each speculative diagnostic is the last list entry. Failure continues + # its own loop before pop; success removes only that entry, preserving + # earlier failures and their order for every sequence of path results. + for check in checks: + owner = parents[check] + self.assertIsInstance(owner, ast.For) + position = owner.body.index(check) + append = owner.body[position - 1] + pop = owner.body[position + 1] + self.assertIsInstance(append, ast.Expr) + self.assertIsInstance(pop, ast.Expr) + self.assertIsInstance(append.value, ast.Call) + self.assertIsInstance(pop.value, ast.Call) + self.assertEqual(ast.unparse(append.value.func), "errors.append") + self.assertEqual(len(append.value.args), 1) + self.assertEqual(ast.unparse(pop.value.func), "errors.pop") + self.assertEqual(pop.value.args, []) + self.assertEqual(pop.value.keywords, []) + self.assertEqual(len(check.handlers), 1) + self.assertEqual(ast.unparse(check.handlers[0].type), "ValueError") + self.assertEqual(len(check.handlers[0].body), 1) + self.assertIsInstance(check.handlers[0].body[0], ast.Continue) + self.assertEqual(check.orelse, []) + self.assertEqual(check.finalbody, []) + self.assertIsNone(self.inspect(bodies[0])) + def test_pr103_current_scanner_method_helper_is_not_launcher_alias(self) -> None: module = ast.parse(_scanner_module_source(PACKET_TEXT)) _modules, functions, _unresolved = self.scanner["python_import_bindings"](module) # type: ignore[operator] From f736e2b8ed4e04804f4121534b4a8846fd8415b4 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Thu, 22 Oct 2026 23:57:18 +0900 Subject: [PATCH 112/123] fix(evidence): close issue 79 AST provenance review gaps --- docs/evidence/g01-recovery-packet.md | 540 +++++++++- .../issue79-pr103-review-6172592368.md | 957 ++++++++++++++++++ .../issue79_regression_test.py | 402 ++++++++ 3 files changed, 1866 insertions(+), 33 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 025be857..358ccced 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -10502,11 +10502,19 @@ def python_sensitive_value_expression(node, sensitive_names, tree, parents, seen ) for argument in node.args ) - if isinstance(node.func, ast.Attribute): - sensitive_receiver = python_sensitive_value_expression( + if isinstance(node.func, ast.expr): + sensitive_receiver = isinstance(node.func, ast.Attribute) and python_sensitive_value_expression( node.func.value, sensitive_names, tree, parents, set(seen) ) - return sensitive_receiver + # Imported module calls may encode or otherwise preserve arguments. + # Ordinary methods retain their receiver-based policy; this does + # not turn a lookup in a safe literal map into a copy of its key. + propagated_arguments = (list(node.args) + [keyword.value for keyword in node.keywords] + if python_module_callable_reference(node.func, tree, parents) else []) + return sensitive_receiver or any( + python_sensitive_value_expression(argument, sensitive_names, tree, parents, set(seen)) + for argument in propagated_arguments + ) if isinstance(node, (ast.DictComp, ast.ListComp, ast.SetComp, ast.GeneratorExp)): if any( isinstance(candidate, ast.Call) @@ -12978,12 +12986,432 @@ def python_class_namespace_owner(node, parents): return None +def python_parameter_value_sources(tree, parents): + """Index actual local-call arguments using the existing callable binders. + + Publish staged edges before resolving callee values. Each original + (call, local callable, descriptor reference) is bound once; a fixed point + adds only such finite pairs. Clear value caches between stages so a + partial parameter graph cannot certify a later lookup as insensitive. + """ + argument_sources = getattr(tree, "_issue79_parameter_value_sources", None) + if argument_sources is not None: + return argument_sources + argument_sources = {} + tree._issue79_parameter_value_sources = argument_sources + call_sites = [site for site in ast.walk(tree) if isinstance(site, ast.Call)] + initial_callees = {} + for site in call_sites: + callees = [] + if isinstance(site.func, ast.Name): + callees.extend((function, None) for function in + python_local_function_candidates(site.func.id, site, tree, parents) + + python_local_lambda_candidates(site.func.id, site, tree, parents)) + callees.extend((function, site.func) for function in + python_local_bound_method_candidates(site.func.id, site, tree, parents)) + elif isinstance(site.func, ast.Lambda): + callees.append((site.func, None)) + elif isinstance(site.func, ast.Attribute): + callees.extend((function, site.func) for function in + python_local_method_candidates(site.func, site, tree, parents)) + initial_callees[site] = callees + + def bind(site, callees): + for function, method_reference, is_bound in callees: + positional = list(function.args.posonlyargs) + list(function.args.args) + if method_reference is not None and positional and is_bound: + receiver_parameter, positional = positional[0], positional[1:] + receiver_targets, receiver_visited = [method_reference], set() + while receiver_targets: + receiver_target = receiver_targets.pop() + if id(receiver_target) in receiver_visited: + continue + receiver_visited.add(id(receiver_target)) + if isinstance(receiver_target, ast.Attribute) and receiver_target.attr == function.name: + argument_sources.setdefault(receiver_parameter, []).append((receiver_target.value, None)) + elif isinstance(receiver_target, ast.Name): + binding_index = getattr(tree, "_issue79_local_return_index", ({}, {}))[0] + for scope in python_lexical_scope_chain(python_enclosing_scope(site, parents), parents): + bindings = binding_index.get((id(scope), receiver_target.id), ()) + if bindings: + receiver_targets.extend(bindings) + break + elif isinstance(receiver_target, ast.Call): + receiver_targets.extend(python_local_call_return_values(receiver_target, tree, parents)) + expanded_arguments = [] + for argument in site.args: + if (isinstance(argument, ast.Starred) and isinstance(argument.value, (ast.Tuple, ast.List)) + and not any(isinstance(item, ast.Starred) for item in argument.value.elts)): + expanded_arguments.extend(argument.value.elts) + else: + expanded_arguments.append(argument) + cursor, uncertain_position = 0, False + for argument in expanded_arguments: + if isinstance(argument, ast.Starred): + for position, parameter in enumerate(positional[cursor:]): + argument_sources.setdefault(parameter, []).append((argument.value, ast.Constant(value=position))) + uncertain_position = True + elif uncertain_position: + for parameter in positional[cursor:]: + argument_sources.setdefault(parameter, []).append((argument, None)) + else: + if cursor < len(positional): + argument_sources.setdefault(positional[cursor], []).append((argument, None)) + cursor += 1 + if function.args.vararg is not None: + argument_sources.setdefault(function.args.vararg, []).append(( + ast.Tuple(elts=expanded_arguments, ctx=ast.Load()), ("tail", len(positional)))) + named_parameters = {parameter.arg: parameter for parameter in positional + list(function.args.kwonlyargs)} + for keyword in site.keywords: + if keyword.arg in named_parameters: + argument_sources.setdefault(named_parameters[keyword.arg], []).append((keyword.value, None)) + elif keyword.arg is None: + for name, parameter in named_parameters.items(): + argument_sources.setdefault(parameter, []).append((keyword.value, ast.Constant(value=name))) + if function.args.kwarg is not None: + extra_keywords = [keyword for keyword in site.keywords if keyword.arg not in named_parameters] + argument_sources.setdefault(function.args.kwarg, []).append((ast.Dict( + keys=[ast.Constant(value=keyword.arg) if keyword.arg is not None else None for keyword in extra_keywords], + values=[keyword.value for keyword in extra_keywords]), None)) + bound_pairs = set() + while True: + tree._issue79_value_provenance_cache = {} + tree._issue79_module_callable_cache = {} + changed = False + for site in call_sites: + callees = list(initial_callees[site]) + references = python_value_provenance(site.func, tree, parents) + for function in references: + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + owner = python_class_namespace_owner(function, parents) + descriptors = [reference for reference in references + if isinstance(reference, ast.Attribute) and reference.attr == getattr(function, "name", None) + and owner is not None and owner in python_value_provenance(reference.value, tree, parents)] + callees.extend((function, reference) for reference in descriptors or [None]) + for function, reference in callees: + # Resolve aliases before deciding whether a descriptor + # consumes self. A parameter may receive either a class or + # an instance; never publish a speculative bound offset. + alternatives = [reference] + if isinstance(reference, ast.Name): + alternatives = [item for item in references if isinstance(item, ast.Attribute) + and item.attr == function.name] or alternatives + for method_reference in alternatives: + is_static = any(python_dotted_name(item) == "staticmethod" + or isinstance(item, ast.Name) and python_assigned_callable_alias(item.id, "staticmethod", tree) + for item in getattr(function, "decorator_list", ())) + is_classmethod = any(python_dotted_name(item) == "classmethod" + for item in getattr(function, "decorator_list", ())) + if method_reference is None or is_static: + modes = {False} + elif is_classmethod: + modes = {True} + elif isinstance(method_reference, ast.Attribute): + origins = python_value_provenance(method_reference.value, tree, parents, receiver_origins=True) + modes = {False for item in origins if isinstance(item, ast.ClassDef)} + if any(isinstance(item, ast.Call) and any(isinstance(candidate, ast.ClassDef) + for candidate in python_value_provenance(item.func, tree, parents)) for item in origins): + modes.add(True) + else: + modes = {python_local_method_is_bound(site, function, tree, parents)} + for is_bound in modes: + pair = (site, function, method_reference, is_bound) + if pair in bound_pairs: + continue + bound_pairs.add(pair) + bind(site, [(function, method_reference, is_bound)]) + changed = True + if not changed: + break + tree._issue79_value_provenance_cache = {} + tree._issue79_module_callable_cache = {} + return argument_sources + + +def python_destructured_binding_values(target, value, requested, resolve): + """Project literal nested/starred patterns without mixing known positions.""" + projected_values = [] + pending_pairs = [(target, value)] + visited_pairs = set() + while pending_pairs: + pattern, projection_source_value = pending_pairs.pop() + pair = (id(pattern), id(projection_source_value)) + if pair in visited_pairs: + continue + visited_pairs.add(pair) + if pattern is requested: + projected_values.append(projection_source_value) + continue + if isinstance(pattern, ast.Starred): + pending_pairs.append((pattern.value, projection_source_value)) + continue + if not isinstance(pattern, (ast.Tuple, ast.List)): + continue + alternatives = resolve(projection_source_value, "expand") or resolve(projection_source_value) + for alternative in alternatives: + if (isinstance(alternative, (ast.Tuple, ast.List)) + and not any(isinstance(item, ast.Starred) for item in alternative.elts)): + stars = [index for index, item in enumerate(pattern.elts) if isinstance(item, ast.Starred)] + for index, item in enumerate(pattern.elts): + if stars and isinstance(item, ast.Starred): + tail = len(pattern.elts) - index - 1 + stop = len(alternative.elts) - tail + pending_pairs.append((item.value, ast.List(elts=alternative.elts[index:stop], ctx=ast.Load()))) + else: + position = index if not stars or index < stars[0] else index - len(pattern.elts) + if -len(alternative.elts) <= position < len(alternative.elts): + pending_pairs.append((item, alternative.elts[position])) + else: + # Unresolved expansion keeps alternatives instead of guessing + # a slot. Name/call aliases resolve through the caller's guard. + candidates = alternative.elts if isinstance(alternative, (ast.Tuple, ast.List)) else [alternative] + for candidate in candidates: + elements = resolve(candidate.value, True) if isinstance(candidate, ast.Starred) else [candidate] + pending_pairs.extend((item, element) for item in pattern.elts for element in elements) + return projected_values + + +def python_value_provenance(value, tree, parents, seen=None, initial_selection=None, receiver_origins=False): + """Bounded value reachability; specimens are AST data, never evaluated. + + Retain every visible alternative, but select known literal container keys. + The complete binder inventory owns shadowing and global/nonlocal scopes. + Unknown binders stop lookup rather than falling through to an outer name. + """ + cached_values = getattr(tree, "_issue79_value_provenance_cache", None) + if cached_values is None: + cached_values = {} + tree._issue79_value_provenance_cache = cached_values + cache_key = (value, initial_selection, receiver_origins) + if seen is None and cache_key in cached_values: + return cached_values[cache_key] + pending = [(value, initial_selection)] + visited = set() if seen is None else set(seen) + found = [] + while pending: + expression, selection = pending.pop() + if expression is None: + continue + identity = (id(expression), id(selection) if isinstance(selection, ast.AST) else selection) + if identity in visited: + continue + visited.add(identity) + if selection is None: + found.append(expression) + if isinstance(expression, ast.Name): + python_sensitive_imported_function_alias(expression, tree, parents) + inventory = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in python_sensitive_alias_scope_chain(expression, parents): + bindings = inventory.get((id(scope), expression.id), ()) + if not bindings: + continue + if any(kind == "global" for kind, _binding in bindings) and not isinstance(scope, ast.Module): + continue + if any(kind == "nonlocal" for kind, _binding in bindings): + continue + for kind, binding in bindings: + if kind == "assignment": + pending.append((binding.value, selection)) + elif kind == "unknown" and isinstance(binding, ast.Name): + owner = parents.get(binding) + while isinstance(owner, (ast.Tuple, ast.List, ast.Starred)): + owner = parents.get(owner) + if isinstance(owner, ast.Assign) and binding in owner.targets: + pending.append((owner.value, selection)) + elif isinstance(owner, (ast.For, ast.AsyncFor, ast.comprehension)): + rows = python_value_provenance(owner.iter, tree, parents, visited, "iterate", receiver_origins) + if owner.target is binding: + pending.extend((row, selection) for row in rows) + else: + for row in rows: + for source in python_destructured_binding_values(owner.target, row, binding, + lambda item, projection=None: python_value_provenance(item, tree, parents, visited, projection, receiver_origins)): + pending.append((source, selection)) + elif kind == "unknown" and isinstance(binding, ast.arg): + for actual, actual_selection in python_parameter_value_sources(tree, parents).get(binding, ()): + if actual_selection is None: + pending.append((actual, selection)) + else: + pending.extend((selected, selection) for selected in + python_value_provenance(actual, tree, parents, visited, actual_selection, receiver_origins)) + arguments = parents.get(binding) + if isinstance(arguments, ast.arguments): + positional = list(arguments.posonlyargs) + list(arguments.args) + defaults = list(zip(positional[-len(arguments.defaults):], arguments.defaults)) if arguments.defaults else [] + defaults.extend(zip(arguments.kwonlyargs, arguments.kw_defaults)) + pending.extend((default, selection) for parameter, default in defaults + if parameter is binding and default is not None) + elif kind == "unknown" and isinstance(binding, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + pending.append((binding, selection)) + break + elif isinstance(expression, ast.Call): + pending.extend((returned, selection) + for returned in python_local_call_return_values(expression, tree, parents)) + # Descriptor binding needs the constructor expression to remain + # distinguishable from a class value, through the same aliases, + # selections and supplied-argument edges as ordinary provenance. + if not receiver_origins: + pending.extend((candidate, selection) + for candidate in python_value_provenance(expression.func, tree, parents, visited) + if isinstance(candidate, ast.ClassDef)) + elif isinstance(expression, ast.ClassDef): + if not receiver_origins: + pending.extend((base, selection) for base in expression.bases) + elif isinstance(expression, ast.Attribute): + class_members = getattr(tree, "_issue79_provenance_class_members", None) + if class_members is None: + direct_members, stored_members = {}, {} + for statement in ast.walk(tree): + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + method_owner = python_class_namespace_owner(statement, parents) + if method_owner is not None: + direct_members.setdefault((method_owner, statement.name), []).append(statement) + continue + if not isinstance(statement, (ast.Assign, ast.AnnAssign)) or statement.value is None: + continue + member_targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + class_owner = python_class_namespace_owner(statement, parents) + for member_target in member_targets: + if isinstance(member_target, ast.Name) and class_owner is not None: + direct_members.setdefault((class_owner, member_target.id), []).append(statement.value) + elif isinstance(member_target, ast.Attribute): + stored_members.setdefault(member_target.attr, []).append((member_target.value, statement.value)) + class_members = (direct_members, stored_members, + any(isinstance(item, ast.ClassDef) for item in ast.walk(tree))) + tree._issue79_provenance_class_members = class_members + direct_members, stored_members, has_classes = class_members + if has_classes: + receiver_classes = {candidate for candidate in python_value_provenance(expression.value, tree, parents, visited) + if isinstance(candidate, ast.ClassDef)} + for classdef in receiver_classes: + pending.extend((source, selection) for source in direct_members.get((classdef, expression.attr), ())) + for receiver, source in stored_members.get(expression.attr, ()): + owners = python_value_provenance(receiver, tree, parents, visited) + if any(owner in receiver_classes for owner in owners): + pending.append((source, selection)) + elif isinstance(expression, ast.Subscript): + # Nested extraction retains all reachable element alternatives. + # A finite flattening mode avoids an ever-growing index stack on + # cyclic aliases such as a = a[0]. Single literal keys stay exact. + pending.append((expression.value, expression.slice if selection is None else True)) + elif isinstance(expression, (ast.List, ast.Tuple, ast.Set)) and selection is not None: + if selection is True: + pending.extend((item.value, True) if isinstance(item, ast.Starred) else (item, None) + for item in expression.elts) + pending.extend((item, True) for item in expression.elts if not isinstance(item, ast.Starred)) + continue + spreads = [position for position, item in enumerate(expression.elts) if isinstance(item, ast.Starred)] + if len(spreads) == 1: + spread_position = spreads[0] + spread_alternatives = python_value_provenance( + expression.elts[spread_position].value, tree, parents, visited, "expand", receiver_origins) + spread_values = [item for item in spread_alternatives + if isinstance(item, (ast.Tuple, ast.List)) + and not any(isinstance(part, ast.Starred) for part in item.elts)] + if spread_values: + pending.extend((ast.Tuple(elts=(expression.elts[:spread_position] + item.elts + + expression.elts[spread_position + 1:]), ctx=ast.Load()), selection) + for item in spread_values) + if all(item in spread_values for item in spread_alternatives): + continue + if selection == "expand": + # An unresolved/cyclic spread stays as a collection + # alternative; a precise sibling never discards it. + found.append(expression) + continue + if isinstance(selection, tuple) and selection[0] == "tail": + # Known packs have expanded above. For unresolved spreads, + # consume only the fixed entries definitely before a spread; + # all remaining alternatives may occupy the first vararg. + consumed = min(selection[1], spreads[0]) if spreads else selection[1] + found.append(ast.Tuple(elts=expression.elts[consumed:], ctx=ast.Load())) + continue + if any(isinstance(item, ast.Starred) for item in expression.elts): + pending.extend((item.value, True) if isinstance(item, ast.Starred) else (item, None) + for item in expression.elts) + elif (isinstance(selection, ast.Constant) and isinstance(selection.value, int) + and not isinstance(selection.value, bool) + and -len(expression.elts) <= selection.value < len(expression.elts)): + pending.append((expression.elts[selection.value], None)) + else: + pending.extend((item, None) for item in expression.elts) + if selection is True: + pending.extend((item, True) for item in expression.elts) + elif isinstance(expression, ast.Dict) and selection is not None: + for key, item in zip(expression.keys, expression.values): + if key is None: + pending.append((item, selection)) + elif (selection is True or not isinstance(selection, ast.Constant) + or not isinstance(key, ast.Constant) or key.value == selection.value): + pending.append((key if selection == "iterate" else item, None)) + if selection is True: + pending.append((item, True)) + elif isinstance(expression, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)) and selection is not None: + yielded_value = ((expression.key if selection == "iterate" else expression.value) + if isinstance(expression, ast.DictComp) else expression.elt) + pending.append((yielded_value, None)) + if selection is True: + pending.append((expression.value if isinstance(expression, ast.DictComp) else expression.elt, True)) + elif isinstance(expression, ast.IfExp): + pending.extend(((expression.body, selection), (expression.orelse, selection))) + elif isinstance(expression, ast.BoolOp): + pending.extend((item, selection) for item in expression.values) + elif isinstance(expression, (ast.NamedExpr, ast.Starred)): + pending.append((expression.value, selection)) + if seen is None: + cached_values[cache_key] = found + return found + + +def python_module_callable_reference(value, tree, parents): + """Recognize imported module callables through the same value graph.""" + module_callable_cache = getattr(tree, "_issue79_module_callable_cache", None) + if module_callable_cache is None: + module_callable_cache = {} + tree._issue79_module_callable_cache = module_callable_cache + if value in module_callable_cache: + return module_callable_cache[value] + imported_callable = False + references = list(python_value_provenance(value, tree, parents)) + visited_references = set() + while references: + reference = references.pop() + if id(reference) in visited_references: + continue + visited_references.add(id(reference)) + root_reference = reference + while isinstance(root_reference, ast.Attribute): + root_reference = root_reference.value + if not isinstance(root_reference, ast.Name): + continue + references.extend(python_value_provenance(root_reference, tree, parents)) + python_sensitive_imported_function_alias(root_reference, tree, parents) + inventory = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in python_sensitive_alias_scope_chain(root_reference, parents): + bindings = inventory.get((id(scope), root_reference.id), ()) + if not bindings: + continue + if any(kind in {"global", "nonlocal"} for kind, _binding in bindings) and not isinstance(scope, ast.Module): + continue + imported_callable = imported_callable or any( + kind == "import" or isinstance(binding, ast.Import) + for kind, binding in bindings) + break + module_callable_cache[value] = imported_callable + return imported_callable + + def python_class_command_attribute_violation(tree, modules, functions): """Reject unresolved conditional and instance/class launcher attributes.""" attributes = {} parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + def launcher_value(value): + return next((resolved for candidate in python_value_provenance(value, tree, parents) + if (resolved := python_resolved_name(candidate, modules, functions)) in python_command_functions), None) + def record(target, value): if not isinstance(target, ast.Attribute): return @@ -12993,7 +13421,7 @@ def python_class_command_attribute_violation(tree, modules, functions): or attribute_name.split(".", 1)[0] in python_command_modules ): return - resolved = python_resolved_name(value, modules, functions) + resolved = launcher_value(value) if resolved in python_command_functions: attributes[attribute_name] = resolved @@ -13056,11 +13484,11 @@ def python_class_command_attribute_violation(tree, modules, functions): elif isinstance(statement, ast.Assign): for target in statement.targets: if isinstance(target, ast.Name): - resolved = python_resolved_name(statement.value, modules, functions) + resolved = launcher_value(statement.value) 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) + resolved = launcher_value(statement.value) if resolved in python_command_functions: attributes[f"{node.name}.{statement.target.id}"] = resolved @@ -13103,15 +13531,16 @@ def python_class_command_attribute_violation(tree, modules, functions): "Python conditional process launcher target is not allowed " f"on line {node.lineno}" ) - dotted = python_dotted_name(node.func) - paths = receiver_paths(node.func, set()) if isinstance(node.func, ast.Attribute) else set() - matched = next((path for path in paths if path in attributes), None) - if dotted in attributes or matched is not None: - dotted = matched or dotted - return ( - "Python process launcher stored on a class attribute is not allowed " - f"({dotted!r} resolves to {attributes[dotted]!r}) on line {node.lineno}" - ) + for reference in python_value_provenance(node.func, tree, parents): + dotted = python_dotted_name(reference) + paths = receiver_paths(reference, set()) if isinstance(reference, ast.Attribute) else set() + matched = next((path for path in paths if path in attributes), None) + if dotted in attributes or matched is not None: + dotted = matched or dotted + 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 @@ -18504,9 +18933,7 @@ def python_filesystem_mutation_violation(tree, parents): 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 - ): + elif python_open_callable_reference(node.func, tree, parents, open_aliases): unpacked_keyword = any(keyword.arg is None for keyword in node.keywords) if unpacked_keyword: mutation = True @@ -19537,9 +19964,9 @@ def python_reviewed_read_path(node, tree, parents, seen=None): assignments = [] scope = python_enclosing_scope(node, parents) for candidate in ast.walk(tree): - if isinstance(candidate, (ast.Assign, ast.NamedExpr)): + if isinstance(candidate, ast.Assign): targets = candidate.targets - elif isinstance(candidate, ast.AnnAssign): + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): targets = [candidate.target] else: continue @@ -22520,6 +22947,13 @@ def python_sensitive_read_violation(tree, parents): assigned_display_methods = {} if error_operand_classes: for candidate in ast.walk(tree): + if (isinstance(candidate, ast.Call) and len(candidate.args) >= 3 + and any(python_unshadowed_builtin_reference(reference, {"setattr"}, tree, parents) + for reference in python_value_provenance(candidate.func, tree, parents)) + and isinstance(candidate.args[1], ast.Constant) + and candidate.args[1].value in {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__"}): + for classdef in display_class_targets(candidate.args[0], set()): + assigned_display_methods.setdefault(classdef, []).append((candidate.args[1].value, candidate.args[2])) if not isinstance(candidate, (ast.Assign, ast.AnnAssign)) or candidate.value is None: continue targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] @@ -22670,6 +23104,10 @@ def python_sensitive_read_violation(tree, parents): if not python_reviewed_read_path(receiver, tree, parents): return f"Python unreviewed Path reader alias {alias!r} is not allowed" for node in ast.walk(tree): + if (isinstance(node, (ast.comprehension, ast.For, ast.AsyncFor)) + and error_operand_repr_sensitive(node.iter, set(), ("__iter__", "__next__", "__getitem__"))): + return ("Python custom iterator may expose sensitive protocol values " + f"on line {node.iter.lineno if isinstance(node, ast.comprehension) else node.lineno}") if isinstance(node, ast.Subscript) and python_dotted_name(node.value) == "os.environ": key = node.slice.value if isinstance(node.slice, ast.Constant) else None if isinstance(key, str) and credential_environment_name(key): @@ -22733,9 +23171,17 @@ def python_sensitive_read_violation(tree, parents): ) if isinstance(node, ast.Call): dotted = python_dotted_name(node.func) - if (isinstance(node.func, ast.Attribute) and node.func.attr == "relative_to" + callable_values = python_value_provenance(node.func, tree, parents) + if any(isinstance(reference, ast.Attribute) and reference.attr == "format_map" + and (error_operand_sensitive(reference.value) + or node.args and any(python_unshadowed_builtin_reference(receiver, {"str"}, tree, parents) + for receiver in python_value_provenance(reference.value, tree, parents)) + and error_operand_sensitive(node.args[0])) for reference in callable_values): + return ("Python sensitive format field may surface in an implicit formatting exception " + f"on line {node.lineno}") + if any(isinstance(reference, ast.Attribute) and reference.attr == "relative_to" and python_resolved_local_path_expression( - node.func.value, tree, parents, assignments_by_name)): + reference.value, tree, parents, assignments_by_name) for reference in callable_values): current = node handled = False while current in parents: @@ -22786,12 +23232,22 @@ def python_sensitive_read_violation(tree, parents): ("__iter__", "__next__", "__getitem__")) for argument in node.args)): return ("Python custom iterator may expose sensitive protocol values " f"on line {node.lineno}") + attribute_name_operands = [] if len(node.args) >= 2 and ( dotted in {"getattr", "delattr", "setattr", "builtins.getattr", "builtins.delattr", "builtins.setattr"} or isinstance(node.func, ast.Name) and any( python_assigned_callable_alias(node.func.id, lookup, tree) for lookup in ("getattr", "delattr", "setattr")) - ) and (error_operand_sensitive(node.args[1]) or error_operand_repr_sensitive(node.args[1], set())): + ): + attribute_name_operands.append(node.args[1]) + if any(isinstance(reference, ast.Attribute) + and reference.attr in {"__getattribute__", "__setattr__", "__delattr__"} + for reference in callable_values): + # Bound methods take the name first; class access takes an + # explicit receiver first. Unknown descriptors retain both. + attribute_name_operands.extend(node.args[:2]) + if any(error_operand_sensitive(operand) or error_operand_repr_sensitive(operand, set()) + for operand in attribute_name_operands): return ("Python sensitive attribute name may surface in an exception " f"message on line {node.lineno}") if error_operand_conversion_callable(node.func, set()) and any( @@ -23057,9 +23513,9 @@ def python_computed_callable_violation(tree): if any(isinstance(target, ast.Attribute) and target.attr == "breakpointhook" for target in hook_targets): return f"Python breakpoint hook rebinding is not reviewed on line {node.lineno}" - if isinstance(node, ast.Call) and python_unshadowed_builtin_reference( - node.func, {"breakpoint"}, tree, hook_parents - ): + if isinstance(node, ast.Call) and any(python_unshadowed_builtin_reference( + reference, {"breakpoint"}, tree, hook_parents + ) for reference in python_value_provenance(node.func, tree, hook_parents)): return f"Python indirect breakpoint execution is not reviewed on line {node.lineno}" if isinstance(node, ast.Call) and isinstance(node.func, ast.Call): return ( @@ -23138,11 +23594,13 @@ def python_callback_command_violation(tree, modules, functions): def python_open_aliases(tree): """Resolve aliases of builtin/io open/open_code for path classification.""" # Alias/return/container edges cannot create a reader without a source. - if not any((isinstance(node, ast.Name) and node.id == "open") + has_open_sources = any((isinstance(node, ast.Name) and node.id == "open") or (isinstance(node, ast.Attribute) and node.attr in {"open", "open_code"}) or (isinstance(node, ast.ImportFrom) and node.module in {"builtins", "io"} and any(item.name in {"open", "open_code"} for item in node.names)) - for node in ast.walk(tree)): + for node in ast.walk(tree)) + tree._issue79_has_open_sources = has_open_sources + if not has_open_sources: return set() aliases = set() assignments = [] @@ -23155,6 +23613,9 @@ def python_open_aliases(tree): 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)): + assignments.extend((target, target) for target in ast.walk(node.target) + if isinstance(target, ast.Name) and isinstance(target.ctx, ast.Store)) elif isinstance(node, ast.ImportFrom) and node.module in {"builtins", "io"}: for imported in node.names: if imported.name in {"open", "open_code"}: @@ -23175,8 +23636,10 @@ def python_open_aliases(tree): if isinstance(value, ast.Name): if value.id in aliases: return True - return any(open_reference(source, seen) for target, source in assignments + return (any(open_reference(source, seen) for target, source in assignments if isinstance(target, ast.Name) and target.id == value.id) + or any(open_reference(source, seen) for source in python_value_provenance(value, tree, parents) + if source is not value)) if isinstance(value, ast.Call): return any(open_reference(source, seen) for source in python_local_call_return_values(value, tree, parents)) @@ -23193,6 +23656,12 @@ def python_open_aliases(tree): if id(container) in seen: return [] seen.add(id(container)) + if isinstance(container, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)): + yielded = container.value if isinstance(container, ast.DictComp) else container.elt + return python_value_provenance(yielded, tree, parents) + if isinstance(container, ast.Call): + return [value for returned in python_local_call_return_values(container, tree, parents) + for value in selected_open_values(returned, key, seen)] if isinstance(container, ast.Name): return [value for target, source in assignments if isinstance(target, ast.Name) and target.id == container.id @@ -23227,6 +23696,14 @@ def python_open_aliases(tree): return aliases +def python_open_callable_reference(value, tree, parents, aliases): + if not getattr(tree, "_issue79_has_open_sources", True): + return False + return any(python_dotted_name(reference) in {"open", "builtins.open", "io.open", "io.open_code"} + or isinstance(reference, ast.Name) and reference.id in aliases + for reference in python_value_provenance(value, tree, parents)) + + python_path_filesystem_read_methods = { "open", "read_text", @@ -23369,10 +23846,7 @@ def python_open_read_violation(tree, parents): if not isinstance(node, ast.Call): continue dotted = python_dotted_name(node.func) - if not ( - dotted in {"open", "io.open_code"} - or (isinstance(node.func, ast.Name) and node.func.id in open_aliases) - ): + if not python_open_callable_reference(node.func, tree, parents, open_aliases): continue path = node.args[0] if node.args else next( ( diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index 4681a04e..f7a48d1b 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -857,3 +857,960 @@ PASS, full regression/compiler sweep, Go/hosted check, canary or live result is claimed. These results do not complete G01/G02 or prove arbitrary Python safety. No credentials, runner operations, Docker operation or GitHub write occurred. Rollback is a reviewed revert of this scoped local correction. + +## Nine P1 corrections from exact head `91e0d30` + +The sole writer verified clean starting head +`91e0d30aee73b2aa6ea86bba529d619ed8768c38` and read the entire immutable +[GitHub finding comment 6340344117](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6340344117) +with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6340344117 --jq .body`. +All nine findings are blocking P1; none is waived, deferred or rebutted. +Authorship follows the explicit Codex `gpt-6.1-sol`, medium override. +This section records local writer evidence, not independent or GitHub approval. + +The trusted harness loader, validators, selected methods and scanner changes +were inspected before loading them. Every reported witness, including the +destructive-looking launcher string, remained inert AST data. No specimen, +recipe, returned callable, debugger, encoder or custom protocol executed. +Only inspected trusted scanner/harness definitions ran, using the PATH/locale +environment and CPython 3.14.3 isolated invocation below. No GitHub write, +push, merge, live/canary operation or Docker operation ran; no credential +values were inspected or output. + +| Finding in immutable comment order | Correction and focused controls | +|---|---| +| Custom iterators consumed by comprehensions | Apply the existing local protocol-return check to comprehension and ordinary loop iterables, using the same `__iter__`/`__next__`/`__getitem__` provenance as constructors. `test_pr103_nine_comprehension_protocols` covers list/set/generator/dictionary comprehensions with sensitive and literal sources. | +| Display methods installed through `setattr` | Feed direct and aliased builtin setters with literal protocol names into the existing class-method index. `test_pr103_nine_setter_display` covers the three display protocols and the adjacent iterator protocol with safe-return counterparts. | +| Open provenance through comprehensions | Resolve yielded callable values and their scoped comprehension bindings, retaining the existing reader/path checks. `test_pr103_nine_open_comprehensions` rejects the exact private-path form and dictionary extraction, while the same reviewed repository reader and `len` remain accepted. Ordinary loop-held readers use the same graph. | +| Bound `relative_to` aliases | Resolve callable values before applying the existing local-path/handler proof. `test_pr103_nine_bound_relative_to` covers direct, container and helper-return aliases; literal relative paths and existing safe `ValueError` suppression remain accepted. | +| Dunder attribute operations | Inspect the sensitive name for direct/aliased `__getattribute__`, `__setattr__` and `__delattr__`. Unknown bound/unbound descriptor shapes conservatively retain both first operands; bound calls therefore cover argument zero. Direct, alias, bound and literal-name controls are retained. | +| Helper-returned launchers assigned to attributes | Resolve local returns and selected container alternatives when recording class/instance attribute assignments, including class-suite assignment. `test_pr103_nine_returned_attribute_launchers` rejects all three forms with `os.system` and accepts the identical `len` alternatives. | +| Breakpoint aliases | Resolve builtin identity through the shared value graph before the existing debugger rejection. `test_pr103_nine_breakpoint_aliases` covers direct aliases, containers, defaults and loop targets; a local function shadowing `breakpoint` remains accepted. | +| Reversible encoder taint | Propagate sensitive arguments at imported module/callable boundaries, including direct imports, callable aliases and module receiver aliases. `test_pr103_nine_encoder_taint` and the adjacent alias method cover `quote`, `quote_plus`, `shlex.quote` and chained encoding, literal inputs and lexical local shadowing. No encoder-specific allowlist is added. | +| Sensitive `format_map` fields | Resolve direct/bound formatter targets and reject a sensitive receiver that may become a missing-field diagnostic. `test_pr103_nine_format_map_fields` covers direct/aliased calls and the exact reversible-hex witness, retaining literal-field controls. | + +`python_value_provenance` is a finite worklist over expression/selection pairs. +It reuses the complete scoped binder inventory, follows assignment/default/ +loop/comprehension/local-return alternatives, and caches only on the current +AST. Known single literal container keys are selected precisely. Nested +extraction conservatively flattens reachable container values, bounding cycles +without an ever-growing index stack; it does not promise arbitrary nested-key +precision. `test_pr103_nine_adjacent_cycles` and the nested/loop method retain +safe alternatives and verify termination. Unknown binders stop lexical lookup. +The imported-callable predicate reuses the same graph and scoped inventory. + +The new argument propagation applies to imported module callables. Ordinary +methods retain their existing receiver policy: a safe dictionary lookup does +not copy its sensitive lookup key into the returned value. The experimental +join early-false return and subprocess environment/expanded-keyword exemption +were removed before final validation. The join branch is byte-identical to +the starting source, and there is no new process-consumer exemption or policy +allowlist. Existing local-format return proofs and all reader policy remain. + +All harness commands use this exact prefix: + +`env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py` + +Actual incremental results (intermediate working snapshots are not final-source +validation and are not attributed to the frozen hashes below): + +- Baseline prefix plus `-k test_pr103_nine`: nine methods, **36 failed unsafe + assertions in 0.714s**, exit 1; safe counterparts accepted. Packet blob was + `025be857c0a0a7c83ab8cb32ae21a073312fb5b8`. The initial RED harness blob is + `22f1dfe2adcccb604171e97aa7feced4a1550df8`, reconstructed from the immutable + starting harness by inserting only the unchanged first nine new methods + before the packet selector. The first correction passed those nine methods + in 4.395s, exit 0. +- The first combined command below ran 76 methods in **132.365s**, exit 1: + 75 passed, while the actual canonical package body was refused at body line + 373. A generic argument rule had crossed ordinary method lookup boundaries. + A join-branch experiment moved the refusal to actual body line 921: prefix + plus `-k test_package_containment_try_requires_direct_reachable_body + -k test_pr103_nine -k test_pr103_current_markdown` ran 11 methods in 15.393s, + exit 1. An experimental process-env rule still failed at the same line in + 18.740s. Neither failing result qualifies final source. +- Prefix plus `-k test_pr103_nine_adjacent`: three methods, nine failures in + **0.406s**, exit 1: six bound-dunder and three encoder-alias negatives. + Cycles and literal controls passed. After scoped module-call provenance and + bound operand correction, the preceding tiny compatibility command passed + 14 methods in 21.211s. Removing the process exemption retained a canonical + package PASS: one method in 16.413s. Restoring the original join branch and + adding module-alias/local-shadow controls passed five methods in 17.593s + with `-k test_pr103_nine_adjacent + -k test_package_containment_try_requires_direct_reachable_body + -k test_pr103_current_markdown`. +- Prefix plus `-k test_pr103_nine_adjacent_nested`: one method, two failed + assertions in **0.298s**, exit 1, for nested breakpoint extraction and a + loop-held reader. The shared finite flattening/reader propagation then + passed all 13 `-k test_pr103_nine` methods in **0.529s**, exit 0. +- Final combined focused command: **80 methods PASS in 27.175s**, exit 0, + including all prior 67 methods, the 13 new/adjacent methods, actual canonical + Markdown inspection/structural proof, containment and scanner-name controls. + No performance benchmark is claimed; the failed 132.365s invocation remains + recorded rather than being replaced by the later elapsed time. + +The exact final combined command is: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py \ + -k test_pr103 \ + -k test_launchers_bound_through_callable_defaults_stay_classified \ + -k test_launchers_nested_in_default_expressions_stay_classified \ + -k test_sensitive_operands_are_rejected_before_implicit_exception_messages \ + -k test_complex_conversions_are_rejected_before_implicit_exception_messages \ + -k test_conversion_callables_in_defaults_and_containers_are_tracked \ + -k test_sensitive_call_arguments_propagate_into_parameters \ + -k test_late_inserted_sensitive_keys_lose_keys_only_exemption \ + -k test_keys_with_sensitive_representations_are_rejected \ + -k test_sensitive_values_are_tainted_into_method_and_lambda_parameters \ + -k test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters \ + -k test_sensitive_mapping_return_survives_unrelated_nested_name_collision \ + -k test_path_filesystem_readers_require_reviewed_paths \ + -k test_resolved_local_paths_are_not_disclosed_to_output_sinks \ + -k test_resolved_paths_cross_expanded_keyword_helpers_and_formatting \ + -k test_container_and_conditional_class_aliases_preserve_return_taint \ + -k test_sensitive_taint_reaches_string_format_arguments \ + -k test_format_callable_aliases_preserve_sensitive_taint \ + -k test_user_defined_format_alias_returning_constant_is_safe \ + -k test_markdown_link_containment_guard_must_be_direct_and_reachable \ + -k test_package_containment_try_requires_direct_reachable_body \ + -k test_git_show_output_and_reader_option_paths_are_reviewed +``` + +Frozen packet/harness Git blobs are respectively +`0694b402878219e5969807715f71302f47bedc12` and +`766b270e7a12935ff75b42f38b2d926b5cf5a22c`. +The bounded baseline compatibility preflight passed in **13.766s**, exit 0: +206 called baseline helpers, seven retained scoped regex-receiver proofs, +zero new command-name collisions or lost regex proofs. Two preceding ancillary +probe attempts incorrectly asserted that unscoped receiver identities or full +call occurrences should number seven; they reported four and fifteen, +respectively, and exited 1. Those are probe-definition errors, not scanner +failures. The corrected identity is `(enclosing function, receiver AST)`. +The exact successful trusted preflight was: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B - <<'PY' +import ast, runpy, subprocess, time +started=time.monotonic() +h=runpy.run_path('scripts/evidence_packet/issue79_regression_test.py'); s=h['_scanner_namespace']() +old_source=subprocess.run(['/usr/bin/git','--no-replace-objects','-P','-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null','show','34968872cd1d1f832c9ee02dcf2119badc18bacc:docs/evidence/g01-recovery-packet.md'],env={'PATH':'/opt/homebrew/bin:/usr/bin:/bin','LC_ALL':'C','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_SYSTEM':'/dev/null','GIT_ATTR_NOSYSTEM':'1'},check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True).stdout +old=ast.parse(h['_scanner_module_source'](old_source)); current=ast.parse(h['_scanner_module_source'](h['PACKET_TEXT'])) +a=s['python_import_bindings'](old)[1]; b=s['python_import_bindings'](current)[1] +helpers={n.name for n in old.body if isinstance(n,ast.FunctionDef)} +called={n.func.id for n in ast.walk(current) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name)} +compared=helpers & called +collisions=sorted(name for name in compared if name in b and name not in a) +def proofs(tree): + parents={c:p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)} + result=set() + for node in ast.walk(tree): + if not (isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute) and node.func.attr=='group' and s['python_known_non_path_reader_call'](node,tree)): + continue + owner=node + while owner in parents and not isinstance(owner,ast.FunctionDef): owner=parents[owner] + result.add((owner.name if isinstance(owner,ast.FunctionDef) else None,ast.dump(node.func.value,include_attributes=False))) + return result +prior=proofs(old); now=proofs(current) +assert len(compared)==206,len(compared) +assert len(prior)==7,(len(prior),prior) +assert not collisions,collisions +assert prior <= now,prior-now +print(f'PASS aggregate preflight: {len(compared)} called baseline helpers; {len(prior)} retained scoped regex-receiver proofs; zero new collisions/lost proofs; {time.monotonic()-started:.3f}s') +PY +``` + +`git diff --check` passed on these inputs. The preflight loads the inspected +trusted harness and reads only the named immutable local Git blob; it executes +no baseline recipe. It is separate bounded compatibility evidence, not a full +scanner/compiler audit or another packet invocation. + +Only ledger edits are permitted during provisional independent delta/security +review. This scanner change invalidates reuse of the historical composed +261-command/86-body coverage as current coverage. The historical 720.470s +failure and later composed proof above remain accurately labeled; neither is +a fresh whole-process PASS of this candidate. + +The final packet invocation is gated on coordinator authorization after that +provisional review, using the unchanged prefix plus +`Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations`. +Its result will be added after completion. No full regression/compiler sweep, +Go/hosted matrix, canary/live result or final approval is claimed. Coordinator +owns independent attestation, GitHub review of the exact eventual head, hosted +quick checks, push and merge. These results do not complete G01/G02 or prove +arbitrary Python safety. Rollback is a reviewed revert of this three-file +correction while retaining the historical evidence. + +### Provisional HOLD on the first nine-finding candidate + +Independent contract and security review both settled HOLD on the exact first +frozen packet `0694b402878219e5969807715f71302f47bedc12` and harness +`766b270e7a12935ff75b42f38b2d926b5cf5a22c`. The four blocking families are +recorded in immutable [comment 6340802523](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6340802523), +read in full through `gh api repos/1XP-AI/gh-runnerd/issues/comments/6340802523 +--jq .body`. The 80-method pass did not establish approval. No long packet +scan ran on that first candidate. The writer held source unchanged until both +readers settled and the coordinator explicitly released the source barrier. + +| Confirmed blocking family | Shared correction and controls | +|---|---| +| Chained multi-target assignment | Reuse the complete inventory's unknown-Name binder, recognizing direct targets of the same `Assign` and following its common RHS. No inventory or unknown-binder policy is weakened. Chained breakpoint, setter-display and encoder aliases retain `len`/literal controls. | +| Class-member provenance | The same worklist resolves class identities through scoped class bindings, aliases, instances and inherited classes, then retains class-suite and later member assignments. Context-dependent recursive queries share the visited state and do not populate the root-query cache. Class breakpoint/encoder/path aliases retain safe counterparts; cyclic class members terminate. The writer's adjacent `launch = C.go` hypothesis was reproduced and fixed by resolving the final call's provenance before matching recorded launcher attributes. | +| Destructured iteration | Resolve yielded row values, then apply the existing literal front/tail/starred projection pattern to nested targets. A starred middle is represented as inert list AST data, retaining its later subscript selection. Known safe positions are not mixed with other row entries. Scoped row aliases, cyclic alternatives, loop/comprehension readers, nested/starred targets and breakpoint targets are covered. Resolved callable identity now feeds both existing readable-open and open-mutation checks, including direct extracted calls. No path/mode policy is widened. | +| Unbound `str.format_map` | A resolved unbound builtin `str` descriptor uses the first call argument as its format string; bound receivers retain the prior check. Direct calls, callable aliases and a `str` class alias retain literal-field controls. | + +All witnesses remain inert AST data under the same isolated prefix above. +Actual incremental evidence: + +- Prefix plus `-k test_pr103_nine_review`, before correction: four methods, + **23 failed assertions in 0.359s**, exit 1, on the first frozen packet and + RED harness `50dc52d534c953d2c289fbf2565c352205d63581`. Some grouped subtests + stopped at their first assertion, so this count does not claim every later + subcase ran. The class-launcher alias independently reproduced acceptance. +- The first shared correction left four assertions failing in 0.361s: the + class-launcher alias and direct comprehension-extracted reader calls. After + feeding resolved identity into those existing call checks, all four methods + passed in **0.346s**, exit 0. +- Subtests were separated so the chained encoder and both reader consumption + shapes execute independently. An isolated trusted-harness command loaded the + inspected scanner definitions from immutable original head `91e0d30` with + literal local `git show`, replaced only the loader's packet-text data in + memory, and selected the same four methods. It reproduced **27 failed + assertions in 0.384s**, exit 1. This is additional RED evidence against the + original scanner, not a rerun of the first frozen candidate or final-source + validation; no recipe or specimen was imported/compiled/executed. +- A further loop-held container control reproduced one failure in **0.252s** + with prefix plus `-k test_pr103_nine_review_destructured`. The loop edge had + dropped a requested subscript selection. Retaining that selection passed + all 17 `-k test_pr103_nine` methods in **0.572s**, exit 0, including the four + new review methods. Nested and starred safe-position controls remain green. +- The identical bounded preflight command above passed in **19.420s**, exit 0: + 206 called baseline helpers, seven scoped regex proofs, no new collisions or + lost proofs. This result applies to the second packet/harness inputs below. + +Second packet/harness inputs are respectively +`17ed29f05d99d294511c98dd5362c944dfddbe8e` and +`7e9d36156ec794d9fb98ce04f121cba0efb06d3f`. +The combined focused command is unchanged; it selects the four new methods +through `-k test_pr103`. Its final result and the next review/scan checkpoint +are recorded below after completion. Earlier frozen identities and actual +failed results remain historical evidence rather than current qualification. + +The first combined 84-method invocation completed in **25.817s**, exit 1: +83 methods passed and one new safe-cycle control failed. Static inspection +showed that its `.debug` member triggered the pre-existing logging-sink-holder +policy, independently of callable provenance. The control also referenced a +member before initializing it. It was corrected to an initialized neutral +`.step`/`.other` cycle; no scanner or sink policy changed. Prefix plus +`-k test_pr103_nine_review` then passed four methods in **0.380s**, exit 0. + +Final combined focused evidence is **84 methods PASS in 21.808s**, exit 0, +including all prior 80 and the four review-family methods. Final frozen inputs +are packet `17ed29f05d99d294511c98dd5362c944dfddbe8e` and harness +`bdb6a480516a950474983efbaa891033a586bb77`. The packet is unchanged from +the 19.420s preflight; the harness change since that preflight is solely the +cycle fixture above, and the trusted loader prefix is unchanged. No additional +preflight is inferred or claimed for that harness-only fixture change. +`git diff --check` passed. These hashes and results were sent to the +coordinator for renewed provisional independent delta/security review before +authorizing any full packet scan. Source remains frozen; only ledger edits +are permitted until the coordinator releases the next gate. + +### V2 HOLD: call-target expressions and supplied parameters + +Renewed independent contract/security review settled HOLD on packet +`17ed29f05d99d294511c98dd5362c944dfddbe8e` and harness +`bdb6a480516a950474983efbaa891033a586bb77`. The two blocking families are +recorded in immutable [comment 6341046534](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6341046534), +read completely with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6341046534 +--jq .body`. Contract reported ten inert probes in 0.337s; security reported +27 in 0.339s and 0.434s. The writer again held source until both readers +settled and the coordinator released the barrier. No full packet invocation +ran on V2, and its 84 passes remain writer evidence without review approval. + +The generic imported-callable argument branch now considers every AST call +target expression, including direct subscript extraction. Attribute receiver +handling remains specific to attributes. There is no list/dictionary encoder +spelling rule. Supplied values now use a cached AST-parameter source index +which reuses the existing function/lambda/method candidate and bound/static +method helpers. It records argument/selection edges before the value worklist +resolves them, avoiding recursive index construction. Positional, named, +keyword-only, default, starred positional/mapping, variadic and implicit +receiver sources remain scoped to their parameter AST identity. Call and member +cycles share the existing finite guard. A known single starred literal pack is +projected before selecting an element; unresolved multiple expansions retain +conservative alternatives rather than guessing a position. + +`test_pr103_nine_v2_extracted_encoder_calls` covers named list/dictionary +extraction and a selected `len` control. The supplied-class method covers +breakpoint, encoder, path and reader members through six invocation forms. +The implicit/variadic method covers direct and aliased bound receivers, +staticmethod/unbound calls, lambda, variadic positional/mapping values, +recursive-call cycles and unrelated scoped parameters. The argument-selection +method retains precise safe positions in mixed packs/maps and keyword-only +binding. Existing decorator and command-name policies are unchanged. + +Actual commands use the same isolated harness prefix: + +- Prefix plus `-k test_pr103_nine_v2` initially reported 29 failures in three + methods, **0.425s**, exit 1, on RED harness + `389a2001bfd404770c2cd6ee41f9e9faa9634381`. This included invalid safe + controls: direct literal-container syntax was already refused; `classmethod` + is an unreviewed decorator; and a named `run` alias/static call independently + triggers existing command classification. These are not 29 unsafe witnesses. + Fixtures were corrected to the exact named-box report, environment aliases, + neutral method names and accepted static/unbound forms without relaxing policy. +- The corrected command reproduced **33 unsafe failed assertions in 0.435s**, + exit 1, on V2 packet and RED harness + `3a7c044e11a35321e95ee2c6676bb77f96fd1288`; literal and scoped controls passed. + After the shared fix the same three methods passed in **0.521s**, exit 0. +- Prefix plus `-k test_pr103_nine + -k test_package_containment_try_requires_direct_reachable_body + -k test_pr103_current_markdown` passed **22 methods in 20.518s**, exit 0, + on intermediate packet `c0873f6347bab267355f591382c1c6e52aca69e5` and the + preceding harness. This includes both actual canonical bodies. +- A mixed starred-pack control then reproduced one false refusal in **0.255s**, + exit 1, using prefix plus `-k test_pr103_nine_v2_argument_selection`. After + retaining the known pack's selected position, prefix plus + `-k test_pr103_nine_v2` passed all four methods in **0.455s**, exit 0. + +The next candidate inputs are packet +`32851cf2de8879f133527021dfe88b6622744bd3` and harness +`0da42ccdf139c48395cc15d6959724fba8a05ed0`. +The unchanged combined command selects 88 focused methods; final result, +preflight and renewed review checkpoint follow after completion. No full +packet, compiler sweep, hosted/Go check, live result or approval is implied. + +Final V3 combined evidence: **88 methods PASS in 23.754s**, exit 0, including +all prior 84 methods and the four V2-boundary methods. The identical bounded +preflight passed in **13.339s**, exit 0, retaining 206 called baseline helpers +and seven scoped regex proofs with zero new collisions or lost proofs. +Packet/harness hashes were rechecked unchanged as +`32851cf2de8879f133527021dfe88b6622744bd3` / +`0da42ccdf139c48395cc15d6959724fba8a05ed0`, and `git diff --check` passed. +The writer sent this renewed frozen checkpoint to the coordinator and holds +the full packet invocation pending provisional independent delta/security +review. Only ledger edits follow this checkpoint until the next gate release. + +### V3 HOLD: consumed prefixes and container-selected local callees + +Both independent V3 rechecks completed with **HOLD**, despite their review +tasks completing successfully. This is not approval. Immutable +[comment 6341280010](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6341280010) +records the two blocking families on packet +`32851cf2de8879f133527021dfe88b6622744bd3` and harness +`0da42ccdf139c48395cc15d6959724fba8a05ed0`; the writer read the complete +comment with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6341280010 --jq .body`. +Contract recorded six tiny probes in 0.643s, security eight in 0.560s plus +four confirmations in 0.445s, all inert. Source stayed frozen until both +readers settled and the coordinator released this correction batch. + +The variadic binding now retains the full argument tuple with an explicit +tail projection after consumed fixed parameters. A known single literal pack +expands before that tail is selected. With unresolved spreads, only the prefix +definitely preceding a spread is consumed; remaining alternatives may occupy +the first vararg and remain conservative. The prior behavior which treated a +consumed fixed prefix as `targets[0]` is removed. Conservative retention of an +unsafe default despite a safe supplied override remains documented behavior; +the reviewer identified that over-refusal separately from the security blocker. + +The parameter index now resolves **all callee expression forms** through the +same bounded value graph. Local function/lambda identities and class method +definitions are data edges. Existing function/method binders seed the index; +it then publishes staged argument edges and adds each original +`(call, local callable, descriptor reference)` pair once to a fixed point. +There are finitely many such original nodes; generated argument containers +are not new callees. Value/module-callable caches are cleared between and +after stages so partial graphs cannot persist as final negative proofs. +Descriptor context retains explicit versus implicit receiver binding, using +the existing static/bound-method rules. No specimen or returned callable runs. + +Prefix plus `-k test_pr103_nine_v3` reproduced **nine failed unsafe assertions +in two methods, 0.386s**, exit 1, before correction, with packet above and RED +harness `c549e823511189ee72aac93aff3589f00b28310b`. It covers the exact two +variadic-prefix shapes, cyclic pack aliases, list/dictionary-held local helpers, +direct callable parameters and encoder consequences, with literal/`len` +controls. After correction the two methods passed in **0.270s**, exit 0. +Adding staged callback, bound/unbound receiver, lambda, cycle and two-spread +controls passed all three methods in **0.420s**, exit 0. These controls stay +within the changed call/binding graph; no unrelated Python audit ran. + +Prefix plus `-k test_pr103_nine +-k test_package_containment_try_requires_direct_reachable_body +-k test_pr103_current_markdown` passed **26 methods in 26.205s**, exit 0, +including both actual canonical body checks. Final combined evidence is +**91 methods PASS in 25.859s**, exit 0, including all prior 88 methods and the +three V3-boundary methods. The unchanged bounded preflight passed in +**15.843s**, exit 0: 206 called baseline helpers, seven retained scoped regex +proofs, zero new collisions or lost proofs. + +Final frozen packet/harness inputs are +`9deba18701b539654f7356c12de2f8157bdd9386` and +`1cbf7e12b22a9003f4dbeae80e43004e6f88020d`. +`git diff --check` passed. The writer reports this checkpoint for renewed +provisional independent delta/security review and holds the long packet +selector until authorization. No full packet invocation or final approval +occurred for the superseded V3 inputs. Only ledger edits follow this freeze. + +### V4 HOLD: mixed starred packs and supplied descriptor receivers + +Independent contract/security review settled **HOLD** on packet +`9deba18701b539654f7356c12de2f8157bdd9386` and harness +`1cbf7e12b22a9003f4dbeae80e43004e6f88020d`. Immutable +[comment 6341560059](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6341560059) +was read completely using `gh api repos/1XP-AI/gh-runnerd/issues/comments/6341560059 +--jq .body`. Security reported seven tiny probes in 0.475s plus three +confirmations in 0.014s; contract reported 12 contrasts in 0.533s, two probes +in 0.663s and three contrasts/index inspections in 0.452s. All specimens +remained inert AST data. Both readers settled and were released before the +coordinator released source freeze for these two corrections. + +The first exact unsafe body supplies `pack = (D,) if False else (*other,)`, +`other = (C,)`, `C.step = breakpoint`, `D.step = len`, to +`invoke("safe", *pack)`, whose `targets[0].step()` was accepted. A precise +plain expansion had discarded the nested-starred alternative. The shared +sequence worklist now recursively projects nested single-spread packs before +selecting their position. Every returned alternative is retained; an unresolved +or cyclic spread remains collection data beside precise alternatives. Flattening +has its own finite mode, and destructured projection no longer filters all +other alternatives merely because one literal alternative exists. Known safe +selected positions stay precise. Cyclic packs are covered without evaluating +their Python semantics. + +The second exact unsafe body calls `apply(E)` where +`apply(cls): cls.invoke(None, C)` and `E.invoke(self, target): target.step()`. +Its target incorrectly received `None` because a supplied class was assumed +to be an instance. The existing value graph now has a receiver-origin query +mode which retains constructor calls separately from class values through +aliases, selections and actual-argument edges. Constructor identities do not +become class identities in that mode. The staged binder publishes explicit +and implicit receiver offsets only for the corresponding proven origins; +genuinely mixed class/instance alternatives retain both. Unresolved receivers +wait for later stages rather than permanently publishing a guessed offset. +Static method/decorator and existing fallback alias policies are unchanged. +The finite binding key now includes the bound/unbound bit. + +Actual writer evidence uses the same isolated Python 3.14 harness prefix above: + +- Prefix plus `-k test_pr103_nine_v4` reproduced **five unsafe failures in two + methods, 0.462s**, exit 1, against frozen packet `9deba18701b539654f7356c12de2f8157bdd9386` + and RED harness `21f53bb6a50e2b4866d8d5528fab939af1f7093a`. The exact mixed + pack, reversed branch order, supplied class, aliased method and aliased class + failed; isolated nested/plain packs, direct class/instance contrasts and + corresponding `len` controls passed. After the shared fix, these two methods + passed in **0.364s**, exit 0. +- An expanded control draft then failed seven safe assertions in **0.649s**, + exit 1. Its shared prefix declared an inherited class, independently refused + by the existing unreviewed-base policy. Those were invalid safe controls, + not seven provenance regressions. That prefix and its two inheritance cases + were removed without changing scanner policy; no hash was captured for this + transient fixture draft. +- The same selector then passed **three methods in 0.509s**, exit 0, including + cyclic mixed packs, a safe selected element beside an unsafe sibling, + class/instance argument-offset controls, selected receiver containers and + genuinely mixed receiver alternatives. The unchanged combined focused + command above passed **94 methods in 28.999s**, exit 0: all prior 91 plus + these three methods, including actual canonical Markdown/containment checks. +- The identical bounded aggregate preflight above passed in **16.621s**, + exit 0: 206 called baseline helpers, seven retained scoped regex proofs, + zero new collisions or lost proofs. `git diff --check` passed. + +The renewed frozen inputs are packet +`3620ed694185a56661944f5c6f702d30c545bc3d` and harness +`b8b7e4cd030a2fac04255a7dcc413fe5f31a2333`. These results are writer evidence +for provisional independent delta/security re-review, not approval. No full +packet invocation ran on these or any earlier candidate in this correction +dispatch. Historical composed results remain historical and do not cover this +changed scanner. Only ledger edits are permitted after this checkpoint until +the coordinator releases a subsequent gate. Rollback remains a reviewed revert +of the three-file correction; no live action, credential values, push or merge +is part of this writer checkpoint. + +### V5 HOLD: nested-starred destructured rows + +The V5 contract delta received provisional approval after 16 inert contrasts +in 0.651s, but overall review remained **HOLD** because independent security +review reproduced a directly adjacent row projection loss. Immutable +[comment 6341801508](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6341801508) +was read in full with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6341801508 +--jq .body`. Its inputs were packet +`3620ed694185a56661944f5c6f702d30c545bc3d` and harness +`b8b7e4cd030a2fac04255a7dcc413fe5f31a2333`. Security reported ten contrasts +in 0.032s excluding loader, after 12 passing bounded probes in 0.477s including +loader. The writer held source until both reviewers settled and the +coordinator released the next correction barrier. + +The exact rejected expectation was `rows = [(D,) if False else (*other,)]`, +`other = (C,)`, followed by `for (target,) in rows: target.step()`, where +`C.step = breakpoint` and `D.step = len`. The scanner accepted this and the +reversed/isolated nested-starred forms, while plain unsafe tuples rejected +and the five `len` counterparts accepted. Destructured binding had forwarded +a starred container without selecting its elements. It now requests the +existing bounded `expand` projection before projecting literal target +positions, and uses the existing flattening selection for elements of +residual unresolved/cyclic spreads. This reuses the same sequence analysis +as starred actual arguments; no new spelling rule or policy exemption exists. + +Using the same isolated harness prefix: + +- Prefix plus `-k test_pr103_nine_v5` reproduced **six unsafe failed assertions + in one method, 0.319s**, exit 1, on the frozen packet above and RED harness + `1e0d902f366e241bd133d9f6fdd87b5f80e8b684`. Exact, reversed and isolated + nested-starred rows each failed for an ordinary loop and a comprehension; + plain unsafe rows rejected and literal/`len` controls passed. +- After the shared fix that method passed in **0.319s**, exit 0. Adding a + second method for nested patterns, starred targets, safe selected positions + beside unsafe siblings and cyclic rows passed **two methods in 0.309s**, + exit 0. +- The unchanged combined focused command passed **96 methods in 27.250s**, + exit 0: all prior 94 plus these two, including actual canonical body and + containment checks. The identical aggregate preflight passed in **16.309s**, + exit 0, retaining 206 baseline called helpers and seven scoped regex proofs, + with zero new collisions or lost proofs. `git diff --check` passed. + +The next frozen packet is `9ba77b69a26403e51195aaab7019f02540d23249` and +harness is `eaa38558429427fcf883d75d52a5cb6e6e5d950a`. The writer sends these +inputs for renewed provisional independent delta/security review. No full +packet scan, final approval or local commit occurred for V5 or these renewed +inputs; no historical composed result is promoted to current coverage. Source +stays frozen except for ledger edits until the coordinator releases a gate. +The previously recorded rollback and live-operation limits remain in force. + +### V6 provisional approval and authorized fresh whole-packet invocation + +Independent contract review `ctx_5b341685ad40` provisionally approved the exact +packet `9ba77b69a26403e51195aaab7019f02540d23249` and harness +`eaa38558429427fcf883d75d52a5cb6e6e5d950a` after 32 tiny inert contrasts +passed in 0.488s including loader. Independent security review +`ctx_1cff172f7e68` provisionally approved the same inputs after 16 contrasts +passed in 0.517s and six safe/cycle controls in 0.412s, each including loader. +Neither reviewer reported a blocker. These are bounded delta approvals, +not final commit-SHA approval. The coordinator authorized exactly one fresh +whole-packet selector with packet/harness frozen, and a three-file local commit +only if it passes. No full compiler/Go matrix or repeated focused suite is +authorized by this gate. + +The trusted loader and complete selector were inspected before invocation. +They extract and validate scanner definitions, then inspect the current packet's +shell commands and Python bodies as static data. Recipes/specimens are never +executed. The authorized command is: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations +``` + +The actual invocation receipt follows; no result is inferred in advance. + +The single invocation started after the UTC stamp **2026-10-22T13:26:17Z**; +completion was observed/stamped at **2026-10-22T13:42:21Z**. The latter is an +observation timestamp, not an invented exact process-exit instant. The harness +reported **one test, 949.810s, exit 1, FAILED (errors=1)**. It raised: + +```text +inspect_python_heredoc + -> python_filesystem_mutation_violation + -> python_filesystem_mutator_alias_violation + -> python_reviewed_path_reader + -> python_reviewed_read_path +AttributeError: 'NamedExpr' object has no attribute 'targets'. Did you mean: 'target'? +``` + +No terminal shell-command/Python-body counts were emitted because the selector +errored before its count assertions and success summary. Partial counts and +zero-violation coverage cannot be inferred. This is a fresh whole-process +**ERROR**, not PASS or the historical composed result. The complete diagnostic +was consumed from the existing invocation; its personal filesystem prefix is +omitted here. A coordinator-authorized narrow owned-process checkpoint during +the run showed 14:29 elapsed, 13:53.89 CPU time and 74,864 KiB RSS, supporting +ongoing computation; no signal, debugger, restart or second invocation occurred. + +Packet/harness blobs were reverified unchanged as +`9ba77b69a26403e51195aaab7019f02540d23249` / +`eaa38558429427fcf883d75d52a5cb6e6e5d950a`. The writer escalated the actual +error and preserves source freeze for review coordination. No correction, +rerun, local commit or final approval follows from this failed gate. Prior +96-method focused/preflight successes remain limited to their actual scope. + +### Narrow correction after the V6 whole-packet ERROR + +Immutable [comment 6342266361](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6342266361) +records the failed gate and pause, and was read completely with +`gh api repos/1XP-AI/gh-runnerd/issues/comments/6342266361 --jq .body`. +The original invocation did not retain a failing body index; it remains +**unknown**, with no rerun of old source to manufacture counts. A tiny inert +read-only reproduction of `root = Path("docs"); (marker := 0); root.read_text()` +raised the same `AttributeError` in 0.341s including trusted loader. The root +cause is structural: `python_reviewed_read_path` walked all candidates and +read `.targets` for both `Assign` and `NamedExpr`, before scope filtering. +The helper was unchanged from the starting head; the current scanner newly +reached it. Both readers acknowledged idle/no-read barriers +(`msg_6bfccf39fc19`, `msg_dc28d39666ec`) before the coordinator authorized +the narrow writer correction. + +The implementation changes only that structural discrimination: `Assign` +retains its target list, while `AnnAssign` and `NamedExpr` use their singular +`.target`. No exception handling, scope predicate, path allowlist or read +policy changed. The new regression covers unrelated and path-binding named +expressions beside ordinary, annotated and chained assignments, with an +existing reviewed repository file and an unreviewed synthetic absolute path. + +Actual commands/results using the isolated prefix above: + +- Prefix plus `-k test_pr103_named_expression_path_assignment_targets` on + packet `9ba77b69a26403e51195aaab7019f02540d23249` and initial RED harness + `e4dd43ada3091abdaa4cd1e558e8f16946f4969f` produced **eight AttributeError + subcases in one method, 0.390s**, exit 1. After the two-line fix, it produced + **four failed positive assertions in 0.252s**, exit 1: bare `docs` is already + unreviewed by the path policy. The fixture was corrected to + `docs/EXECUTION.md`, without changing that policy, and passed in **0.250s**, + exit 0. +- The corrected fixture was also run against reconstructed prior scanner data, + with the entire packet's Git blob hash asserted equal to + `9ba77b69a26403e51195aaab7019f02540d23249` before loading. It reproduced + **eight AttributeError subcases in 0.241s**, exit 1. This is a tiny + source-matched RED, not a whole-packet rerun. The exact trusted command was: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B - <<'PY' +import hashlib, runpy, sys, unittest +h=runpy.run_path('scripts/evidence_packet/issue79_regression_test.py') +current=h['PACKET_TEXT'] +fixed=''' assignments = [] + scope = python_enclosing_scope(node, parents) + for candidate in ast.walk(tree): + if isinstance(candidate, ast.Assign): + targets = candidate.targets + elif isinstance(candidate, (ast.AnnAssign, ast.NamedExpr)): + targets = [candidate.target]''' +prior=''' 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]''' +assert current.count(fixed)==1 +original=current.replace(fixed, prior) +encoded=original.encode(); blob=hashlib.sha1(b'blob '+str(len(encoded)).encode()+b'\0'+encoded).hexdigest() +assert blob=='9ba77b69a26403e51195aaab7019f02540d23249',blob +h['_scanner_namespace'].__globals__['PACKET_TEXT']=original +suite=unittest.TestSuite([h['Issue79RegressionTests']('test_pr103_named_expression_path_assignment_targets')]) +result=unittest.TextTestRunner(verbosity=2).run(suite) +print('source-matched RED packet:',blob) +sys.exit(not result.wasSuccessful()) +PY +``` + +The unchanged combined focused command passed **97 methods in 25.857s**, +exit 0, including all prior 96, the new regression and actual canonical body/ +containment checks. The identical bounded preflight passed in **14.620s**, +exit 0: 206 called baseline helpers, seven retained scoped regex proofs and +zero new collisions or lost proofs. `git diff --check` passed. + +The renewed frozen packet is `3bc6168854c5143dbf21de2c7829b2e7f551f472` and +harness is `0646d583629d6e6abdb472d7a1fc6fdd444cc10e`. These are submitted +for renewed provisional review while the 949.810s ERROR remains intact above. +No further whole-packet invocation or local commit is authorized until the +coordinator releases the next gate; no final approval is claimed. Source +remains frozen except for ledger edits. + +### V7 provisional approval and new-source whole-packet gate + +Both reviewers provisionally approved packet +`3bc6168854c5143dbf21de2c7829b2e7f551f472` and harness +`0646d583629d6e6abdb472d7a1fc6fdd444cc10e`. Contract passed ten tiny +contrasts in 0.471s; security passed eight in 0.447s including loader. Both +independently reversed the exact two-line correction and verified the prior +V6 packet hash. Each reviewer's initial nonunique/broad snippet proof assertion +failed before its specimen probes; corrected unique source proofs passed. +Those assertion setup failures were not scanner failures or passing probes. +These are provisional delta approvals, with final SHA verification still due. + +The coordinator authorized one fresh invocation of the exact whole-packet +selector recorded above on these new sources. The previous 949.810s ERROR is +preserved, and no old-source rerun, extra focused suite, compiler sweep or Go +matrix is implied. Packet/harness remain frozen. The actual receipt follows +after completion; only PASS authorizes the three-file local candidate commit. + +The authorized V7 invocation started after UTC stamp +**2026-10-22T13:48:15Z**; completion was observed/stamped at +**2026-10-22T14:03:54Z** (observation, not an invented exact exit instant). +The harness reported **one test, 927.980s, exit 1, FAILED (failures=1)**. +The sole recorded diagnostic was: + +```text +line 7448: Python heredoc assigns an unreviewed reflective helper result to alias 'sensitive_read_violation' on line 16622 +``` + +This run reached the terminal `matches` assertion with one violation, rather +than the preceding V6 `NamedExpr` error. The success-only summary follows that +assertion, so no command/body counts were emitted and none are claimed here. +It is a fresh whole-process **FAIL**, with both the older ERROR and historical +composed results preserved as their actual statuses. + +Packet `3bc6168854c5143dbf21de2c7829b2e7f551f472` and harness +`0646d583629d6e6abdb472d7a1fc6fdd444cc10e` were reverified unchanged. The +writer escalated this exact failure and holds source frozen for review +coordination. No automatic fix, rerun, local commit or final approval occurred. + +### V7 classification failure: diagnostic-only structural correction + +Immutable [comment 6342734965](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6342734965) +was read fully using `gh api repos/1XP-AI/gh-runnerd/issues/comments/6342734965 +--jq .body`. Both reviewers acknowledged paused/no-source-read barriers +(`msg_74848344dc55`, `msg_07f653d5702e`) before new regression edits. +Read-only inspection found that the unchanged reflective-return guard scans +every nested `getattr` inside a returned AST expression. The new iterator +diagnostic had nested metadata `getattr` calls inside its returned f-string, +so assigning the local scanner helper's textual result triggered that existing +conservative guard. A tiny metadata f-string, a direct reflected callable and +a conditional reflected callable all reproduced refusal in **0.328s** including +loader; the latter two remain refusal controls. + +An exploratory proposal to distinguish generic f-string consumption reproduced +two failed proposed positive assertions in **0.243s**, exit 1, with harness +`74929d8f3025c49cf43644a5f7e5d4bcd19afe57` and packet +`3bc6168854c5143dbf21de2c7829b2e7f551f472`. The coordinator rejected that +broader implementation choice in favor of diagnostic-only structural metadata. +**No generic f-string exemption or reflective guard change was implemented.** +The exploratory test was adapted to assert the existing conservative refusals; +its passing unsafe/sensitive-output controls remain intact. The 0.243s result +is exploratory evidence, not RED for the selected implementation requirement. + +The selected RED derives the actual custom-iterator `Return` AST from the +current scanner, renders that AST as inert source inside a tiny local helper, +and checks scanner self-compatibility. Prefix plus +`-k test_pr103_iterator_diagnostic_metadata_is_structural +-k test_pr103_reflective_helper_return_policy_stays_conservative` produced +**one compatibility failure in two methods, 0.331s**, exit 1, with packet above +and RED harness `9867315e01010c456e8eec462213cb44e6c24da2`. The conservative +reflection policy method passed. + +The only scanner change after V7 is the new iterator diagnostic's line +expression: use `node.iter.lineno` for `ast.comprehension`, otherwise +`node.lineno` for `For`/`AsyncFor`. These nodes come directly from parsing and +`ast.walk`; the comprehension iterator is a parsed expression with original +line metadata. Inert AST-shape controls cover ordinary/async loops and list, +set, generator and dictionary comprehensions. Exact refusal-location checks +cover ordinary/async loops and a multiline comprehension; their harmless +iterator controls still accept. The bounded inspection of this changed return +finds no metadata getter after correction. The reflective escape/sensitive +formatting guards are unchanged, with no scanner-name, source or marker rule. + +Prefix plus the two diagnostic/policy selectors and +`-k test_pr103_nine_comprehension_protocols` passed **three methods in 0.348s**, +exit 0. After adding the exact location controls, the two diagnostic/policy +selectors passed in **0.358s**, exit 0. The unchanged combined command passed +**99 methods in 28.214s**, exit 0: all prior 97 plus the two selected methods, +including actual canonical bodies/containment. The unchanged aggregate +preflight passed in **16.395s**, exit 0: 206 called baseline helpers, seven +retained scoped regex proofs, zero new collisions or lost proofs. +`git diff --check` passed. + +The renewed frozen inputs are packet +`136ecdacda22736a34d0ea5a566f2d58d6cd0bb5` and harness +`643f1368237dbbd9ce6d9754333e13b6628da6fe`. These are submitted for renewed +provisional review. Both earlier fresh whole-packet failures retain their +actual statuses and absent emitted counts. No further full invocation, local +commit or final approval is inferred; packet/harness stay frozen pending the +next coordinator gate. + +### V8 provisional approval and authorized current-source full scan + +Both independent reviewers provisionally approved exact packet +`136ecdacda22736a34d0ea5a566f2d58d6cd0bb5` and harness +`643f1368237dbbd9ce6d9754333e13b6628da6fe`. Contract reversed the single +diagnostic metadata expression and proved every other packet byte identical +to V7. Security also reversed the two test additions and proved V7 harness +identity; bounded self-compatibility, eight location/taint contrasts, two +reflection controls and identity checks passed in 0.410s including loader. +No generic exemption was added. These approvals are provisional pending the +actual full scan and final commit-SHA verification. + +The coordinator authorized exactly one fresh current-source invocation of +the same isolated whole-packet selector above, because this one-expression +correction changes V7's failed source. Packet/harness stay frozen; no old-source +rerun, extra 99-method suite, compiler sweep, Go matrix or live action is part +of this gate. Only an actual PASS authorizes the three-file local candidate +commit. The V6 ERROR and V7 FAIL remain unchanged as historical outcomes. +The actual invocation receipt follows after completion. + +The single authorized V8 invocation started after UTC stamp +**2026-10-22T14:11:37Z**; completion was observed/stamped at +**2026-10-22T14:31:03Z** (observation, not an invented exact process-exit time). +The harness reported **one test, 1148.552s, exit 1, FAILED (failures=1)**, +with this sole diagnostic: + +```text +line 7448: Python heredoc contains an unresolved command-capable call 'source_value' on line 4328 +``` + +The earlier reflective diagnostic is absent from this run's single violation. +The selector reached its final `matches` assertion but did not reach the +success-only command/body summary, so no terminal counts were emitted or +claimed. This is another fresh whole-process **FAIL**; the V6 ERROR and V7 +FAIL remain preserved, and no composed result is promoted to current coverage. +During the run, an authorized exact-owned-process checkpoint showed 15:01 +elapsed, 13:45.63 CPU time and 109,280 KiB RSS; no signal, debugger, restart or +additional invocation was used. + +Packet `136ecdacda22736a34d0ea5a566f2d58d6cd0bb5` and harness +`643f1368237dbbd9ce6d9754333e13b6628da6fe` were reverified unchanged. The +writer escalated the actual failure and holds source frozen for coordination. +No automatic correction, further full scan, local commit or final approval +has occurred at this failed gate. + +### V9 bounded bookkeeping rename and expanded compatibility inventory + +Immutable [comment 6343413610](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6343413610) +was read completely with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6343413610 +--jq .body`. Read-only causal inspection mapped body line 4328 to an existing +nested compiler-proof helper call `source_value(value)`. The new projection +helper used `pattern, source_value = pending_pairs.pop()`. The unchanged +conservative command-binding classifier marks unresolved destructured names +as potential aliases across the scanned body, so that new bookkeeping spelling +poisoned the existing nested callable. The prior 206-name preflight covered +called original top-level helpers and correctly passed that limited inventory; +it did not include nested helper `source_value`. + +A bounded extension over the same baseline AST found **328 called original +helper names including nested definitions**, with exactly one newly introduced +command-name collision: `source_value`. Two tiny inert contrasts reproduced +the nested-call refusal and acceptance after changing only the unrelated +destructured local spelling to `projection_source_value`; inventory and both +contrasts took **6.490s** including loader. Both readers acknowledged paused +no-read barriers (`msg_67c893223d95`, `msg_b17b0fee8ff5`) before the coordinator +authorized the rename and durable expanded preflight. + +The new regression derives the actual projection-helper AST as inert source +beside the baseline-shaped nested callable, and preserves a separate refusal +control for the original unresolved-destructuring shape. Prefix plus +`-k test_pr103_projection_bookkeeping_preserves_nested_helper_calls` failed +one assertion in **0.412s**, exit 1, on packet +`136ecdacda22736a34d0ea5a566f2d58d6cd0bb5` and RED harness +`51a5f50d01253909e7a61b72dbc0014ef6fbd66d`. Renaming only the new projection +local and its references to `projection_source_value` passed the same method +in **0.574s**, exit 0. No classifier, allowlist, existing helper or scope +semantics changed. + +The unchanged combined focused command passed **100 methods in 26.421s**, +exit 0, retaining all prior 99 plus the new regression and actual canonical +body/containment checks. The expanded aggregate preflight passed in +**14.303s**, exit 0: 206 root helpers retained, 328 called original helper +names including nested definitions checked, seven retained scoped regex +proofs, zero new collisions or lost proofs. Its exact durable command is: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B - <<'PY' +import ast, runpy, subprocess, time +started=time.monotonic() +h=runpy.run_path('scripts/evidence_packet/issue79_regression_test.py'); s=h['_scanner_namespace']() +old_source=subprocess.run(['/usr/bin/git','--no-replace-objects','-P','-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null','show','34968872cd1d1f832c9ee02dcf2119badc18bacc:docs/evidence/g01-recovery-packet.md'],env={'PATH':'/opt/homebrew/bin:/usr/bin:/bin','LC_ALL':'C','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_SYSTEM':'/dev/null','GIT_ATTR_NOSYSTEM':'1'},check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True).stdout +old=ast.parse(h['_scanner_module_source'](old_source)); current=ast.parse(h['_scanner_module_source'](h['PACKET_TEXT'])) +a=s['python_import_bindings'](old)[1]; b=s['python_import_bindings'](current)[1] +root_helpers={n.name for n in old.body if isinstance(n,ast.FunctionDef)} +all_helpers={n.name for n in ast.walk(old) if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef))} +called={n.func.id for n in ast.walk(current) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name)} +compared=root_helpers & called; all_compared=all_helpers & called +collisions=sorted(name for name in all_compared if name in b and name not in a) +def proofs(tree): + parents={c:p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)} + result=set() + for node in ast.walk(tree): + if not (isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute) and node.func.attr=='group' and s['python_known_non_path_reader_call'](node,tree)): + continue + owner=node + while owner in parents and not isinstance(owner,ast.FunctionDef): owner=parents[owner] + result.add((owner.name if isinstance(owner,ast.FunctionDef) else None,ast.dump(node.func.value,include_attributes=False))) + return result +prior=proofs(old); now=proofs(current) +assert len(compared)==206,len(compared) +assert len(all_compared)==328,len(all_compared) +assert len(prior)==7,(len(prior),prior) +assert not collisions,collisions +assert prior <= now,prior-now +print(f'PASS expanded aggregate preflight: {len(compared)} root helpers; {len(all_compared)} called original helpers including nested; {len(prior)} retained scoped regex-receiver proofs; zero new collisions/lost proofs; {time.monotonic()-started:.3f}s') +PY +``` + +The renewed frozen inputs are packet +`358ccced22db3ed1befcd8c9947f1d44cfc86942` and harness +`51a5f50d01253909e7a61b72dbc0014ef6fbd66d`. `git diff --check` passed. +These are submitted for renewed provisional review; no new whole-packet +invocation or local commit follows without the next coordinator gate. All +three preceding fresh ERROR/FAIL outcomes remain explicit, with no invented +terminal counts or final approval. Packet/harness remain frozen. + +### V9 provisional approval and authorized fresh full scan + +Contract review `msg_990c6d397b62` and security review `msg_e51f133fb85a` +provisionally approved exact packet +`358ccced22db3ed1befcd8c9947f1d44cfc86942` and harness +`51a5f50d01253909e7a61b72dbc0014ef6fbd66d`. Both reversed the six new local +identifier occurrences to prove exact V8 packet identity; security also +removed the single added method to prove exact V8 harness identity. Security's +tiny identity/self-compatibility, conservative-collision and nested-star +contrasts passed in 0.437s including loader. Contract performed read-only +review with no extra tests. These are provisional source approvals, not final +commit verification. + +The coordinator authorized one fresh isolated full-packet selector on these +exact V9 sources using the established command. Packet/harness stay frozen, +with ledger-only edits during the invocation. No extra focused suite, +compiler sweep, Go matrix, recipe, specimen or live execution is part of the +gate. Prior three ERROR/FAIL receipts remain unchanged. An actual PASS is +required before the single three-file local candidate commit, followed by +final exact-SHA review. The actual invocation receipt follows. + +### V9 fresh whole-packet PASS and local candidate handoff + +The authorized single current-source invocation used exactly: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations +``` + +The UTC stamp immediately preceding launch was +**2026-10-22T14:40:19Z**; completion was observed/stamped at +**2026-10-22T14:56:08Z**. The latter is an observation timestamp, not a claimed +exact process-exit instant. The harness completed **one test in 933.819s, +exit 0, OK**, and printed the actual complete-scan receipt: + +```text +current packet static scan: 261 shell commands, 86 Python heredoc bodies, zero violations +``` + +Packet `358ccced22db3ed1befcd8c9947f1d44cfc86942` and harness +`51a5f50d01253909e7a61b72dbc0014ef6fbd66d` were reverified unchanged before +and after this invocation. This is a fresh whole-process PASS on these exact +sources, rather than inherited composed coverage. V6's 949.810s ERROR, V7's +927.980s FAIL and V8's 1148.552s FAIL remain their actual failed outcomes +above, with no retrospective terminal counts invented. The current PASS does +not relabel those failed attempts. The 100 focused passes and expanded +206-root/328-including-nested helper plus seven-regex-proof preflight remain +separate, source-matched evidence. + +At the coordinator's requested natural checkpoint, the existing owned test +session still reported running. One authorized narrow exact-owned-process +read showed 11:12 elapsed, 11:02.58 CPU time and 135,024 KiB RSS. No signal, +debugger, environment inspection, restart or duplicate invocation occurred. +The scan consumed only inspected trusted scanner/harness code; all packet +commands, specimen strings and Python bodies stayed static data. + +A bounded introduced-delta check compared only these three owned files +against starting head `91e0d30aee73b2aa6ea86bba529d619ed8768c38`, using +isolated Git configuration, hooks/fsmonitor disabled and `diff --no-ext-diff +--no-textconv --unified=0`. Added lines had **zero matches** for personal home +path patterns, GitHub token-value patterns, private-key blocks and AWS access +key IDs; no sensitive match values were output. The changed-file set was +exactly the three owned files. This bounded pattern check is not a claim of +an exhaustive secret audit. `git diff --check` is required immediately before +staging, and packet/harness blobs must still equal the frozen values above. + +Under the already granted PASS-only authorization, the writer will stage only +the packet, regression harness and this ledger and create one local candidate +commit. Its immutable SHA, three blob hashes, clean status and diff stat are +reported to the coordinator for both reviewers' final exact-SHA/ledger +verification. No final approval, push, GitHub write, merge, hosted/Go matrix, +canary, live operation, recipe execution or credential-value inspection is +claimed here. Rollback remains a reviewed revert of this three-file change. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 6bd8f562..51a5f50d 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -7674,6 +7674,408 @@ def test_pr103_current_scanner_method_helper_is_not_launcher_alias(self) -> None _modules, functions, _unresolved = self.scanner["python_import_bindings"](module) # type: ignore[operator] self.assertTrue("methods" not in functions, "local methods helper acquired launcher provenance") + def test_pr103_nine_comprehension_protocols(self) -> None: + for expression in ('[item for item in K()]', '{item for item in K()}', + '(item for item in K())', '{item: item for item in K()}'): + for source in ('os.environ.items()', '[("safe", "value")]'): + with self.subTest(expression=expression, source=source): + result = self.inspect('import os\nclass K:\n __iter__ = lambda self: iter(' + source + ')\nprint(' + expression + ')\n') + self.assertEqual(result is None, source.startswith('[')) + + def test_pr103_nine_setter_display(self) -> None: + for setter in ('setattr', 'install'): + for method in ('__str__', '__repr__', '__format__', '__iter__'): + for value in ('str(os.environ)', '"safe"'): + with self.subTest(setter=setter, method=method, value=value): + sink = 'print(list(K()))' if method == '__iter__' else 'print(K())' + result = self.inspect('import os\ndef leak(self, *args):\n return ' + value + '\nclass K: pass\ninstall = setattr\n' + setter + '(K, "' + method + '", leak)\n' + sink + '\n') + self.assertEqual(result is None, value == '"safe"') + + def test_pr103_nine_open_comprehensions(self) -> None: + for expression, selection in (('[candidate for candidate in [open]]', 'readers[0]'), + ('{"x": candidate for candidate in [open]}', 'readers["x"]')): + for path in ('/tmp/maintainer-private', 'docs/backlog.json'): + with self.subTest(expression=expression, path=path): + result = self.inspect('readers = ' + expression + '\nreader = ' + selection + '\nprint(reader("' + path + '").read())\n') + self.assertEqual(result is None, path.startswith('docs/')) + self.assertIsNone(self.inspect('readers = [candidate for candidate in [len]]\nprint(readers[0]("safe"))\n')) + + def test_pr103_nine_bound_relative_to(self) -> None: + for alias in ('method = Path.cwd().relative_to', + 'box = [Path.cwd().relative_to]\nmethod = box[0]', + 'def choose():\n return Path.cwd().relative_to\nmethod = choose()'): + with self.subTest(alias=alias): + self.assertIsNotNone(self.inspect('from pathlib import Path\n' + alias + '\nmethod("/definitely-no")\n')) + self.assertIsNone(self.inspect('from pathlib import Path\n' + alias + '\ntry:\n method("/definitely-no")\nexcept ValueError:\n pass\n')) + self.assertIsNone(self.inspect('from pathlib import Path\nmethod = Path("relative").relative_to\nmethod("root")\n')) + + def test_pr103_nine_dunder_attribute_names(self) -> None: + for method in ('__getattribute__', '__setattr__', '__delattr__'): + for alias in (False, True): + for name in ('str(os.environ)', '"safe"'): + with self.subTest(method=method, alias=alias, name=name): + extra = ', None' if method == '__setattr__' else '' + target = 'lookup' if alias else 'object.' + method + prefix = 'lookup = object.' + method + '\n' if alias else '' + result = self.inspect('import os\n' + prefix + target + '(object(), ' + name + extra + ')\n') + self.assertEqual(result is None, name == '"safe"') + + def test_pr103_nine_returned_attribute_launchers(self) -> None: + for assignment in ('C.go = choose()', 'class D:\n go = choose()\nC = D', + 'box = [choose()]\nC.go = box[0]'): + for value in ('os.system', 'len'): + with self.subTest(assignment=assignment, value=value): + result = self.inspect('import os\ndef choose():\n return ' + value + '\nclass C: pass\n' + assignment + '\nC.go("rm -rf /tmp/maintainer-owned")\n') + self.assertEqual(result is None, value == 'len') + + def test_pr103_nine_breakpoint_aliases(self) -> None: + for body in ('debug = breakpoint\ndebug()', + 'box = [breakpoint]\ndebug = box[0]\ndebug()', + 'def f(debug=breakpoint):\n debug()\nf()', + 'for debug in [breakpoint]:\n debug()'): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect(body + '\n')) + self.assertIsNone(self.inspect('def breakpoint():\n return "safe"\ndebug = breakpoint\nprint(debug())\n')) + + def test_pr103_nine_encoder_taint(self) -> None: + for expression in ('urllib.parse.quote(str(env))', 'shlex.quote(str(env))', + 'urllib.parse.quote_plus(str(env))', 'urllib.parse.quote(str(env)).encode().hex()'): + for value in ('os.environ', '"safe"'): + with self.subTest(expression=expression, value=value): + result = self.inspect('import os, urllib.parse, shlex\nenv = ' + value + '\nprint(' + expression + ')\n') + self.assertEqual(result is None, value == '"safe"') + + def test_pr103_nine_format_map_fields(self) -> None: + for call in ('("{" + key + "}").format_map({})', + 'method = ("{" + key + "}").format_map\nmethod({})'): + for value in ('str(env).encode().hex()', '"safe"'): + with self.subTest(call=call, value=value): + result = self.inspect('import os\nenv = os.environ\nkey = ' + value + '\n' + call + '\n') + self.assertEqual(result is None, value == '"safe"') + + def test_pr103_nine_adjacent_bound_dunders(self) -> None: + for method in ('__getattribute__', '__setattr__', '__delattr__'): + for alias in (False, True): + for value in ('str(os.environ)', '"safe"'): + with self.subTest(method=method, alias=alias, value=value): + prefix = 'lookup = object().' + method + '\n' if alias else '' + target = 'lookup' if alias else 'object().' + method + extra = ', None' if method == '__setattr__' else '' + result = self.inspect('import os\n' + prefix + target + '(' + value + extra + ')\n') + self.assertEqual(result is None, value == '"safe"') + + def test_pr103_nine_adjacent_encoder_aliases(self) -> None: + for prefix in ('import urllib.parse\nencode = urllib.parse.quote', + 'from urllib.parse import quote as encode', + 'import urllib.parse\ntools = urllib.parse\nencode = tools.quote', + 'import shlex\nencode = shlex.quote'): + for value in ('os.environ', '"safe"'): + with self.subTest(prefix=prefix, value=value): + result = self.inspect('import os\n' + prefix + '\nenv = ' + value + '\nprint(encode(str(env)))\n') + self.assertEqual(result is None, value == '"safe"') + self.assertIsNone(self.inspect('from urllib.parse import quote as encode\ndef f():\n def encode(value):\n return "safe"\n return encode(str(os.environ))\nimport os\nprint(f())\n')) + + def test_pr103_nine_adjacent_cycles(self) -> None: + for source in ('breakpoint', 'len'): + code = 'a = b\nb = a\nb = ' + source + '\na("safe")\n' + with self.subTest(source=source): + self.assertEqual(self.inspect(code) is None, source == 'len') + self.assertIsNone(self.inspect('def outer():\n debug = len\n return debug("safe")\nprint(outer())\n')) + + def test_pr103_nine_adjacent_nested_and_loop_values(self) -> None: + for source in ('breakpoint', 'len'): + with self.subTest(source=source): + result = self.inspect('box = [[' + source + ']]\ndebug = box[0][0]\ndebug("safe")\n') + self.assertEqual(result is None, source == 'len') + for source in ('open', 'len'): + with self.subTest(reader=source): + result = self.inspect('for reader in [' + source + ']:\n print(reader("/tmp/maintainer-private"))\n') + self.assertEqual(result is None, source == 'len') + + def test_pr103_nine_review_chained_assignments(self) -> None: + for source in ('breakpoint', 'len'): + with self.subTest(source=source): + self.assertEqual(self.inspect('debug = other = ' + source + '\ndebug("safe")\n') is None, source == 'len') + for value in ('str(os.environ)', '"safe"'): + for code in ('import os\ndef leak(self):\n return ' + value + '\nclass K: pass\ninstall = other = setattr\ninstall(K, "__str__", leak)\nprint(K())\n', + 'import os, urllib.parse\nenv = ' + value + '\nencode = other = urllib.parse.quote\nprint(encode(str(env)))\n'): + with self.subTest(value=value, code=code): + self.assertEqual(self.inspect(code) is None, value == '"safe"') + + def test_pr103_nine_review_class_members(self) -> None: + for receiver in ('C', 'Alias', 'C()', 'instance'): + for source in ('breakpoint', 'len'): + with self.subTest(receiver=receiver, source=source): + self.assertEqual(self.inspect('class C:\n debug = ' + source + '\nAlias = C\ninstance = C()\n' + receiver + '.debug("safe")\n') is None, source == 'len') + for value in ('os.environ', '"safe"'): + with self.subTest(receiver=receiver, encoder=value): + self.assertEqual(self.inspect('import os, urllib.parse\nclass C:\n encode = urllib.parse.quote\nAlias = C\ninstance = C()\nenv = ' + value + '\nprint(' + receiver + '.encode(str(env)))\n') is None, value == '"safe"') + for path in ('Path.cwd()', 'Path("relative")'): + with self.subTest(receiver=receiver, path=path): + self.assertEqual(self.inspect('from pathlib import Path\nclass C:\n method = ' + path + '.relative_to\nAlias = C\ninstance = C()\n' + receiver + '.method("/definitely-no")\n') is None, path == 'Path("relative")') + for source in ('os.system', 'len'): + with self.subTest(launcher=source): + self.assertEqual(self.inspect('import os\ndef choose():\n return ' + source + '\nclass C: pass\nC.go = choose()\nlaunch = C.go\nlaunch("synthetic-command")\n') is None, source == 'len') + for source in ('breakpoint', 'len'): + with self.subTest(cycle=source): + self.assertEqual(self.inspect('class C: pass\nC.other = ' + source + '\nC.step = C.other\nC.other = C.step\nC.step("safe")\n') is None, source == 'len') + + def test_pr103_nine_review_destructured_iteration(self) -> None: + for pattern, row in (('reader, label', '(SOURCE, "x")'), + ('(reader,), label', '((SOURCE,), "x")'), + ('*labels, reader', '("x", SOURCE)')): + for source in ('open', 'len'): + for code in ('for ' + pattern + ' in [' + row.replace('SOURCE', source) + ']:\n print(reader("/tmp/synthetic-private").read())\n', + 'readers = [reader for ' + pattern + ' in [' + row.replace('SOURCE', source) + ']]\nprint(readers[0]("/tmp/synthetic-private").read())\n'): + with self.subTest(pattern=pattern, source=source, code=code): + self.assertEqual(self.inspect(code) is None, source == 'len') + self.assertIsNone(self.inspect('for reader, other in [(len, open)]:\n print(reader("safe"))\n')) + for source in ('open', 'len'): + with self.subTest(rows_cycle=source): + self.assertEqual(self.inspect('rows = other\nother = rows\nother = [(' + source + ', "x")]\nfor reader, label in rows:\n print(reader("/tmp/synthetic-private").read())\n') is None, source == 'len') + for source in ('breakpoint', 'len'): + with self.subTest(debug=source): + self.assertEqual(self.inspect('for debug, label in [(' + source + ', "x")]:\n debug("safe")\n') is None, source == 'len') + for target, rows in (('readers', '[[SOURCE]]'), ('*readers, label', '[(SOURCE, "x")]')): + for source in ('open', 'len'): + with self.subTest(target=target, source=source): + self.assertEqual(self.inspect('for ' + target + ' in ' + rows.replace('SOURCE', source) + ':\n print(readers[0]("/tmp/synthetic-private").read())\n') is None, source == 'len') + + def test_pr103_nine_review_unbound_format_map(self) -> None: + for target in ('str', 'Text'): + for call in (target + '.format_map("{" + key + "}", {})', + 'formatting = ' + target + '.format_map\nformatting("{" + key + "}", {})'): + for value in ('str(env).encode().hex()', '"safe"'): + with self.subTest(call=call, value=value): + result = self.inspect('import os\nText = str\nenv = os.environ\nkey = ' + value + '\n' + call + '\n') + self.assertEqual(result is None, value == '"safe"') + + def test_pr103_nine_v2_extracted_encoder_calls(self) -> None: + for container, expression in (('[urllib.parse.quote]', 'box[0]'), ('{"x": urllib.parse.quote}', 'box["x"]')): + for value in ('os.environ', '"safe"'): + with self.subTest(expression=expression, value=value): + self.assertEqual(self.inspect('import os, urllib.parse\nenv = ' + value + '\nbox = ' + container + '\nprint(' + expression + '(str(env)))\n') is None, value == '"safe"') + self.assertIsNone(self.inspect('import os, urllib.parse\nenv = os.environ\nbox = {"safe": len, "encode": urllib.parse.quote}\nprint(box["safe"](str(env)))\n')) + + def test_pr103_nine_v2_supplied_class_parameters(self) -> None: + for member, unsafe, safe, operation in ( + ('step', 'breakpoint', 'len', 'target.step()'), + ('encode', 'urllib.parse.quote', 'urllib.parse.quote', 'print(target.encode(str(VALUE)))'), + ('method', 'Path.cwd().relative_to', 'Path("relative").relative_to', 'target.method("/definitely-no")'), + ('reader', 'open', 'len', 'print(target.reader("/tmp/synthetic-private").read())'), + ): + for invocation in ('invoke(C)', 'invoke(target=C)', 'invoke(*(C,))', + 'invoke(**{"target": C})', 'pack = (C,)\ninvoke(*pack)', + 'options = {"target": C}\ninvoke(**options)'): + for sensitive in (True, False): + with self.subTest(member=member, invocation=invocation, sensitive=sensitive): + body = operation.replace('VALUE', 'env') + code = ('import os, urllib.parse\nfrom pathlib import Path\nenv = ' + ('os.environ' if sensitive else '"safe"') + '\nclass C:\n ' + member + ' = ' + (unsafe if sensitive else safe) + + '\ndef invoke(target):\n ' + body + '\n' + invocation + '\n') + self.assertEqual(self.inspect(code) is None, not sensitive) + + def test_pr103_nine_v2_implicit_and_variadic_receivers(self) -> None: + for source in ('breakpoint', 'len'): + for body in ( + 'class C:\n step = SOURCE\n def run(self):\n self.step()\nC().run()', + 'class C:\n step = SOURCE\n def invoke(self):\n self.step()\nfn = C().invoke\nfn()', + 'class C:\n step = SOURCE\n @staticmethod\n def invoke(target):\n target.step()\nC.invoke(C)', + 'class C: step = SOURCE\ndef invoke(*targets):\n targets[0].step()\ninvoke(C)', + 'class C: step = SOURCE\ndef invoke(**targets):\n targets["target"].step()\ninvoke(target=C)', + 'class C: step = SOURCE\ninvoke = lambda target: target.step()\ninvoke(C)', + 'class C: step = SOURCE\ndef invoke(target):\n if False:\n invoke(target)\n target.step()\ninvoke(C)', + ): + with self.subTest(source=source, body=body): + self.assertEqual(self.inspect(body.replace('SOURCE', source) + '\n') is None, source == 'len') + self.assertIsNone(self.inspect('class C: step = len\nclass D: step = breakpoint\ndef invoke(target):\n target.step()\ndef other(target):\n return None\ninvoke(C)\nother(D)\n')) + + def test_pr103_nine_v2_argument_selection(self) -> None: + for pack, sensitive in (('(C, D)', False), ('(D, C)', True)): + with self.subTest(pack=pack): + code = ('class C: step = len\nclass D: step = breakpoint\ndef invoke(*targets):\n targets[0].step()\npack = ' + pack + '\ninvoke(*pack)\n') + self.assertEqual(self.inspect(code) is None, not sensitive) + for body in ( + 'def invoke(target, other):\n target.step()\npack = (C, D)\ninvoke(*pack)', + 'def invoke(**targets):\n targets["target"].step()\noptions = {"target": C, "other": D}\ninvoke(**options)', + 'def invoke(*, target):\n target.step()\ninvoke(target=C)', + 'class E:\n step = len\n def invoke(self):\n self.step()\nE.invoke(E())', + ): + with self.subTest(body=body): + self.assertIsNone(self.inspect('class C: step = len\nclass D: step = breakpoint\n' + body + '\n')) + + def test_pr103_nine_v3_variadic_fixed_prefix(self) -> None: + for source in ('breakpoint', 'len'): + for invocation in ('pack = (C,)\ninvoke("safe", *pack)', + 'pack = ("safe", C)\ninvoke(*pack)', + 'pack = (C,)\nother = pack\npack = other\ninvoke("safe", *pack)'): + with self.subTest(source=source, invocation=invocation): + code = 'class C: step = ' + source + '\ndef invoke(prefix, *targets):\n targets[0].step()\n' + invocation + '\n' + self.assertEqual(self.inspect(code) is None, source == 'len') + + def test_pr103_nine_v3_callee_value_provenance(self) -> None: + for container, target in (('[invoke]', 'calls[0]'), ('{"x": invoke}', 'calls["x"]')): + for source in ('breakpoint', 'len'): + for operation, argument in (('target.step()', 'C'), ('target()', source)): + with self.subTest(container=container, source=source, operation=operation): + code = ('class C: step = ' + source + '\ndef invoke(target):\n ' + operation + + '\ncalls = ' + container + '\n' + target + '(' + argument + ')\n') + self.assertEqual(self.inspect(code) is None, source == 'len') + for value in ('os.environ', '"safe"'): + with self.subTest(container=container, value=value): + self.assertEqual(self.inspect('import os, urllib.parse\nenv = ' + value + '\nclass C: encode = urllib.parse.quote\ndef invoke(target):\n print(target.encode(str(env)))\ncalls = ' + container + '\n' + target + '(C)\n') is None, value == '"safe"') + + def test_pr103_nine_v3_staged_call_edges(self) -> None: + for source in ('breakpoint', 'len'): + for body in ( + 'class C: step = SOURCE\ndef invoke(target):\n target.step()\ndef apply(fn, target):\n fn(target)\napply(invoke, C)', + 'class C: step = SOURCE\ncalls = [lambda target: target.step()]\ncalls[0](C)', + 'class C:\n step = SOURCE\n def invoke(self):\n self.step()\ndef apply(fn):\n fn()\napply(C().invoke)', + 'class C:\n step = SOURCE\n def invoke(self):\n self.step()\ndef apply(fn, target):\n fn(target)\napply(C.invoke, C())', + 'class C: step = SOURCE\ndef invoke(target):\n target.step()\ncalls = [invoke]\nother = calls\ncalls = other\ncalls[0](C)', + 'class C: step = SOURCE\ndef invoke(prefix, *targets):\n targets[0].step()\nfirst = ("safe",)\nsecond = (C,)\ninvoke(*first, *second)', + ): + with self.subTest(source=source, body=body): + self.assertEqual(self.inspect(body.replace('SOURCE', source) + '\n') is None, source == 'len') + + def test_pr103_nine_v4_mixed_starred_alternatives(self) -> None: + for source in ('breakpoint', 'len'): + for pack in ('(D,) if False else (*other,)', '(*other,) if False else (D,)', + '(*other,)', '(D,) if False else (C,)'): + with self.subTest(source=source, pack=pack): + code = ('class C: step = ' + source + '\nclass D: step = len\n' + 'def invoke(prefix, *targets):\n targets[0].step()\n' + 'other = (C,)\npack = ' + pack + '\ninvoke("safe", *pack)\n') + self.assertEqual(self.inspect(code) is None, source == 'len') + self.assertIsNone(self.inspect('class C: step = len\nclass D: step = breakpoint\ndef invoke(prefix, *targets):\n targets[0].step()\nother = (C, D)\npack = (C, D) if False else (*other,)\ninvoke("safe", *pack)\n')) + for source in ('breakpoint', 'len'): + with self.subTest(cyclic_source=source): + self.assertEqual(self.inspect('class C: step = ' + source + '\nclass D: step = len\ndef invoke(prefix, *targets):\n targets[0].step()\nother = (*other, C)\npack = (D,) if False else (*other,)\ninvoke("safe", *pack)\n') is None, source == 'len') + + def test_pr103_nine_v4_supplied_descriptor_receivers(self) -> None: + for source in ('breakpoint', 'len'): + for body in ( + 'def apply(cls):\n cls.invoke(None, C)\napply(E)', + 'def apply(cls):\n alias = cls.invoke\n alias(None, C)\napply(E)', + 'def apply(cls):\n cls.invoke(None, C)\nalias = E\napply(alias)', + 'def apply(obj):\n obj.invoke(C)\napply(E())', + 'E.invoke(None, C)', + ): + with self.subTest(source=source, body=body): + code = ('class C: step = ' + source + '\nclass E:\n' + ' def invoke(self, target):\n target.step()\n' + body + '\n') + self.assertEqual(self.inspect(code) is None, source == 'len') + + def test_pr103_nine_v4_descriptor_precision(self) -> None: + prefix = ('class C: step = len\nclass D: step = breakpoint\nclass E:\n' + ' def invoke(self, target, *rest):\n target.step()\n') + for body in ( + 'def apply(cls):\n cls.invoke(D, C)\napply(E)', + 'def apply(obj):\n obj.invoke(C, D)\napply(E())', + 'def apply(cls):\n cls.invoke(D, C)\nvalues = [E]\napply(values[0])', + 'def apply(obj):\n obj.invoke(C, D)\nvalues = [E()]\napply(values[0])', + 'def apply(value):\n value.invoke(C, C)\napply(E if False else E())', + ): + with self.subTest(safe_body=body): + self.assertIsNone(self.inspect(prefix + body + '\n')) + for args in ('None, D', 'D, None'): + with self.subTest(ambiguous_args=args): + self.assertIsNotNone(self.inspect(prefix + 'def apply(value):\n value.invoke(' + args + ')\napply(E if False else E())\n')) + + def test_pr103_nine_v5_nested_starred_rows(self) -> None: + for source in ('breakpoint', 'len'): + for row in ('(D,) if False else (*other,)', '(*other,) if False else (D,)', + '(*other,)', '(C,)', '(D,) if False else (C,)'): + for operation in ('for (target,) in rows:\n target.step()', + 'values = [target.step() for (target,) in rows]'): + with self.subTest(source=source, row=row, operation=operation): + code = ('class C: step = ' + source + '\nclass D: step = len\nother = (C,)\n' + 'rows = [' + row + ']\n' + operation + '\n') + self.assertEqual(self.inspect(code) is None, source == 'len') + + def test_pr103_nine_v5_row_projection_controls(self) -> None: + prefix = 'class C: step = len\nclass D: step = breakpoint\nother = (C, D)\n' + for body in ( + 'rows = [(*other,)]\nfor (target, unused) in rows:\n target.step()', + 'rows = [(C, D) if False else (*other,)]\nvalues = [target.step() for (target, unused) in rows]', + 'rows = [(*other,)]\nfor (target, *rest) in rows:\n target.step()', + 'rows = [((*other,),)]\nfor ((target, unused),) in rows:\n target.step()', + ): + with self.subTest(safe_body=body): + self.assertIsNone(self.inspect(prefix + body + '\n')) + self.assertIsNotNone(self.inspect(prefix + 'rows = [(*other,)]\nfor (*rest, target) in rows:\n target.step()\n')) + for source in ('breakpoint', 'len'): + with self.subTest(cyclic_source=source): + self.assertEqual(self.inspect('class C: step = ' + source + '\nother = (*other, C)\nrows = [(*other,)]\nfor (target,) in rows:\n target.step()\n') is None, source == 'len') + + def test_pr103_named_expression_path_assignment_targets(self) -> None: + for path in ('docs/EXECUTION.md', '/private/issue79-fixture'): + for binding in ('root = Path(PATH)\n(marker := 0)', + '(root := Path(PATH))', + 'root: object = Path(PATH)\n(marker := 0)', + 'other = root = Path(PATH)\n(marker := 0)'): + with self.subTest(path=path, binding=binding): + body = 'from pathlib import Path\n' + binding.replace('PATH', repr(path)) + '\nroot.read_text()\n' + self.assertEqual(self.inspect(body) is None, path == 'docs/EXECUTION.md') + + def test_pr103_reflective_helper_return_policy_stays_conservative(self) -> None: + for returned in ('f"line {getattr(node, \'lineno\', 0)}"', + 'f"line {getattr(node, \'lineno\', getattr(node, \'offset\', 0))}"'): + with self.subTest(conservative_return=returned): + self.assertIsNotNone(self.inspect('def describe(node):\n return ' + returned + '\ntext = describe(None)\n')) + for returned in ('getattr(node, name)', + 'f"line {getattr(node, \'lineno\', 0)}" if False else getattr(node, name)', + '(getattr(node, name),)[0]', + '[f"line {getattr(node, \'lineno\', 0)}", getattr(node, name)]'): + with self.subTest(unsafe_return=returned): + self.assertIsNotNone(self.inspect('def extract(node, name):\n return ' + returned + '\nmember = extract(None, "move_into")\nmember("fixture")\n')) + self.assertIsNotNone(self.inspect('import os\ndef describe(node):\n return f"{getattr(node, \'value\', os.environ)}"\ntext = describe(None)\nprint(text)\n')) + + def test_pr103_iterator_diagnostic_metadata_is_structural(self) -> None: + scanner_tree = ast.parse(_scanner_module_source(PACKET_TEXT)) + helper = next(node for node in scanner_tree.body if isinstance(node, ast.FunctionDef) + and node.name == 'python_sensitive_read_violation') + diagnostic = next(node for node in ast.walk(helper) if isinstance(node, ast.Return) + and isinstance(node.value, ast.JoinedStr) + and any(isinstance(part, ast.Constant) and isinstance(part.value, str) + and part.value.startswith('Python custom iterator may expose sensitive protocol values ') + for part in node.value.values)) + body = 'import ast\ndef describe(node):\n ' + ast.unparse(diagnostic).replace('\n', '\n ') + '\ntext = describe(None)\n' + self.assertIsNone(self.inspect(body)) + # All candidates come from ast.parse/ast.walk, never generated or + # executed specimen nodes. A comprehension's iter is an expression. + for source in ('for value in values:\n pass\n', + 'async def collect():\n async for value in values:\n pass\n', + 'rows = [value for value in values]\n', + 'rows = {value for value in values}\n', + 'rows = (value for value in values)\n', + 'rows = {value: value for value in values}\n'): + for node in ast.walk(ast.parse(source)): + if isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)): + location = node.iter if isinstance(node, ast.comprehension) else node + self.assertIsInstance(location.lineno, int) + self.assertGreater(location.lineno, 0) + for operation, line in (('for item in K():\n pass\n', 4), + ('async def collect():\n async for item in K():\n pass\n', 5), + ('rows = [item\n for item in\n K()]\n', 6)): + for source in ('os.environ.items()', '[("safe", "value")]'): + with self.subTest(operation=operation, source=source): + result = self.inspect('import os\nclass K:\n __iter__ = lambda self: iter(' + source + ')\n' + operation) + if source.startswith('['): + self.assertIsNone(result) + else: + self.assertEqual(result, 'Python custom iterator may expose sensitive protocol values on line ' + str(line)) + + def test_pr103_projection_bookkeeping_preserves_nested_helper_calls(self) -> None: + scanner_tree = ast.parse(_scanner_module_source(PACKET_TEXT)) + projection = next(node for node in scanner_tree.body if isinstance(node, ast.FunctionDef) + and node.name == 'python_destructured_binding_values') + original_helper = ('def check():\n def source_value(value):\n return False\n' + ' return source_value(None)\nresult = check()\n') + self.assertIsNone(self.inspect('import ast\n' + ast.unparse(projection) + '\n' + original_helper)) + # The conservative unresolved-destructuring rule remains unchanged; + # only the actual scanner's new bookkeeping avoids this collision. + self.assertIsNotNone(self.inspect(original_helper + 'def project(items):\n pattern, source_value = items.pop()\n')) + def test_current_packet_has_no_static_scanner_violations(self) -> None: matches: list[str] = [] shell_command_count = 0 From 0b5b0c50f830bebf2dc32615ee714535f5200942 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 00:34:38 +0900 Subject: [PATCH 113/123] fix(evidence): close protocol alias and async iteration gaps --- docs/evidence/g01-recovery-packet.md | 27 +- .../issue79-pr103-review-6172592368.md | 270 ++++++++++++++++++ .../issue79_regression_test.py | 72 +++++ 3 files changed, 362 insertions(+), 7 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 358ccced..9412d919 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22945,15 +22945,25 @@ def python_sensitive_read_violation(tree, parents): return True assigned_display_methods = {} + display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__aiter__", "__anext__"} if error_operand_classes: for candidate in ast.walk(tree): if (isinstance(candidate, ast.Call) and len(candidate.args) >= 3 and any(python_unshadowed_builtin_reference(reference, {"setattr"}, tree, parents) - for reference in python_value_provenance(candidate.func, tree, parents)) - and isinstance(candidate.args[1], ast.Constant) - and candidate.args[1].value in {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__"}): - for classdef in display_class_targets(candidate.args[0], set()): - assigned_display_methods.setdefault(classdef, []).append((candidate.args[1].value, candidate.args[2])) + for reference in python_value_provenance(candidate.func, tree, parents))): + mutation_classes = display_class_targets(candidate.args[0], set()) + # The string helper's nested name lookup is body-wide. Do + # not let a concatenation borrow an unrelated scope's literal. + mutation_name_values = [set() if isinstance(reference, ast.BinOp) + and any(isinstance(part, ast.Name) for part in ast.walk(reference)) + else python_static_string_values(reference, tree) + for reference in python_value_provenance(candidate.args[1], tree, parents)] + mutation_names = set().union(*mutation_name_values) + if mutation_classes and (not mutation_names or not all(mutation_name_values)): + return f"Python class protocol mutation has an unresolved member name on line {candidate.lineno}" + for classdef in mutation_classes: + for mutation_name in mutation_names & display_protocol_names: + assigned_display_methods.setdefault(classdef, []).append((mutation_name, candidate.args[2])) if not isinstance(candidate, (ast.Assign, ast.AnnAssign)) or candidate.value is None: continue targets = candidate.targets if isinstance(candidate, ast.Assign) else [candidate.target] @@ -22965,7 +22975,7 @@ def python_sensitive_read_violation(tree, parents): classes, method = display_class_targets(target.value, set()), target.attr else: continue - if method in {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__"}: + if method in display_protocol_names: for classdef in classes: assigned_display_methods.setdefault(classdef, []).append((method, candidate.value)) @@ -23105,7 +23115,10 @@ def python_sensitive_read_violation(tree, parents): return f"Python unreviewed Path reader alias {alias!r} is not allowed" for node in ast.walk(tree): if (isinstance(node, (ast.comprehension, ast.For, ast.AsyncFor)) - and error_operand_repr_sensitive(node.iter, set(), ("__iter__", "__next__", "__getitem__"))): + and error_operand_repr_sensitive(node.iter, set(), + ("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__") if isinstance(node, ast.AsyncFor) + or isinstance(node, ast.comprehension) and node.is_async + else ("__iter__", "__next__", "__getitem__"))): return ("Python custom iterator may expose sensitive protocol values " f"on line {node.iter.lineno if isinstance(node, ast.comprehension) else node.lineno}") if isinstance(node, ast.Subscript) and python_dotted_name(node.value) == "os.environ": diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index f7a48d1b..a805f0e5 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -1814,3 +1814,273 @@ reported to the coordinator for both reviewers' final exact-SHA/ledger verification. No final approval, push, GitHub write, merge, hosted/Go matrix, canary, live operation, recipe execution or credential-value inspection is claimed here. Rollback remains a reviewed revert of this three-file change. + +### V10 four-P1 exact-head triage and bounded correction checkpoint + +The complete [new exact-head review](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6344020291) +was read using `gh api repos/1XP-AI/gh-runnerd/issues/comments/6344020291 +--jq .body`. Its source is clean pushed head +`f736e2b8ed4e04804f4121534b4a8846fd8415b4`, packet +`358ccced22db3ed1befcd8c9947f1d44cfc86942`, harness +`51a5f50d01253909e7a61b72dbc0014ef6fbd66d`, and starting ledger +`f7a48d1b646bfe439b0a3040c2f9cdaa5e0223f0`. +The [coordinator's public triage](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6344090467) +initially treats all four P1 reports as blocking pending reproduction. This +writer uses the explicit current maintainer override, Codex `gpt-6.1-sol` / +`medium`; no model-route substitution or independent approval is implied. +The earlier nine finding dispositions, V6 ERROR, V7/V8 FAIL and V9 PASS remain +historical evidence on their recorded sources, not coverage of this delta. + +| Report class | Authoritative entry-point reproduction at f736 | Proposed local disposition, pending independent verification | +|---|---|---| +| 1, factory encoder provenance | `functools.partial` direct, from-import and assigned-factory shapes are refused by the reviewed import policy, with sensitive and literal arguments. | Evidence-based rebuttal of whole-entry acceptance; preserve the existing import refusal. A lower-level provenance omission does not establish a reachable scanner bypass. | +| 2, indirect setattr protocol name | Simple alias, alias chain and literal concatenation installing a sensitive `__str__` are accepted before correction. A supplied parameter shadowing an unrelated module literal also reproduces. | Reproduced P1; index the bounded string alternatives reached through the existing value graph and fail closed for unresolved class mutation names. | +| 3, async iteration | Both `__aiter__` yields and `__anext__` returns of the environment escape checks in async loops and list/set/dict/generator comprehensions. | Reproduced P1; add async protocols for async consumers and assigned implementations, retaining the previous conservative sync-protocol checks. | +| 4, classmethod decorator alias | Direct alias and alias chain, class/instance/extracted/keyword calls are refused as unreviewed decorators, with launcher and harmless members. | Evidence-based rebuttal of whole-entry acceptance; preserve the existing decorator refusal. Descriptor helper hardening is not needed for these refused source forms. | + +The coordinator selected rebuttal plus durable authoritative-entry regressions +for 1/4 in reply to the writer's blocking `ask`; it explicitly rejected +unreachable helper-only hardening and allowlist expansion. Both independent +reviewers still must verify these rebuttals. The import/decorator policy and +the factory/descriptor helpers are unchanged by this correction. + +For precise reproduction, these complete strings are **inert AST data**, never +imported, compiled, evaluated or executed as programs: + +```python +import os, urllib.parse +import functools +encode = functools.partial(urllib.parse.quote) +print(encode(str(os.environ))) +``` + +The actual authoritative diagnostic is +`Python import 'functools' is not in the reviewed non-network allowlist`. +The durable factory method also supplies `from functools import partial as +bind` and `import functools; bind = functools.partial` as separate well-formed +sources, and pairs each with a literal `"safe"` argument. Every source is +refused for the import policy, rather than a parse error. + +```python +import os +cm = classmethod +class C: + step = os.system +class E: + @cm + def invoke(cls, target): + target.step() +E.invoke(C) +``` + +The actual authoritative diagnostic is +`Python heredoc has an unreviewed decorator on line 7`. +The durable classmethod method uses `target.step("safe")` so both `os.system` +and `len` forms have a supplied argument; it also covers `first = classmethod; +cm = first` as separate assignment lines and class, instance, extracted and +keyword invocation forms. All sources are refused for the decorator policy. +The two displayed exact forms were also inspected directly on the corrected +scanner, producing the same two diagnostics; the policy bytes did not change. +The four-method baseline-source reversal below verifies these refusals at f736. + +Actual local commands use this isolated prefix: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py +``` + +Before any packet edit, prefix plus `-k test_pr103_four` produced **four +methods, 26 failed assertions in 0.321s, exit 1**, with initial RED harness +`22a57210e74869a4e5cc004f7dd996245bda1a26`. This was a mixed exploratory +result: 14 sensitive unsafe accepts, one deliberately unresolved harmless +mutation accepted where fail-closed refusal was required, and 11 harmless +factory/decorator examples conservatively refused. It is **not 26 security +bypasses**. After correcting 1/4's expectations to their actual policy, +the unchanged packet produced **15 failures in four methods, 0.313s, +exit 1**: ten sensitive async, four sensitive setter, one harmless unresolved +name. Both rebuttal methods passed on the original packet. + +The initial minimal correction passed the four methods in **0.296s**, exit 0. +A finite shadowed-parameter contrast then failed one sensitive assertion in +**0.240s**, exit 1, using prefix plus `-k test_pr103_four_setter`. +Following each name's existing scope-aware value graph, rather than resolving +only the original name through the body-wide string helper, passed all four +methods in **0.323s**, exit 0. Direct and setter-installed async implementations +were then added with harmless paired controls. + +The unchanged combined focused command recorded above (all `-k test_pr103` +methods plus the same 21 adjacent selectors) initially produced **104 methods, +one failure in 23.433s**, exit 1. The older diagnostic regression expects +conservative refusal even when an async loop has a sensitive sync iterator. +Retaining those original sync checks and adding the two async checks passed +the same **104 methods in 22.399s**, exit 0; no historical expectation was +deleted or weakened. The first expanded V9 preflight on the preceding source +passed in **13.718s** and remains scoped to that intermediate source. + +A final adjacent probe using `setattr(K, name + "", leak)` inside a function +whose parameter shadows module `name = "label"` produced two failed refusal +assertions in **0.240s**, exit 1, with prefix plus `-k test_pr103_four_setter`. +One contains a sensitive implementation; the other requires the same +conservative unresolved-name policy for a harmless implementation. The fix +does not generalize the string analyzer: concatenations containing names are +explicitly unresolved at this class-mutation boundary, while literal +concatenations and simple assignment aliases retain their paired positives. +Prefix plus `-k test_pr103_four +-k test_pr103_iterator_diagnostic_metadata_is_structural` then passed **five +methods in 0.429s**, exit 0. + +On the frozen inputs below, the unchanged combined focused command passed +**104 methods in 23.250s**, exit 0, retaining all prior 100 methods and the +four new methods, including actual canonical-body/containment controls. +The **identical expanded V9 preflight command** recorded above passed in +**13.468s**, exit 0: 206 root helpers, 328 called original helper names +including nested definitions, seven retained scoped regex-receiver proofs, +zero new collisions or lost proofs. This is bounded compatibility evidence, +not a whole-packet result or a general Python safety claim. + +For an exact-source check, the final four methods were also applied to the +untouched f736 packet using only the inspected trusted loader and Git GET: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B - <<'PY' +import collections, hashlib, runpy, subprocess, sys, time, unittest +started=time.monotonic() +h=runpy.run_path('scripts/evidence_packet/issue79_regression_test.py') +original=subprocess.run(['/usr/bin/git','--no-replace-objects','-P','-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null','show','f736e2b8ed4e04804f4121534b4a8846fd8415b4:docs/evidence/g01-recovery-packet.md'],env={'PATH':'/usr/bin:/bin','LC_ALL':'C','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_SYSTEM':'/dev/null','GIT_ATTR_NOSYSTEM':'1'},check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True).stdout +encoded=original.encode(); blob=hashlib.sha1(b'blob '+str(len(encoded)).encode()+b'\0'+encoded).hexdigest() +assert blob=='358ccced22db3ed1befcd8c9947f1d44cfc86942',blob +h['_scanner_namespace'].__globals__['PACKET_TEXT']=original +names=sorted(name for name in dir(h['Issue79RegressionTests']) if name.startswith('test_pr103_four')) +result=unittest.TestResult(); unittest.TestSuite(h['Issue79RegressionTests'](name) for name in names).run(result) +counts=collections.Counter(test.id().split(' ')[0].rsplit('.',1)[-1] for test,_error in result.failures) +print('source-matched baseline packet:',blob) +print('methods:',result.testsRun,'failures:',len(result.failures),'errors:',len(result.errors),'seconds:',round(time.monotonic()-started,3)) +print('failure counts:',dict(sorted(counts.items()))) +sys.exit(not result.wasSuccessful()) +PY +``` + +This returned **four methods, 20 failures, zero errors, 0.422s, exit 1**: +13 async and seven setter refusal assertions; both rebuttal methods passed. +The final added setter assertions include the explicitly conservative +unresolved-name controls, not twenty demonstrated runtime credential leaks. +No specimen or packet recipe executed; only trusted scanner functions and +the harness ran, and Git read one immutable tracked blob. + +The first frozen correction inputs are packet +`9412d9190b0e083d33ef13e7dde90ecae47b5b11` and harness +`cfca0bbdd600ca758d5a2c3d312163923844af01`. The packet change is confined +to protocol-name indexing and async protocol inspection; there is no general +analyzer, policy allowlist change, classifier exemption or name-specific +safelist. Unknown class-mutation names refuse conservatively. Prior safe +canonical packet compatibility has focused evidence above; a fresh complete +packet scan is still pending and no old PASS substitutes for it. + +This is the first freeze checkpoint for two bounded independent provisional +reviews. No full-packet selector, local commit, push, GitHub write, merge, +Project mutation, Go/compiler sweep, live runner operation or workflow dispatch +has been performed for V10. Packet/harness remain frozen while ledger-only +receipts may be appended. Rollback is a reviewed revert of this bounded +three-file correction against f736; no historical evidence is removed. + +V10 `git diff --check` passed. The following bounded introduced-delta hygiene +command passed with exactly the three owned files and zero added-line matches +in all four categories; it does not claim an exhaustive secret audit: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B - <<'PY' +import re, subprocess +owned={'docs/evidence/g01-recovery-packet.md','scripts/evidence_packet/issue79_regression_test.py','docs/evidence/issue79-pr103-review-6172592368.md'} +git=['/usr/bin/git','--no-replace-objects','-P','-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null'] +env={'PATH':'/usr/bin:/bin','LC_ALL':'C','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_SYSTEM':'/dev/null','GIT_ATTR_NOSYSTEM':'1'} +def read(args): + return subprocess.run(git+args,env=env,check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True).stdout +names=set(read(['diff','--name-only','f736e2b8ed4e04804f4121534b4a8846fd8415b4','--']).splitlines()) +assert names==owned,sorted(names) +delta=read(['diff','--no-ext-diff','--no-textconv','--unified=0','f736e2b8ed4e04804f4121534b4a8846fd8415b4','--']+sorted(owned)) +added='\n'.join(line[1:] for line in delta.splitlines() if line.startswith('+') and not line.startswith('+++')) +patterns={'personal_home':r'/(?:Users|home)/[^/\s]+','github_token':r'(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})','private_key':r'-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----','aws_access_key':r'AKIA[0-9A-Z]{16}'} +counts={name:len(re.findall(pattern,added)) for name,pattern in patterns.items()} +assert not any(counts.values()),counts +print('PASS introduced-delta hygiene: exact three owned files; added-line pattern counts',counts) +PY +``` + +### V10 provisional approvals and authorized current-source full scan + +Coordinator receipt `msg_ac46c48a3e11` reports both independent provisional +approvals: contract `msg_385487363b09` and security `msg_abaeb96ef700`, against +packet `9412d9190b0e083d33ef13e7dde90ecae47b5b11` and harness +`cfca0bbdd600ca758d5a2c3d312163923844af01`. Both independently inspected +the authoritative entry at f736 and V10 and verified findings 1/4 as +**REBUTTED**, due to the existing import/decorator refusals. Both verified +findings 2/3 as **FIXED**, using finite sensitive/literal, local-shadow and +assigned-async-method contrasts. Contract reports 24 tiny cases per source +in 0.746s; security reports bounded contrasts without a timing receipt, so +none is invented. Both reviewed the 104-method/preflight receipts, proposed +ledger dispositions and historical V9 PASS, and reported no new blocker. +These are provisional source approvals; final exact-commit approvals remain +required after the whole-packet gate and local candidate commit. + +The coordinator authorized exactly one fresh isolated full-packet invocation +on the frozen V10 sources, using: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations +``` + +Immediately before launch, UTC was stamped **2026-10-22T15:18:18Z** and both +frozen blob hashes were reverified unchanged. Packet/harness remain frozen; +only ledger receipts may change. No additional focused/compiler/Go sweep, +specimen/recipe execution, GitHub write or live operation is authorized by +this gate. A real PASS authorizes one three-file local commit with parent +f736 after hygiene checks; a FAIL/ERROR requires preservation, escalation +and continued freeze with no automatic fix, rerun or commit. Actual completion +evidence will follow; no result is predicted here. + +The coordinator published the independently verified dispositions in +[immutable triage comment 6344329778](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6344329778) +while the single authorized full scan was running. That publication records +the two rebuttals, two local fixes and provisional-review limits; it is not +a completed whole-packet or final exact-commit approval receipt. + +### V10 fresh whole-packet PASS and local candidate handoff + +The single authorized invocation above completed **one test in 905.045s, +exit 0, OK** and emitted this actual complete-scan receipt: + +```text +current packet static scan: 261 shell commands, 86 Python heredoc bodies, zero violations +``` + +The preceding launch stamp is **2026-10-22T15:18:18Z**; completion was +observed/stamped at **2026-10-22T15:33:49Z**. The latter is the observation +time, not an invented exact process-exit instant. Packet +`9412d9190b0e083d33ef13e7dde90ecae47b5b11` and harness +`cfca0bbdd600ca758d5a2c3d312163923844af01` were reverified unchanged after +completion, with HEAD still f736 and exactly the three owned files modified. +This fresh whole-process PASS covers V10's changed source; it does not relabel +the earlier V6/V7/V8 failed runs or borrow V9's PASS. The 104-method focused +PASS and expanded 328-helper/seven-proof preflight remain separate receipts. + +At the coordinator's natural checkpoint, the existing owned session's poll +still reported running without further output; that actual receipt was sent +in `msg_e2ea2100304c`. No optional process/command-line/environment observation, +signal, debugger, restart or duplicate test was used. All commands, specimens +and Python bodies under inspection stayed inert data; only the reviewed +scanner/harness code executed. + +Under the coordinator's existing PASS-only gate, the writer will repeat the +exact introduced-delta hygiene command above and `git diff --check`, verify +the frozen packet/harness blobs, and stage only these three owned files for +one local candidate commit with parent f736. Its immutable SHA, three blobs, +diff stat and clean status go to the coordinator for both independent final +exact-SHA approvals. No final approval, push, GitHub write, merge or live +operation follows from this local PASS. Rollback remains a reviewed revert +of this bounded three-file correction, preserving all historical evidence. + +The repeated V10 introduced-delta hygiene check passed after the full-result +receipt was appended: exactly three owned files and zero added-line matches +in all four recorded categories. `git diff --check` also passed, and frozen +packet/harness hashes still matched. Only this receipt was appended afterward; +no source or test input changed. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 51a5f50d..cfca0bbd 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8065,6 +8065,78 @@ def test_pr103_iterator_diagnostic_metadata_is_structural(self) -> None: else: self.assertEqual(result, 'Python custom iterator may expose sensitive protocol values on line ' + str(line)) + def test_pr103_four_factory_encoder_provenance(self) -> None: + for setup, factory in ( + ('import functools', 'functools.partial'), + ('from functools import partial as bind', 'bind'), + ('import functools\nbind = functools.partial', 'bind'), + ): + for value in ('os.environ', '"safe"'): + with self.subTest(factory=factory, setup=setup, value=value): + result = self.inspect( + 'import os, urllib.parse\n' + setup + + '\nencode = ' + factory + '(urllib.parse.quote)\nprint(encode(str(' + + value + ')))\n') + self.assertIsNotNone(result) + self.assertIn("not in the reviewed non-network allowlist", result) + + def test_pr103_four_setter_protocol_name_aliases(self) -> None: + for setup, name in ( + ('name = "__str__"', 'name'), + ('first = "__str__"\nname = first', 'name'), + ('name = "__" + "str__"', 'name'), + ('', 'unknown_name'), + ): + for value in ('str(os.environ)', '"safe"'): + with self.subTest(setup=setup, name=name, value=value): + result = self.inspect('import os\ndef leak(self):\n return ' + value + + '\nclass K: pass\n' + setup + '\nsetattr(K, ' + name + ', leak)\nprint(K())\n') + self.assertEqual(result is None, value == '"safe"' and name != 'unknown_name') + self.assertIsNone(self.inspect('class K: pass\nname = "label"\nsetattr(K, name, "safe")\nprint(K())\n')) + for value in ('str(os.environ)', '"safe"'): + with self.subTest(shadowed_name=True, value=value): + self.assertEqual(self.inspect('import os\nname = "label"\ndef leak(self):\n return ' + + value + '\nclass K: pass\ndef install(name):\n setattr(K, name, leak)\n' + + 'install("__str__")\nprint(K())\n') is None, value == '"safe"') + self.assertIsNotNone(self.inspect('import os\nname = "label"\ndef leak(self):\n return ' + + value + '\nclass K: pass\ndef install(name):\n setattr(K, name + "", leak)\n' + + 'install("__str__")\nprint(K())\n')) + + def test_pr103_four_async_iterator_protocols(self) -> None: + for protocol, statement in (('__aiter__', 'yield'), ('__anext__', 'return')): + for sink in ('async for item in K():\n print(item)', + 'print([item async for item in K()])', + 'print({item async for item in K()})', + 'print({"key": item async for item in K()})', + 'print((item async for item in K()))'): + for value in ('os.environ', '"safe"'): + with self.subTest(protocol=protocol, sink=sink, value=value): + other = ' def __aiter__(self):\n return self\n' if protocol == '__anext__' else '' + self.assertEqual(self.inspect('import os\nclass K:\n' + other + + ' async def ' + protocol + '(self):\n ' + statement + ' ' + value + + '\nasync def collect():\n ' + sink + '\n') is None, value == '"safe"') + self.assertIsNone(self.inspect('import os\nclass K:\n async def __anext__(self):\n return os.environ\n def __iter__(self):\n return iter(["safe"])\nprint([item for item in K()])\n')) + for installation in ('K.__aiter__ = stream', 'setattr(K, "__aiter__", stream)', + 'name = "__aiter__"\nsetattr(K, name, stream)'): + for value in ('os.environ', '"safe"'): + with self.subTest(installation=installation, value=value): + self.assertEqual(self.inspect('import os\nasync def stream(self):\n yield ' + value + + '\nclass K: pass\n' + installation + + '\nasync def collect():\n print([item async for item in K()])\n') is None, value == '"safe"') + + def test_pr103_four_classmethod_decorator_aliases(self) -> None: + for setup, decorator in (('cm = classmethod', 'cm'), + ('first = classmethod\ncm = first', 'cm')): + for invocation in ('E.invoke(C)', 'E().invoke(C)', 'fn = E.invoke\nfn(C)', 'E.invoke(target=C)'): + for source in ('os.system', 'len'): + with self.subTest(setup=setup, invocation=invocation, source=source): + result = self.inspect('import os\n' + setup + '\nclass C:\n step = ' + source + + '\nclass E:\n @' + decorator + + '\n def invoke(cls, target):\n target.step("safe")\n' + + invocation + '\n') + self.assertIsNotNone(result) + self.assertIn("unreviewed decorator", result) + def test_pr103_projection_bookkeeping_preserves_nested_helper_calls(self) -> None: scanner_tree = ast.parse(_scanner_module_source(PACKET_TEXT)) projection = next(node for node in scanner_tree.body if isinstance(node, ast.FunctionDef) From 0a805c6ad62b9854f4ae36d48a1fd15e43178647 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 01:29:42 +0900 Subject: [PATCH 114/123] fix(evidence): close callback-name and async iterator gaps --- docs/evidence/g01-recovery-packet.md | 111 +++++- .../issue79-pr103-review-6172592368.md | 358 ++++++++++++++++++ .../issue79_regression_test.py | 126 ++++++ 3 files changed, 589 insertions(+), 6 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 9412d919..bf18fd1f 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22765,10 +22765,52 @@ def python_sensitive_read_violation(tree, parents): continue return False + def protocol_callable_targets(value, seen, assigned_protocol=False): + """Prove all local callable alternatives at the inspected protocol boundary.""" + if value is None or id(value) in seen: + return None + if assigned_protocol and isinstance(value, ast.Attribute): + return None # Extracted methods have unproven receiver/descriptor binding. + if isinstance(value, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)): + return [value] + seen = seen | {id(value)} + if isinstance(value, ast.Call): + targets = protocol_callable_targets(value.func, seen, assigned_protocol) + if not targets or any(isinstance(target, ast.ClassDef) for target in targets): + return None + alternatives = python_local_call_return_values(value, tree, parents) + else: + alternatives = [reference for reference in python_value_provenance(value, tree, parents) + if reference is not value] + resolved = [protocol_callable_targets(reference, seen, assigned_protocol) for reference in alternatives] + if not resolved or any(targets is None for targets in resolved): + return None + return [target for targets in resolved for target in targets] + def error_operand_repr_from_class(classdef, seen, methods): if id(classdef) in seen: return False seen.add(id(classdef)) + assigned_functions = {} + for method, implementation in assigned_display_methods.get(classdef, ()): + if method not in methods: + continue + targets = protocol_callable_targets(implementation, set(), assigned_protocol=True) + if not targets or any(isinstance(target, ast.ClassDef) for target in targets): + return True # Unproven wrappers/implementations refuse, even if harmless. + assigned_functions.setdefault(method, []).extend(targets) + if "__aiter__" in methods: + implementations = [entry for entry in classdef.body + if isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) + and entry.name == "__aiter__"] + implementations.extend(assigned_functions.get("__aiter__", ())) + for implementation in implementations: + returns = ([implementation.body] if isinstance(implementation, ast.Lambda) else + [part.value for part in ast.walk(implementation) + if isinstance(part, ast.Return) and part.value is not None]) + if any(returned_async_iterator_sensitive(result, implementation, classdef, seen, set()) + for result in returns): + return True for method, implementation in assigned_display_methods.get(classdef, ()): if method in methods and display_implementation_sensitive(implementation, set()): return True @@ -22791,6 +22833,54 @@ def python_sensitive_read_violation(tree, parents): return True return False + def returned_async_iterator_sensitive(value, implementation, classdef, seen_classes, seen_values): + if id(value) in seen_values: + return True + seen_values = seen_values | {id(value)} + if isinstance(value, ast.Name): + positional = list(implementation.args.posonlyargs) + list(implementation.args.args) + python_sensitive_imported_function_alias(value, tree, parents) + bindings = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}).get( + (id(implementation), value.id), ()) + if (positional and value.id == positional[0].arg + and python_enclosing_scope(value, parents) is implementation + and not getattr(implementation, "decorator_list", ()) + and bindings and all(kind == "unknown" and binding is positional[0] + for kind, binding in bindings)): + return False # This class's protocols are already inspected. + alternatives = [reference for reference in python_value_provenance(value, tree, parents) + if reference is not value] + return not alternatives or any(returned_async_iterator_sensitive(reference, + implementation, classdef, seen_classes, seen_values) for reference in alternatives) + if isinstance(value, ast.IfExp): + return any(returned_async_iterator_sensitive(reference, implementation, classdef, + seen_classes, seen_values) for reference in (value.body, value.orelse)) + if isinstance(value, ast.Call): + targets = protocol_callable_targets(value.func, set()) + if not targets: + return True + classes = [target for target in targets if isinstance(target, ast.ClassDef)] + if any(error_operand_repr_from_class(reference, set(seen_classes), + ("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__")) for reference in classes): + return True + for target in targets: + if isinstance(target, ast.ClassDef): + continue + # Targets are proven before selecting their lexical returns; + # a known class sibling must not discard a helper alternative. + returned = ([target.body] if isinstance(target, ast.Lambda) else + [part.value for part in ast.walk(target) + if isinstance(part, (ast.Return, ast.Yield, ast.YieldFrom)) + and part.value is not None and python_enclosing_scope(part, parents) is target]) + if not returned or any(returned_async_iterator_sensitive(reference, + implementation, classdef, seen_classes, seen_values) for reference in returned): + return True + return False + if isinstance(value, ast.ClassDef): + return error_operand_repr_from_class(value, set(seen_classes), + ("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__")) + return True + def display_implementation_sensitive(value, seen): if value is None or id(value) in seen: return False @@ -22946,18 +23036,27 @@ def python_sensitive_read_violation(tree, parents): assigned_display_methods = {} display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__aiter__", "__anext__"} + def mutation_literal_names(value, seen): + if id(value) in seen: + return set() + if isinstance(value, ast.Name): + alternatives = [reference for reference in python_value_provenance(value, tree, parents) + if reference is not value] + values = [mutation_literal_names(reference, seen | {id(value)}) for reference in alternatives] + return set().union(*values) if values and all(values) else set() + # The existing graph owns aliases and scopes. The body-wide string + # helper may only inspect terminal expressions containing no names. + if any(isinstance(part, ast.Name) for part in ast.walk(value)): + return set() + return python_static_string_values(value, tree) + if error_operand_classes: for candidate in ast.walk(tree): if (isinstance(candidate, ast.Call) and len(candidate.args) >= 3 and any(python_unshadowed_builtin_reference(reference, {"setattr"}, tree, parents) for reference in python_value_provenance(candidate.func, tree, parents))): mutation_classes = display_class_targets(candidate.args[0], set()) - # The string helper's nested name lookup is body-wide. Do - # not let a concatenation borrow an unrelated scope's literal. - mutation_name_values = [set() if isinstance(reference, ast.BinOp) - and any(isinstance(part, ast.Name) for part in ast.walk(reference)) - else python_static_string_values(reference, tree) - for reference in python_value_provenance(candidate.args[1], tree, parents)] + mutation_name_values = [mutation_literal_names(candidate.args[1], set())] mutation_names = set().union(*mutation_name_values) if mutation_classes and (not mutation_names or not all(mutation_name_values)): return f"Python class protocol mutation has an unresolved member name on line {candidate.lineno}" diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index a805f0e5..5a400702 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -2084,3 +2084,361 @@ receipt was appended: exactly three owned files and zero added-line matches in all four recorded categories. `git diff --check` also passed, and frozen packet/harness hashes still matched. Only this receipt was appended afterward; no source or test input changed. +### V11 two-P1 authoritative reproduction and bounded correction checkpoint + +This correction starts from clean pushed head +`0b5b0c50f830bebf2dc32615ee714535f5200942`, packet +`9412d9190b0e083d33ef13e7dde90ecae47b5b11` and harness +`cfca0bbdd600ca758d5a2c3d312163923844af01`. The maintainer explicitly selected +Codex `gpt-6.1-sol` / `medium` for this writer. The coordinator owns GitHub +writes, push, merge, Project state and subsequent gates. The complete new +[Codex review comment 6344825684](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6344825684) +was read using `gh api repos/1XP-AI/gh-runnerd/issues/comments/6344825684`; +the coordinator's [triage comment 6344897440](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6344897440) +records the two-finding correction boundary. All historical receipts and the +V10 factory/import and classmethod/decorator rebuttals remain intact. + +Both P1 findings reproduce at `inspect_python_heredoc`, using inert source +strings passed through the inspected trusted harness. The callback specimen +uses an outer `name = "label"`, a shadowing `install(name)` parameter, +`setattr(K, name, leak)`, `list(map(install, ["__str__"]))` and `print(K())`. +The original scanner accepts its sensitive implementation. The returned-iterator +specimen has `K.__aiter__` return `L()` and `L.__anext__` return `os.environ`; +an async comprehension is printed inside a coroutine, with +`collect().send(None)` present only as AST data. The original scanner accepts +that sensitive implementation too. No specimen, callback, coroutine, packet +recipe or protocol was executed, compiled, evaluated or imported. + +| Finding | Disposition and bounded change | Finite adjacent coverage | +|---|---|---| +| Callback-supplied protocol name | Reproduced P1. Resolve aliases only through the existing scope-aware value graph; terminal string expressions containing names cannot fall back to body-wide lookup. A remaining unknown or cyclic alternative refuses. | Direct callback parameter and local alias; sensitive and harmless unresolved callback implementations; an ordinary comprehension whose direct call arguments are resolved by the existing graph; supported literal alias control and all prior direct-call/concatenation regressions. | +| Returned async iterator | Reproduced P1. Inspect local `__aiter__` return values, follow known local returned classes and inspect their protocols with class/value cycle protection. Unknown return values refuse conservatively. Existing sync protocols remain checked for async consumers. | Constructor, assigned object, alias chain and conditional self/object return; direct, attribute-assigned and setter-assigned `__aiter__`; assigned/setter-installed `__anext__`; lambda implementation; async loop and list/set/dict/generator consumers; paired literal controls; unknown/cyclic aliases and a safe two-class cycle. | + +The isolated command prefix remains: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py +``` + +Before editing the packet, prefix plus `-k test_pr103_two` ran **two methods +in 0.392s, exit 1, 17 failures, zero errors**. This initial RED comprises +14 sensitive unsafe accepts, two harmless unresolved callback accepts where +refusal is required, and the first unknown-return refusal assertion. It is +not 17 demonstrated credential leaks. A first green attempt with +`-k test_pr103_two -k test_pr103_four +-k test_pr103_iterator_diagnostic_metadata_is_structural` ran seven methods +in **0.600s, exit 1**, with four failures in the adjacent direct-call +comprehension assertions. Inspection showed those argument values are resolved +by the existing graph: the sensitive form correctly refused at the output +sink and the harmless form correctly passed. Those four expectations were +corrected to require the paired sensitive/safe behavior rather than an +unresolved-name diagnostic. No scanner policy was changed for that adjustment. + +The final two methods were also applied to the immutable baseline packet by +loading the inspected trusted harness with `runpy.run_path`, obtaining only +`0b5b0c50f830bebf2dc32615ee714535f5200942:docs/evidence/g01-recovery-packet.md` +through literal `git show`, replacing the loader's `PACKET_TEXT` data and +running only `test_pr103_two_callback_protocol_names` and +`test_pr103_two_returned_async_iterators` with `unittest.TestResult`. +That diagnostic script reported **two methods, 19 assertion failures, zero +errors, 0.485s, process exit 0** (it deliberately printed rather than exited +on the recorded test failures): six callback-method failures and 13 +returned-iterator failures. These classify as 14 sensitive unsafe accepts, +two harmless unresolved callback accepts, two harmless direct-call +comprehension refusals and the first unknown-return refusal assertion. The +unittest method stops at that first unknown assertion; no later control is +claimed to have run on the baseline. This reversal corroborates the earlier +actual RED without mislabeling the diagnostic script's exit status. + +The seven-method command above then passed in **0.649s, exit 0**. The final +combined focused command is exactly the command recorded under the nine-P1 +batch above, beginning `-k test_pr103` and retaining the same 21 adjacent +selectors. It passed **106 methods in 22.932s, exit 0**, preserving all prior +104 methods and selecting the two new methods. The **identical expanded V9 +preflight command** above passed in **13.868s, exit 0**: 206 root helpers, +328 called original helpers including nested definitions, seven retained +scoped regex-receiver proofs, zero new collisions or lost proofs. Those are +bounded focused/compatibility results, not a fresh whole-packet PASS. + +Frozen correction inputs are packet +`416ec71bcf8a0d490d18bee350556c2a89214052` and harness +`a0a8d1bdccae6580710fc946145211f0b18ae239`. The implementation stays inside +the existing protocol boundary and value graph; it adds no higher-order +callback analyzer, policy allowlist change, source/name exception or generic +Python analyzer redesign. These inputs await two independent bounded +provisional reviews and coordinator authorization for one fresh full packet +scan. No independent approval, fresh full-packet result, local commit, push, +GitHub write, merge or live operation is claimed by this checkpoint. +Rollback is a reviewed revert of this bounded three-file correction against +`0b5b0c50f830bebf2dc32615ee714535f5200942`, preserving historical evidence. + +`git diff --check` passed. The V10 introduced-delta hygiene command above was +run with its comparison base replaced by +`0b5b0c50f830bebf2dc32615ee714535f5200942`; it passed with exactly the three +owned files and zero added-line matches for personal-home paths, GitHub token +patterns, private-key markers and AWS access-key patterns. This is a bounded +introduced-delta check, not an exhaustive secret audit. The packet/harness +remain frozen at the two blobs above while ledger-only receipts may be added. + +### V11 provisional HOLD, bounded receiver/return correction and renewed freeze + +The first frozen packet/harness above did **not** receive provisional approval. +Contract reviewer dispatch `ctx_da91a49b9c7f` reported HOLD in +`msg_efc7ddcd9a2a` and confirmed its no-source-read barrier in +`msg_0070a90108ba`; security reported HOLD and the same barrier in +`msg_8d40d274d3f0`. These are Orca review receipts, not GitHub Codex approvals. +The coordinator delivered the complete confirmed cases and authorized this +single batched correction in `msg_a14754e4893e`, after both barriers were +acknowledged. No edits occurred while those readers held the old source. + +The confirmed sensitive accepts were `__aiter__` implementations that return +the first formal after rebinding it to `L()`: ordinary and renamed instance +receiver forms, an externally assigned implementation, and a staticmethod +formal with default `L()`. In each case `L.__anext__` returns the environment. +The first-formal shortcut incorrectly treated those values as the original +receiver. Separately, the writer's authoritative AST probe and security's +independent probe both accepted a local `choose()` returning +`L() if flag else unknown`, with a harmless `L`. That last result is an +unresolved-return refusal gap, **not** a demonstrated sensitive leak. + +The new durable method +`test_pr103_two_async_return_binding_alternatives` uses a finite consumer: + +```python +async def collect(): + async for item in K(): + print(item) + break +collect().send(None) +``` + +This is inert AST data like every specimen. No coroutine or protocol was +executed. Earlier nonterminating comprehension fixtures remain static +protocol-refusal probes and are not evidence of completed runtime output. +The new method includes all four sensitive cases, their harmless counterparts, +an unchanged renamed receiver, known-safe local-helper returns, mixed known +classes and known/unknown helper return alternatives. Prefix plus +`-k test_pr103_two_async_return_binding_alternatives`, before the correction, +produced **one method, five failures, zero errors, 0.292s, exit 1**: four +sensitive accepts and the harmless/unknown policy gap. + +The minimal correction requires the existing binder inventory to prove that +the undecorated first parameter has no other binding before treating it as +the original receiver. Rebindings and staticmethod defaults instead follow +the existing provenance graph. For returned calls, existing local-call return +expressions are traversed before constructor-class filtering, retaining every +conditional alternative. No new generic analyzer or policy exception was added. +Prefix plus `-k test_pr103_two -k test_pr103_four +-k test_pr103_iterator_diagnostic_metadata_is_structural` then passed +**eight methods in 0.691s, exit 0**. + +Two additional conditional-constructor expressions were included in the same +finite method. Its first invocation then reported **one failure in 0.282s, +exit 1** because a known-safe conditional constructor was expected to pass. +Direct authoritative inspection showed both known-safe and unknown target +forms refuse at the unchanged gate: +`Python conditional process launcher target is not allowed on line 8`. +The test now preserves that conservative refusal for both forms; no source +change or new allowlist was made for this expectation correction. + +The unchanged combined focused command (`-k test_pr103` and the same 21 +adjacent selectors, recorded in full above) passed **107 methods in 24.076s, +exit 0**. The identical expanded V9 preflight command passed in **13.983s, +exit 0**: 206 root helpers, 328 called original helpers including nested, +seven retained scoped regex-receiver proofs, zero new collisions/lost proofs. +The earlier 106-method/13.868s receipts remain limited to the first frozen +inputs and are not relabeled as results for this correction. + +Renewed frozen inputs are packet +`861699b074f38e02a6a9c0eede046ab8d2a3cf93` and harness +`ae88dab91e24649c480d86dd85e36fd5058bf0c7`. They await both renewed bounded +provisional reviews. A fresh full scan and local commit remain unauthorized; +the earlier rollback base, three-file ownership and no-live-operation boundary +are unchanged. + +On these renewed inputs `git diff --check` and the identical introduced-delta +hygiene command against `0b5b0c50f830bebf2dc32615ee714535f5200942` passed: +exactly the three owned files, zero added-line matches in all four categories. +Source stays frozen for the renewed review gate; ledger-only receipts may follow. + +### V11 second provisional HOLD and callable-proof correction checkpoint + +The renewed `861699b0` packet did **not** receive approval. Both renewed +reviewers reported HOLD and acknowledged no more source reads: security +`msg_ff98f6c511d3`, contract `msg_1adbb96fc4eb`. The coordinator then +authorized one bounded correction batch in `msg_aa42058528f8`. + +Both readers reproduced the sensitive assigned-wrapper specimen with +`def start(receiver=L()): return receiver` and +`K.__aiter__ = staticmethod(start)`, with `L.__anext__` returning the +environment and the finite async consumer recorded above. Its inert source +SHA-256 is +`844c9d42a65047807232059df29aafcc9968eccdd423fe270ed83a08b8c86dd5`. +An undecorated function definition alone does not prove instance binding +when the assigned expression wraps it. Both readers also confirmed that +call-target filtering could discard unresolved alternatives: a harmless local +class combined with an unknown target through a conditional alias or sequential +assignments was accepted. Those harmless-class cases are refusal-policy gaps, +not demonstrated environment leaks. The coordinator required the same proof +for every assigned protocol implementation actively being inspected, including +`__anext__`, and for mixed local-function/unknown callable alternatives. + +The added method `test_pr103_two_protocol_callable_proofs` first produced +**one method, 11 failures, zero errors, 0.299s, exit 1**, using prefix plus +`-k test_pr103_two_protocol_callable_proofs`. One assertion is the confirmed +sensitive assigned `__aiter__` wrapper. Seven are unsupported wrapper/unknown +implementation refusal assertions (some source strings contain a sensitive +local class without establishing a working sensitive consumer). Three are +harmless/unknown callable-alternative refusal assertions. These are not eleven +demonstrated leaks. The mixed conditional local-function/unknown contrast +already refused at the authoritative entry; the sequential version reproduced. + +The bounded implementation now proves every local callable alternative through +the existing value graph before interpreting it as an assigned protocol or +returned-call target. Unproven assigned implementations, including harmless +unsupported wrappers, refuse at the active inspected protocol boundary; there +is no descriptor-wrapper normalization engine or new allowlist. Known local +function/lambda targets retain their lexical return expressions, and known +constructor targets retain class inspection. Every known/unknown alternative +must be accounted for before either selection; a known class sibling cannot +discard a function or unknown target. All prior direct/decorated safe controls +remain, and an extra known-safe mixed constructor/helper control is retained. + +The first nine-method focused invocation (`-k test_pr103_two +-k test_pr103_four -k test_pr103_iterator_diagnostic_metadata_is_structural`) +reported **one failure in 0.786s, exit 1**: the mixed known-safe class/helper +control was conservatively refused because the old local-return helper did +not select that conditional callable alias. Selecting lexical returns only +after proving all targets fixed the false refusal without weakening unknown +target handling. The same nine-method command then passed in **0.865s, +exit 0**. The unchanged combined focused command passed **108 methods in +23.157s, exit 0**, retaining the prior 107 methods. The identical expanded V9 +preflight passed in **12.907s, exit 0**: 206 root/328 including nested called +original helpers, seven retained scoped regex proofs, zero new collisions or +lost proofs. No whole-packet PASS is claimed for these sources yet. + +The next frozen inputs are packet +`950f84cadcdabc45469048067d27aaf556dad019` and harness +`2c9673b2f05567c4ca946668aebad279bedd7c0b`. Earlier receipts stay scoped to +their actual inputs, including both HOLD rounds and all RED/false-refusal +results. These sources await both renewed bounded provisional reviews; no +full scan, local commit, push or live action has occurred. The same three-file +ownership and reviewed rollback against `0b5b0c50f830bebf2dc32615ee714535f5200942` +remain in effect. + +`git diff --check` and the same introduced-delta hygiene command against +`0b5b0c50f830bebf2dc32615ee714535f5200942` passed on this freeze: exactly +three owned files and zero added-line matches in all four pattern categories. + +### V11 superseded provisional approvals and assigned-receiver proof checkpoint + +The coordinator reported in `msg_b27d18a4b7fb` that both provisional approvals +for `950f84ca` predated the writer's bound-method finding. Those approvals +were superseded and did **not** authorize a full scan. The writer's read-only +authoritative probe accepted `K.__aiter__ = L().start`, where `L.start` +returns its bound receiver and `L.__anext__` returns the environment, with +the finite async consumer above. Both readers independently confirmed the +same sensitive acceptance (source SHA-256 +`f8ff71876c828bda8fcc2061ebcd0d8f7358d5a05ba068ac9c002194a6d4e2c6`) +and its harmless counterpart +(`b0035c1d89a38863c9bdbc5e5a2fc4c8adcf1c81b70eefe653be02219e000031`). +Security HOLD/no-source-read receipt `msg_c0383a7eab25` and contract +HOLD/no-source-read receipt `msg_2737ed8f90f4` superseded those earlier +approvals. Coordinator `msg_3b19ed318a03` then authorized this bounded edit. + +The new method `test_pr103_two_assigned_protocol_receiver_proof` uses the +same finite consumer, covers direct attribute extraction, alias chains and +local-helper returns for both `__aiter__` and `__anext__`, and pairs every +sensitive return with a harmless return. Prefix plus +`-k test_pr103_two_assigned_protocol_receiver_proof` first produced **one +method, 12 failures, zero errors, 0.283s, exit 1**: six sensitive accepts and +six harmless unsupported-implementation accepts. All sources remained inert. + +The minimal fix propagates an assigned-protocol context through the existing +local callable proof. Attribute-origin implementations refuse conservatively +in that context, including aliases/helper returns, because an underlying +function definition does not prove its receiver binding. The rule applies to +every actively inspected assigned protocol and does not add descriptor +analysis. Harmless extracted methods are explicitly unsupported and refused. +Direct function/lambda aliases remain supported; the original-receiver shortcut +now has both assignment-origin proof and the existing local binder/no-rebinding +proof. Ordinary returned-call target proof retains its prior behavior. + +The ten-method command (`-k test_pr103_two -k test_pr103_four +-k test_pr103_iterator_diagnostic_metadata_is_structural`) passed in **0.923s, +exit 0**. The unchanged combined focused command passed **109 methods in +25.788s, exit 0**, retaining the previous 108. The identical expanded V9 +preflight passed in **15.056s, exit 0**: 206 root helpers, 328 including +nested called original helpers, seven retained scoped regex proofs, zero +new collisions/lost proofs. The next frozen inputs are packet +`bf18fd1fb55580dea79b36b15e4f2e010d3a8731` and harness +`c51a6c4ad1fe51763f2ccfc05c28c8cc1f48a858`. + +No full scan, local commit or push is claimed. Both renewed bounded reviews +are still required before coordinator authorization for the one fresh scan. +Prior results remain scoped to their actual sources; the three-file boundary +and rollback base `0b5b0c50f830bebf2dc32615ee714535f5200942` are unchanged. + +`git diff --check` and the identical introduced-delta hygiene command against +that baseline passed: exactly three owned files and zero added-line matches +in all four pattern categories. Packet/harness stay frozen for review. + +### V11 current-source provisional approvals and single authorized full scan + +Both renewed bounded reviewers approved the exact fourth frozen sources: +contract `msg_e1ef5c9ca3c8` and security `msg_459d779e1794`. These approvals +apply only to packet `bf18fd1fb55580dea79b36b15e4f2e010d3a8731` and harness +`c51a6c4ad1fe51763f2ccfc05c28c8cc1f48a858`; they do not erase prior HOLD +findings or serve as final-SHA/GitHub Codex approval. Coordinator authorization +`msg_e8417e24005e` allowed one fresh full-packet selector, with a local +three-file commit only if it passes. Failure/error would retain the freeze +and require reporting without a commit or automatic rerun. + +The writer checked both source blobs, acknowledged that authorization and +started the following trusted isolated selector immediately after UTC stamp +**2026-10-22T16:13:32Z**, local exec session `24008`: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations +``` + +This invokes the inspected trusted harness/scanner against packet shell/Python +AST data; it does not execute specimens or packet recipes. Packet and harness +remain frozen during the run; ledger-only receipts may be appended. No result, +elapsed time, completion stamp or success count is inferred in advance. + +### V11 fresh whole-packet PASS and local candidate preparation + +The single authorized full invocation in owned exec session `24008` completed +with **exit 0, one test in 917.190s, OK**. The trusted harness reported: + +```text +current packet static scan: 261 shell commands, 86 Python heredoc bodies, zero violations +``` + +Completion was observed and stamped at **2026-10-22T16:28:56Z**; this is the +observation timestamp, not an invented exact process-exit instant. The start +stamp remains **2026-10-22T16:13:32Z**. No restart or second invocation ran. +The post-result source identity check matched packet +`bf18fd1fb55580dea79b36b15e4f2e010d3a8731` and harness +`c51a6c4ad1fe51763f2ccfc05c28c8cc1f48a858`, exactly the two provisionally +reviewed inputs. The working tree contained only the three owned modifications. + +This fresh whole-process PASS covers the corrected V11 sources. It does not +borrow the baseline's PASS, erase prior HOLD/RED outcomes, or substitute for +exact-final-SHA review or GitHub Codex/PR-quick checks. The 109-method focused +PASS and expanded 328-helper/seven-proof compatibility PASS are separate +bounded receipts. No specimen, packet recipe, callback, coroutine or protocol +executed; the inspected trusted harness/scanner inspected AST/text data. + +Coordinator `msg_e8417e24005e` authorizes one local three-file commit after +this PASS and final source/hygiene checks. Push, GitHub writes, merge, Project +updates and final review coordination remain with the coordinator. No Go or +compiler matrix, live runner operation, workflow replay/dispatch, token/JIT +issuance, credential operation, Docker action or process cleanup ran. The +reviewed rollback remains a revert of this bounded correction against parent +`0b5b0c50f830bebf2dc32615ee714535f5200942`, preserving historical evidence. + +After the full-result receipt, `git diff --check` and the same introduced-delta +hygiene command against `0b5b0c50f830bebf2dc32615ee714535f5200942` passed: +exactly three owned files, zero added-line matches in all four categories. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index cfca0bbd..c51a6c4a 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8124,6 +8124,132 @@ def test_pr103_four_async_iterator_protocols(self) -> None: + '\nclass K: pass\n' + installation + '\nasync def collect():\n print([item async for item in K()])\n') is None, value == '"safe"') + def test_pr103_two_callback_protocol_names(self) -> None: + for callback in ('list(map(install, ["__str__"]))', + '[install(part) for part in ["__str__"]]'): + for expression in ('name', 'alias'): + for value in ('str(os.environ)', '"safe"'): + with self.subTest(callback=callback, expression=expression, value=value): + result = self.inspect('import os\nname = "label"\nalias = "label"\n' + + 'def leak(self):\n return ' + value + '\nclass K: pass\n' + + 'def install(name):\n alias = name\n setattr(K, ' + expression + + ', leak)\n' + callback + '\nprint(K())\n') + if callback.startswith('list(map'): + self.assertIsNotNone(result) + self.assertIn('unresolved member name', result) + else: + self.assertEqual(result is None, value == '"safe"') + self.assertIsNone(self.inspect('class K: pass\nfirst = "label"\nname = first\n' + + 'setattr(K, name, "safe")\nprint(K())\n')) + + def test_pr103_two_returned_async_iterators(self) -> None: + for returned in ('L()', 'iterator', 'alias', 'L() if flag else self'): + for installation in ('direct', 'K.__aiter__ = start', 'setattr(K, "__aiter__", start)'): + for value in ('os.environ', '"safe"'): + with self.subTest(returned=returned, installation=installation, value=value): + start = 'def start(self):\n return ' + returned + '\n' + definition = ('class K:\n def __aiter__(self):\n return ' + returned + + '\n' if installation == 'direct' else 'class K: pass\n' + start + installation + '\n') + result = self.inspect('import os\nclass L:\n async def __anext__(self):\n' + + ' return ' + value + '\niterator = L()\nalias = iterator\n' + + definition + 'async def collect():\n print([item async for item in K()])\n' + + 'collect().send(None)\n') + self.assertEqual(result is None, value == '"safe"') + for returned in ('unknown', 'alias'): + self.assertIsNotNone(self.inspect('alias = alias\nclass K:\n' + + ' def __aiter__(self):\n return ' + returned + + '\nasync def collect():\n print([item async for item in K()])\n')) + self.assertIsNone(self.inspect('class K:\n def __aiter__(self):\n return L()\n' + + 'class L:\n def __aiter__(self):\n return K()\n' + + ' async def __anext__(self):\n return "safe"\n' + + 'async def collect():\n print([item async for item in K()])\n')) + for setter in ('L.__anext__ = next_item', 'setattr(L, "__anext__", next_item)'): + for value in ('os.environ', '"safe"'): + for sink in ('async for item in K():\n print(item)', + 'print({item async for item in K()})', + 'print({"key": item async for item in K()})', + 'print((item async for item in K()))'): + with self.subTest(setter=setter, value=value, sink=sink): + self.assertEqual(self.inspect('import os\nclass L: pass\n' + + 'async def next_item(self):\n return ' + value + '\n' + setter + + '\nclass K: pass\nK.__aiter__ = lambda self: L()\n' + + 'async def collect():\n ' + sink + '\n') is None, value == '"safe"') + + def test_pr103_two_async_return_binding_alternatives(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for definition in ( + 'class K:\n def __aiter__(self):\n self = L()\n return self\n', + 'class K:\n def __aiter__(receiver):\n receiver = L()\n return receiver\n', + 'class K: pass\ndef start(receiver):\n receiver = L()\n return receiver\nK.__aiter__ = start\n', + 'class K:\n @staticmethod\n def __aiter__(receiver=L()):\n return receiver\n', + ): + with self.subTest(value=value, definition=definition): + self.assertEqual(self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return ' + value + '\n' + + definition + consumer) is None, value == '"safe"') + self.assertIsNone(self.inspect('class K:\n def __aiter__(receiver):\n' + + ' return receiver\n async def __anext__(receiver):\n' + + ' return "safe"\n' + consumer)) + for returned in ('L()', 'L() if flag else unknown', 'L() if flag else M()', + '(L if flag else unknown)()', '(L if flag else M)()'): + for value in ('os.environ', '"safe"'): + with self.subTest(returned=returned, value=value): + self.assertEqual(self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return ' + value + '\n' + + 'class M:\n async def __anext__(self):\n return "safe"\n' + + 'def choose():\n return ' + returned + '\nclass K:\n' + + ' def __aiter__(self):\n return choose()\n' + consumer) is None, + value == '"safe"' and 'unknown' not in returned and not returned.startswith('(')) + + def test_pr103_two_protocol_callable_proofs(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for method in ('__aiter__', '__anext__'): + for implementation in ('staticmethod(start)', 'unknown'): + with self.subTest(value=value, method=method, implementation=implementation): + result = self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return ' + value + '\n' + + 'def start(receiver=L()):\n return receiver\nclass K: pass\n' + + 'K.' + method + ' = ' + implementation + '\n' + consumer) + self.assertIsNotNone(result) + for setup in ('factory = L if flag else unknown', 'factory = L\nfactory = unknown', + 'factory = choose if flag else unknown', 'factory = choose\nfactory = unknown', + 'factory = L', 'factory = choose', 'factory = L if flag else M', + 'factory = L if flag else choose'): + with self.subTest(setup=setup): + result = self.inspect('class L:\n async def __anext__(self):\n return "safe"\n' + + 'class M:\n async def __anext__(self):\n return "safe"\n' + + 'def choose():\n return L()\n' + setup + '\nclass K:\n' + + ' def __aiter__(self):\n return factory()\n' + consumer) + self.assertEqual(result is None, 'unknown' not in setup) + + def test_pr103_two_assigned_protocol_receiver_proof(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for method in ('__aiter__', '__anext__'): + for setup, implementation in ( + ('', 'L().start'), ('bound = L().start\nalias = bound\n', 'alias'), + ('def implementation():\n return L().start\n', 'implementation()'), + ): + with self.subTest(value=value, method=method, setup=setup): + start = (' def start(self):\n return self\n' + + ' async def __anext__(self):\n return ' + value + '\n' + if method == '__aiter__' else + ' async def start(self):\n return ' + value + '\n') + other = '' if method == '__aiter__' else ' def __aiter__(self):\n return self\n' + result = self.inspect('import os\nclass L:\n' + start + + 'class K:\n' + (other or ' pass\n') + setup + + 'K.' + method + ' = ' + implementation + '\n' + consumer) + self.assertIsNotNone(result) + self.assertIsNone(self.inspect('def start(receiver):\n return receiver\n' + + 'first = start\nalias = first\nclass K:\n' + + ' async def __anext__(self):\n return "safe"\n' + + 'K.__aiter__ = alias\n' + consumer)) + def test_pr103_four_classmethod_decorator_aliases(self) -> None: for setup, decorator in (('cm = classmethod', 'cm'), ('first = classmethod\ncm = first', 'cm')): From 84e1f11cf08b3a1914c9446a00511e7739297c01 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 02:14:56 +0900 Subject: [PATCH 115/123] fix(evidence): close callback and async protocol gaps --- docs/evidence/g01-recovery-packet.md | 130 ++++++- .../issue79-pr103-review-6172592368.md | 338 ++++++++++++++++++ .../issue79_regression_test.py | 153 ++++++++ 3 files changed, 609 insertions(+), 12 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index bf18fd1f..cd8c5807 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22788,9 +22788,15 @@ def python_sensitive_read_violation(tree, parents): return [target for targets in resolved for target in targets] def error_operand_repr_from_class(classdef, seen, methods): - if id(classdef) in seen: + if methods == ("__await__",) and not any( + isinstance(entry, ast.FunctionDef) and entry.name == "__await__" + for entry in classdef.body) and not any(method == "__await__" + for method, _entry in assigned_display_methods.get(classdef, ())): + return True # Only a proven local await protocol may close this edge. + protocol_visit = (id(classdef), tuple(methods)) + if protocol_visit in seen: return False - seen.add(id(classdef)) + seen.add(protocol_visit) assigned_functions = {} for method, implementation in assigned_display_methods.get(classdef, ()): if method not in methods: @@ -22811,6 +22817,19 @@ def python_sensitive_read_violation(tree, parents): if any(returned_async_iterator_sensitive(result, implementation, classdef, seen, set()) for result in returns): return True + if "__anext__" in methods: + implementations = [entry for entry in classdef.body + if isinstance(entry, ast.FunctionDef) and entry.name == "__anext__"] + implementations.extend(entry for entry in assigned_functions.get("__anext__", ()) + if not isinstance(entry, ast.AsyncFunctionDef)) + for implementation in implementations: + returns = ([implementation.body] if isinstance(implementation, ast.Lambda) else + [part.value for part in ast.walk(implementation) + if isinstance(part, ast.Return) and part.value is not None + and python_enclosing_scope(part, parents) is implementation]) + if not returns or any(returned_async_iterator_sensitive(result, implementation, + classdef, seen, set(), ("__await__",)) for result in returns): + return True for method, implementation in assigned_display_methods.get(classdef, ()): if method in methods and display_implementation_sensitive(implementation, set()): return True @@ -22833,7 +22852,65 @@ def python_sensitive_read_violation(tree, parents): return True return False - def returned_async_iterator_sensitive(value, implementation, classdef, seen_classes, seen_values): + def receiver_setter_proven(value, seen): + if id(value) in seen: + return False + if python_unshadowed_builtin_reference(value, {"setattr"}, tree, parents): + return True + if (isinstance(value, ast.Attribute) and value.attr == "__setattr__" + and python_unshadowed_builtin_reference(value.value, {"object"}, tree, parents)): + return True + if isinstance(value, ast.IfExp): + alternatives = [value.body, value.orelse] + elif isinstance(value, ast.Name): + python_sensitive_imported_function_alias(value, tree, parents) + inventory = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in python_sensitive_alias_scope_chain(value, parents): + bindings = inventory.get((id(scope), value.id), ()) + if not bindings: + continue + if any(kind == "unknown" and isinstance(binding, ast.arg) + and not mutation_parameter_calls_closed(binding) for kind, binding in bindings): + return False # A default cannot close an unmodeled callback origin. + break + alternatives = [reference for reference in python_value_provenance(value, tree, parents) + if reference is not value] + else: + return False + return bool(alternatives) and all(receiver_setter_proven(reference, seen | {id(value)}) + for reference in alternatives) + + def receiver_class_mutated(implementation): + # A first formal proves identity, not an unchanged runtime class. + # Refuse these mutations at this boundary, including receiver aliases. + positional = list(implementation.args.posonlyargs) + list(implementation.args.args) + for part in ast.walk(implementation): + if isinstance(part, ast.Attribute) and part.attr == "__class__" and isinstance(part.ctx, (ast.Store, ast.Del)): + return True + if not isinstance(part, ast.Call): + continue + known_setter = len(part.args) >= 3 and receiver_setter_proven(part.func, set()) + if known_setter: + names = mutation_literal_names(part.args[1], set()) + if not names or "__class__" in names: + return True + continue + # No helper-effect interpreter: escaping the original receiver + # through a call cannot certify an unchanged runtime class. + escaped = list(part.args) + [keyword.value for keyword in part.keywords] + if isinstance(part.func, ast.Attribute): + escaped.append(part.func.value) + if positional and any(isinstance(reference, ast.Name) + and reference.id == positional[0].arg + and python_enclosing_scope(reference, parents) is implementation + for argument in escaped for expression in ast.walk(argument) + if isinstance(expression, ast.expr) + for reference in python_value_provenance(expression, tree, parents)): + return True + return False + + def returned_async_iterator_sensitive(value, implementation, classdef, seen_classes, seen_values, + protocols=("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__")): if id(value) in seen_values: return True seen_values = seen_values | {id(value)} @@ -22847,21 +22924,20 @@ def python_sensitive_read_violation(tree, parents): and not getattr(implementation, "decorator_list", ()) and bindings and all(kind == "unknown" and binding is positional[0] for kind, binding in bindings)): - return False # This class's protocols are already inspected. + return protocols == ("__await__",) or receiver_class_mutated(implementation) alternatives = [reference for reference in python_value_provenance(value, tree, parents) if reference is not value] return not alternatives or any(returned_async_iterator_sensitive(reference, - implementation, classdef, seen_classes, seen_values) for reference in alternatives) + implementation, classdef, seen_classes, seen_values, protocols) for reference in alternatives) if isinstance(value, ast.IfExp): return any(returned_async_iterator_sensitive(reference, implementation, classdef, - seen_classes, seen_values) for reference in (value.body, value.orelse)) + seen_classes, seen_values, protocols) for reference in (value.body, value.orelse)) if isinstance(value, ast.Call): targets = protocol_callable_targets(value.func, set()) if not targets: return True classes = [target for target in targets if isinstance(target, ast.ClassDef)] - if any(error_operand_repr_from_class(reference, set(seen_classes), - ("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__")) for reference in classes): + if any(error_operand_repr_from_class(reference, set(seen_classes), protocols) for reference in classes): return True for target in targets: if isinstance(target, ast.ClassDef): @@ -22873,12 +22949,11 @@ def python_sensitive_read_violation(tree, parents): if isinstance(part, (ast.Return, ast.Yield, ast.YieldFrom)) and part.value is not None and python_enclosing_scope(part, parents) is target]) if not returned or any(returned_async_iterator_sensitive(reference, - implementation, classdef, seen_classes, seen_values) for reference in returned): + implementation, classdef, seen_classes, seen_values, protocols) for reference in returned): return True return False if isinstance(value, ast.ClassDef): - return error_operand_repr_from_class(value, set(seen_classes), - ("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__")) + return error_operand_repr_from_class(value, set(seen_classes), protocols) return True def display_implementation_sensitive(value, seen): @@ -23035,11 +23110,42 @@ def python_sensitive_read_violation(tree, parents): return True assigned_display_methods = {} - display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__aiter__", "__anext__"} + display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__aiter__", "__anext__", "__await__"} + def mutation_parameter_calls_closed(parameter): + implementation = parents.get(parents.get(parameter)) + if not isinstance(implementation, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + return False + direct = False + for reference in ast.walk(tree): + if not isinstance(reference, ast.Name) or not isinstance(reference.ctx, ast.Load): + continue + if implementation not in python_value_provenance(reference, tree, parents): + continue + owner = parents.get(reference) + if isinstance(owner, ast.Call) and owner.func is reference: + direct = True + elif (isinstance(owner, (ast.Assign, ast.AnnAssign)) and owner.value is reference + and all(isinstance(target, ast.Name) for target in + (owner.targets if isinstance(owner, ast.Assign) else [owner.target]))): + continue # The same scan checks all uses of these local aliases. + else: + return False # Callback/container/descriptor escape is not modeled. + return direct + def mutation_literal_names(value, seen): if id(value) in seen: return set() if isinstance(value, ast.Name): + python_sensitive_imported_function_alias(value, tree, parents) + inventory = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in python_sensitive_alias_scope_chain(value, parents): + bindings = inventory.get((id(scope), value.id), ()) + if not bindings: + continue + if any(kind == "unknown" and isinstance(binding, ast.arg) + and not mutation_parameter_calls_closed(binding) for kind, binding in bindings): + return set() + break alternatives = [reference for reference in python_value_provenance(value, tree, parents) if reference is not value] values = [mutation_literal_names(reference, seen | {id(value)}) for reference in alternatives] diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index 5a400702..766844c1 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -2442,3 +2442,341 @@ reviewed rollback remains a revert of this bounded correction against parent After the full-result receipt, `git diff --check` and the same introduced-delta hygiene command against `0b5b0c50f830bebf2dc32615ee714535f5200942` passed: exactly three owned files, zero added-line matches in all four categories. +### V12 three-P1 authoritative reproduction and bounded correction checkpoint + +This correction starts from clean pushed head +`0a805c6ad62b9854f4ae36d48a1fd15e43178647`, packet +`bf18fd1fb55580dea79b36b15e4f2e010d3a8731`, harness +`c51a6c4ad1fe51763f2ccfc05c28c8cc1f48a858` and ledger +`5a400702ba8eb9c67e5087c6daae8009bcc2af41`. The maintainer explicitly selected +Codex `gpt-6.1-sol` / `medium`; the coordinator retains GitHub writes, push, +merge, Project state and subsequent validation gates. The complete +[Codex report 6345980866](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6345980866) +was read with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6345980866`. +All earlier receipts and strictly source-scoped import/decorator rebuttals +remain historical evidence, not approval of this correction. + +All three findings reproduce at authoritative `inspect_python_heredoc`. +Every specimen remains inert source text parsed by the inspected trusted +harness/scanner. No specimen, callback, coroutine, protocol or packet recipe +was executed, compiled, evaluated or imported. + +| Report finding, in order | Disposition and bounded correction | Adjacent contrasts | +|---|---|---| +| Defaulted callback protocol name | Reproduced P1. The existing binding inventory identifies parameter origin before following defaults. Only a function with supported direct calls and simple local aliases, with no unmodeled value escape, can close this parameter boundary. A higher-order callback remains unresolved even if an additional direct call or a harmless default is visible. | Defaulted parameter and local alias; callback alone and beside a direct call; sensitive and harmless implementations both refuse for the unresolved policy. Supported default-only calls, explicit direct calls, direct callable aliases and literal/local-name aliases retain their paired behavior. | +| Awaitable returned by ordinary `__anext__` | Reproduced P1 with the valid generator-based `__await__` below. Ordinary local/assigned `__anext__` returns reuse the existing return/class graph with `__await__` as the selected protocol; absent or unproven awaitable targets refuse. | Constructor, assigned object and alias; direct, assigned and setter-installed `__anext__`; sensitive returns refuse and literal-return awaitables pass. Unknown and mixed known/unknown awaitables refuse; existing safe native async methods pass. | +| Receiver runtime class rebinding | Reproduced P1. Before exempting an unchanged first formal, refuse visible reflective or direct `__class__` mutation in that implementation. This is conservative refusal at the receiver boundary, not a generic descriptor or possible-class interpreter. | `object.__setattr__`, builtin `setattr`, direct class assignment and simple receiver-alias counterparts; both sensitive and harmless replacement classes refuse. An ordinary safe instance-label assignment remains accepted. | + +The finite valid awaitable specimen is recorded here as **inert AST text only**: + +```python +import os +class Awaitable: + def __await__(self): + if False: + yield None + return os.environ +class L: + def __anext__(self): + return Awaitable() +class K: + def __aiter__(self): + return L() +async def collect(): + async for item in K(): + print(item) + break +collect().send(None) +``` + +The unreachable yield makes `__await__` a generator returning the environment +as its completion value. A plain ordinary `def __await__: return os.environ` +does not supply a valid await iterator and is **not** claimed as a demonstrated +runtime leak. The class-mutation specimen uses the same finite consumer, +ordinary compatible heap classes, `L.async __anext__` returning the environment, +and `K.__aiter__` calling `object.__setattr__(self, "__class__", L)` before +returning `self`. These are semantic observations about AST text; neither +specimen was run and no runtime output observation is claimed. + +The exact isolated prefix remains: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py +``` + +Before any packet edit, prefix plus `-k test_pr103_three` produced **three +methods, 27 failed assertions, zero errors in 0.454s, exit 1**. Classification: +17 sensitive unsafe accepts (four default-callback, nine returned-awaitable, +four reflective-class cases), eight harmless cases accepted where conservative +refusal is required (four callback, four class-mutation), and two unresolved +awaitable refusals missing. Direct class assignments were already refused. +This is not 27 demonstrated leaks. The first correction run produced **three +methods, six failures in 0.408s, exit 1**: safe assigned/aliased awaitables were +refused because provenance also includes their constructor class. Moving the +local await-protocol proof to the shared class boundary preserved that known +alternative. Prefix plus `-k test_pr103_three` then passed **three methods in +0.411s, exit 0** without changing the regression expectations. + +The unchanged combined focused command recorded above (`-k test_pr103` plus +the same 21 adjacent selectors) passed **112 methods in 21.095s, exit 0**, +retaining all prior 109 and adding the three new methods. The **exact same +expanded V9 preflight command** recorded above passed in **12.158s, exit 0**: +206 root helpers, 328 called original helpers including nested definitions, +seven retained scoped regex-receiver proofs, zero new collisions or lost +proofs. The focused command and preflight ran as separate processes with +overlapping lifetimes. These are bounded checks, not a fresh whole-packet PASS. + +This correction adds no generic higher-order, descriptor or async engine, +source-name exception, policy allowlist expansion or weakened safety gate. +The three owned files are the entire authorized change. One fresh whole-packet +scan, independent provisional reviews, local commit and final-SHA reviews +remain gated by the coordinator. No Go/full matrix, hosted CI, live/canary, +runner, JIT, credential, Docker, workflow dispatch, GitHub write, commit, +push or merge was performed. Rollback is a reviewed revert of this bounded +three-file correction against `0a805c6ad62b9854f4ae36d48a1fd15e43178647`, +preserving the historical receipts. + +Frozen V12 inputs are packet `e2b91f4b20cdfef95c46640abcc55d1717cd6f96` +and harness `688d7f335928d2269a75a832c25484cf358c48fc`. +`git diff --check` passed. The identical V10 introduced-delta hygiene command +above, replacing only its comparison base with +`0a805c6ad62b9854f4ae36d48a1fd15e43178647`, passed: exactly the three owned +files, zero added-line matches for personal-home paths, GitHub token patterns, +private-key markers and AWS access-key patterns. This is a bounded pattern +check, not an exhaustive secret audit. Packet/harness stay frozen for the +coordinator's two independent provisional reviews; ledger-only receipts may +be added while the source inputs remain unchanged. + +### V12 provisional HOLD and same-boundary cycle/receiver correction + +The first frozen V12 source above did **not** receive provisional approval. +The writer's post-freeze, read-only authoritative probe found that an ordinary +`L.__anext__` returning `L()` was accepted even when the same class's valid +generator-based `L.__await__` returns `os.environ`. The iterator-only class +visit suppressed a subsequent visit for a different protocol. The coordinator +also identified an immediate local helper `mutate(obj)` calling +`object.__setattr__(obj, "__class__", L)`; `K.__aiter__` calls `mutate(self)` +and returns `self`, while `L.async __anext__` returns the environment. Both use +the finite print/break consumer recorded above and remain inert AST text. +No protocol or specimen was run. + +Both independent readers confirmed HOLD and paused source reads: contract +`msg_1f4fb381a756`, security `msg_99b80505b33d`. The coordinator's +`msg_b39ba7dbaa32` explicitly cleared both barriers and authorized one bounded +batch. No source edits occurred between the first freeze and that barrier. +The contract review's eight callback contrasts passed; no wider callback +sweep was requested or performed. The coordinator also reported historical +baseline quick check `39814729928` as SUCCESS at `0a805c6`; this is not a +quick-check receipt for this uncommitted correction. + +Before editing the packet again, the isolated prefix plus +`-k test_pr103_three_same_class -k test_pr103_three_helper` produced **two +methods, 11 failed assertions, zero errors in 0.282s, exit 1**. Five failures +are sensitive unsafe accepts (one same-class awaitable, four helper call +forms); four are harmless replacement classes needing conservative refusal. +The other two are unsupported-helper refusal controls: an inert helper and a +recursive helper. A nonterminating recursive helper is not runtime leak +evidence. The same-class safe awaitable was already accepted and remains so. + +The class cycle identity now includes both class identity and selected +protocols, preserving inspection of `__await__` after iteration while retaining +finite cycle protection. Before exempting an unchanged receiver, the boundary +also refuses receiver escape through positional/keyword call arguments or +method receivers, following the existing cycle-protected provenance graph. +No recursive helper-effect analyzer was added: even an inert local helper is +conservatively unsupported when passed the receiver. Direct helper, function +alias, receiver alias and keyword forms are covered. Existing direct instance +label assignment and known literal `setattr(self, "label", "safe")` remain +accepted. Unknown and recursive helper escapes refuse rather than returning a +blanket insensitive result. Prefix plus `-k test_pr103_three` passed **five +methods in 0.459s, exit 0** after the minimal batch. + +The unchanged combined focused command passed **114 methods in 21.488s, +exit 0**, retaining the previous 112. The exact expanded V9 preflight command +passed in **12.383s, exit 0**: 206 root helpers, 328 called original helpers +including nested definitions, seven retained scoped regex-receiver proofs, +zero new collisions or lost proofs. These independent processes overlapped +in lifetime. `git diff --check` passed. The same introduced-delta hygiene +command against `0a805c6ad62b9854f4ae36d48a1fd15e43178647` passed with exactly +the three owned files and zero matches in all four documented categories. + +Renewed frozen inputs are packet `1fe29588af73dcdbbb83deac4a67ee33874cc4ce` +and harness `db980be786f9a5f0fdfc181b4ca2c2a55ec6c7fb`. Earlier V12 receipts +remain attributed to their original inputs. The renewed inputs await two +bounded provisional reviews and explicit authorization for one fresh full +scan. No fresh full scan or local commit has run; GitHub Codex, exact-head +quick checks and merge remain separate coordinator gates. Rollback remains a +reviewed revert of this three-file correction against `0a805c6`. + +### V12 all-target setter exemption correction checkpoint + +The renewed provisional approvals `msg_33a1881068b3` and `msg_70d5dc550448` +were superseded before any full scan. The coordinator's source audit found +that `known_setter = any(...)` could exempt a mixed builtin-setter/local-helper +target and discard the helper's mutation effect. Security confirmed the +bounded case and HOLD in `msg_7af2950c9f8e`; contract acknowledged its no-read +barrier in `msg_76d66ac82f7e`. Coordinator `msg_5c9282fc962b` explicitly cleared +both barriers and authorized the minimal all-target correction. Source stayed +frozen until that authorization; no wider callback or effect sweep occurred. + +The new inert regression uses `setter = setattr if flag else mutate` and +`setter(self, "label", "x")` before returning `self`; `mutate(obj, name, val)` +sets the receiver's class to `L`, whose async next method returns the +environment. Both the report's unbound-flag shape and a complete `flag = False` +shape are retained. The former is unresolved policy-path evidence, not +demonstrated runtime output; only the latter supplies the complete finite +sensitive path. Mixed known/unknown callable alternatives also require +refusal, while known/known genuine setters retain the safe literal label. + +Before the packet edit, the isolated prefix plus +`-k test_pr103_three_receiver_setter` produced **one method, eight failed +assertions, zero errors in 0.281s, exit 1**. Classification: one complete finite +sensitive accept, one unresolved-flag sensitive-path accept, two harmless +mutating-helper accepts requiring conservative refusal, and four unresolved +callable-target accepts. The shadowed-`object` counterpart was already refused; +it is a preserved control, not a newly demonstrated leak. Known builtin +setter aliases and the known/known conditional positive already passed. + +The receiver exemption now requires a recursive, cycle-protected proof of +**all** selected setter alternatives; a helper, unknown target or cycle cannot +certify the exemption and falls through to receiver-escape refusal. The +`object.__setattr__` arm also requires the existing unshadowed-builtin proof +for `object`, rather than dotted spelling alone. This is a boundary proof, +not an effects interpreter. Prefix plus `-k test_pr103_three` passed **six +methods in 0.513s, exit 0**. The unchanged combined command passed **115 +methods in 20.896s, exit 0**. The identical expanded V9 preflight passed in +**12.136s, exit 0** with 206 root/328 including nested called original helpers, +seven retained scoped regex proofs and zero collisions/lost proofs. The +focused and preflight processes overlapped in lifetime. + +Renewed frozen sources are packet `fb1f473d05d401ea25c9ec977b370392d34745df` +and harness `5a2c145ff84f8cd8503e7bec9f6642baa9707495`. +`git diff --check` and the unchanged introduced-delta hygiene command against +`0a805c6ad62b9854f4ae36d48a1fd15e43178647` passed: exactly the three owned +files and zero matches in all four documented added-line categories. The +candidate awaits renewed bounded provisional review and explicit full-scan +authorization. No full scan, commit, push, GitHub write or live operation has +occurred for V12; historical results remain attributed to their own sources. + +### V12 third provisional approvals and crossed full-scan authorization HOLD + +The coordinator accepted third-freeze provisional approvals from contract +`msg_9ab728a62c17` and security `msg_0637f69bff9e`, against the exact frozen +`fb1f473d` packet and `5a2c145f` harness. Security disclosed that its first +probe command exited 1 because an accidentally cyclic alias had been expected +to pass; the actual refusal conforms to the cycle policy. The corrected +intended acyclic alias and three remaining contrasts passed in **0.278s**. +The first command is not represented as passing. + +Coordinator `msg_c686ea35c4a5` authorized one fresh isolated full-packet scan +with PASS-only local commit. Before starting, the writer reported an +unreproduced origin concern in `msg_4efbd1b03aac`: the setter-target proof +also uses formal-default provenance, so its all-target closure may require +the same parameter-origin qualification as the callback-name fix. The writer +asked about the crossed authorization before launching a scan. The coordinator +replied to hold for only that bounded origin review, requesting a finite +closure-installed protocol/defaulted-setter callback contrast from the same +security reviewer. No new exploratory sweep, source edit, full scan or commit +occurred while that result and any necessary reader barriers remain pending. + +### V12 formal-origin setter correction after bounded reproduction + +Security reproduced the defaulted-setter closure case and recorded HOLD plus +its no-read barrier in `msg_e8dc4ca8fb47`; contract acknowledged its barrier +in `msg_c2618941abcc`. Coordinator `msg_91ad9ca8cbeb` cleared both barriers +and authorized the bounded formal-origin correction, superseding the third +freeze's provisional approvals. The writer had not started a full scan. + +The new inert regression installs a closure as `K.__aiter__` from +`install(setter=setattr)`, then invokes the installer via +`list(map(install, [mutate]))`. The closure calls the supplied setter on the +receiver before returning it. `mutate(obj, name, val)` changes its class to +`L`, whose async next method returns the environment. The finite consumer +is unchanged. The method also covers a simple captured formal alias, a direct +call alongside the callback, explicit direct mutation-helper calls, default-only +installation and explicit builtin-setter installation, with harmless pairs. + +Before changing the packet, the isolated prefix plus +`-k test_pr103_three_defaulted_setter` produced **one method, eight failed +assertions, zero errors in 0.394s, exit 1**: four sensitive callback accepts +and four harmless callback accepts requiring conservative refusal. Direct +mutation-helper refusal and supported default-only/explicit builtin-setter +controls already passed. No specimen or closure was executed. + +The setter-target proof now consults the existing binder inventory and +`mutation_parameter_calls_closed` before following formal defaults. Unknown +callback invocation origins remain unproven even beside a known direct call; +supported direct-only origins retain their prior behavior. No callback engine +or additional effects model was introduced. Prefix plus `-k test_pr103_three` +passed **seven methods in 0.687s, exit 0**. + +The unchanged combined focused command passed **116 methods in 22.034s, +exit 0**, retaining the prior 115. The identical expanded V9 preflight passed +in **12.456s, exit 0**: 206 root helpers, 328 called original helpers including +nested definitions, seven retained scoped regex-receiver proofs and zero new +collisions or lost proofs. Those processes overlapped in lifetime. +`git diff --check` and the same introduced-delta hygiene command against +`0a805c6ad62b9854f4ae36d48a1fd15e43178647` passed, with exactly the three +owned files and zero matches in all four documented categories. + +Renewed frozen inputs are packet `cd8c5807d676814e2ba86a3b3f6a85b2bd4b4137` +and harness `384505a769fae389fa382c06dd844d69aefa5365`. They await renewed +bounded provisional review and explicit exact-source full-scan authorization. +No whole-packet scan or commit has run for V12, and no prior provisional +approval or full-scan authorization is carried forward to these new inputs. + +### V12 fourth-source provisional approvals and authorized fresh full scan + +The coordinator accepted contract `msg_ea8abbacc9a5` and security +`msg_dc23dac12670` provisional approvals for packet `cd8c5807` and harness +`384505a7`. Contract distinguished its scanner-only `K: pass` positive controls +from two separately verified valid native-async safe controls; the scanner-only +controls are not runtime validity evidence. These are internal provisional +approvals, not exact-final-SHA or GitHub Codex approval. + +Coordinator `msg_742343eebe3f` superseded the earlier pre-start HOLD and +authorized exactly one fresh isolated full-packet selector on these frozen +inputs, with local commit only after actual PASS. The writer started at +**2026-10-22T16:58:38Z**, owned process session `24322`, using: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations +``` + +This invokes only the inspected trusted scanner/harness against packet data. +No specimen, callback, coroutine, protocol or recipe executes. Packet/harness +remain frozen; only ledger receipts may change during the pending run. No +previous V12 full scan was started, and no pending outcome is claimed as PASS. + +### V12 fresh whole-packet PASS and local candidate preparation + +The single authorized session `24322` completed with **exit 0, one test in +950.351s, OK**. Completion was observed at **2026-10-22T17:14:33Z**; this is +the observation timestamp, not an invented exact process-exit timestamp. The +trusted harness reported: + +```text +current packet static scan: 261 shell commands, 86 Python heredoc bodies, zero violations +``` + +The tested frozen packet remains `cd8c5807d676814e2ba86a3b3f6a85b2bd4b4137` +and harness remains `384505a769fae389fa382c06dd844d69aefa5365`, rechecked after +completion. No packet/harness edit, restart, duplicate scan or specimen +execution occurred during the run. Earlier V12 provisional receipts and +pre-start HOLDs retain their own input identities; this fresh PASS is not +borrowed from V11. The 116-method focused PASS and expanded 328-helper/seven +regex-proof compatibility PASS remain separate evidence. + +Coordinator `msg_742343eebe3f` permits one local commit of the three owned +files after this actual PASS and final diff/hygiene checks. Exact-final-SHA +independent reviews remain pending and separate from these provisional +approvals. Push, GitHub writes, GitHub Codex exact-head review, hosted quick +check, Project state and merge remain coordinator-owned. No live, runner, +JIT, credential, Docker or workflow operation occurred; G01/G02 gates are not +declared complete by this static evidence correction. + +Final `git diff --check` and the identical introduced-delta hygiene command +against `0a805c6ad62b9854f4ae36d48a1fd15e43178647` passed after this result +was recorded: exactly the three owned files, with zero added-line matches +for all four documented categories. Rollback is a reviewed revert of this +bounded three-file correction against that immutable parent. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index c51a6c4a..384505a7 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8250,6 +8250,159 @@ def test_pr103_two_assigned_protocol_receiver_proof(self) -> None: + ' async def __anext__(self):\n return "safe"\n' + 'K.__aiter__ = alias\n' + consumer)) + def test_pr103_three_default_callback_protocol_names(self) -> None: + for expression in ('name', 'alias'): + for value in ('str(os.environ)', '"safe"'): + for calls in ('list(map(install, ["__str__"]))', + 'install()\nlist(map(install, ["__str__"]))'): + with self.subTest(expression=expression, value=value, calls=calls): + result = self.inspect('import os\nclass K: pass\n' + + 'def leak(self):\n return ' + value + '\n' + + 'def install(name="label"):\n alias = name\n' + + ' setattr(K, ' + expression + ', leak)\n' + + calls + '\nprint(K())\n') + self.assertIsNotNone(result) + self.assertIn('unresolved member name', result) + for calls in ('install()', 'install("__str__")', + 'direct = install\ndirect("__str__")'): + for value in ('str(os.environ)', '"safe"'): + with self.subTest(calls=calls, value=value): + self.assertEqual(self.inspect('import os\nclass K: pass\n' + + 'def leak(self):\n return ' + value + '\n' + + 'def install(name="label"):\n alias = name\n' + + ' setattr(K, alias, leak)\n' + calls + '\nprint(K())\n') is None, + calls == 'install()' or value == '"safe"') + self.assertIsNone(self.inspect('class K: pass\nname="label"\nalias=name\n' + + 'setattr(K, alias, "safe")\nprint(K())\n')) + + def test_pr103_three_returned_anext_awaitables(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for returned in ('Awaitable()', 'result', 'alias'): + for installation in ('direct', 'L.__anext__ = advance', + 'setattr(L, "__anext__", advance)'): + with self.subTest(value=value, returned=returned, installation=installation): + method = ('class L:\n def __anext__(self):\n return ' + returned + '\n' + if installation == 'direct' else + 'class L: pass\ndef advance(self):\n return ' + returned + '\n' + + installation + '\n') + self.assertEqual(self.inspect('import os\nclass Awaitable:\n' + + ' def __await__(self):\n if False:\n yield None\n' + + ' return ' + value + '\nresult = Awaitable()\nalias = result\n' + + method + 'class K:\n def __aiter__(self):\n return L()\n' + + consumer) is None, value == '"safe"') + for returned in ('unknown', 'Awaitable() if flag else unknown'): + with self.subTest(returned=returned): + self.assertIsNotNone(self.inspect('class Awaitable:\n' + + ' def __await__(self):\n if False:\n yield None\n' + + ' return "safe"\nclass L:\n def __anext__(self):\n' + + ' return ' + returned + '\nclass K:\n' + + ' def __aiter__(self):\n return L()\n' + consumer)) + self.assertIsNone(self.inspect('class K:\n def __aiter__(self):\n' + + ' return self\n async def __anext__(self):\n return "safe"\n' + consumer)) + + def test_pr103_three_async_receiver_class_mutation(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for mutation in ('object.__setattr__(self, "__class__", L)', + 'self.__class__ = L', 'setattr(self, "__class__", L)', + 'alias = self\n object.__setattr__(alias, "__class__", L)', + 'alias = self\n alias.__class__ = L', + 'alias = self\n setattr(alias, "__class__", L)'): + with self.subTest(value=value, mutation=mutation): + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return ' + value + '\n' + + 'class K:\n def __aiter__(self):\n ' + mutation + + '\n return self\n' + consumer)) + self.assertIsNone(self.inspect('class K:\n def __aiter__(self):\n' + + ' self.label = "safe"\n return self\n' + + ' async def __anext__(self):\n return "safe"\n' + consumer)) + + def test_pr103_three_same_class_await_protocol_cycle(self) -> None: + for value in ('os.environ', '"safe"'): + with self.subTest(value=value): + self.assertEqual(self.inspect('import os\nclass L:\n' + + ' def __anext__(self):\n return L()\n' + + ' def __await__(self):\n if False:\n yield None\n' + + ' return ' + value + '\nclass K:\n' + + ' def __aiter__(self):\n return L()\n' + + 'async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') is None, + value == '"safe"') + + def test_pr103_three_helper_receiver_class_mutation(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for call in ('mutate(self)', 'alias(self)', 'mutate(obj=self)', + 'receiver = self\n alias(obj=receiver)'): + with self.subTest(value=value, call=call): + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return ' + value + '\n' + + 'def mutate(obj):\n object.__setattr__(obj, "__class__", L)\n' + + 'alias = mutate\nclass K:\n def __aiter__(self):\n ' + + call + '\n return self\n' + consumer)) + for helper in ('def helper(obj):\n return None\n', + 'def helper(obj):\n helper(obj)\n'): + with self.subTest(helper=helper): + self.assertIsNotNone(self.inspect(helper + 'class K:\n' + + ' def __aiter__(self):\n helper(self)\n return self\n' + + ' async def __anext__(self):\n return "safe"\n' + consumer)) + self.assertIsNone(self.inspect('class K:\n def __aiter__(self):\n' + + ' setattr(self, "label", "safe")\n return self\n' + + ' async def __anext__(self):\n return "safe"\n' + consumer)) + + def test_pr103_three_receiver_setter_target_exhaustiveness(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for flag in ('', 'flag = False\n'): + for alternative in ('mutate', 'unknown'): + with self.subTest(value=value, flag=flag, alternative=alternative): + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return ' + value + '\n' + + 'def mutate(obj, name, val):\n' + + ' object.__setattr__(obj, "__class__", L)\n' + flag + + 'setter = setattr if flag else ' + alternative + '\n' + + 'class K:\n def __aiter__(self):\n' + + ' setter(self, "label", "x")\n return self\n' + consumer)) + for setter in ('setattr', 'object.__setattr__', + 'setattr if flag else object.__setattr__'): + with self.subTest(setter=setter): + self.assertIsNone(self.inspect('flag = False\nsetter = ' + setter + + '\nclass K:\n def __aiter__(self):\n' + + ' setter(self, "label", "safe")\n return self\n' + + ' async def __anext__(self):\n return "safe"\n' + consumer)) + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return os.environ\n' + + 'class Replacement:\n @staticmethod\n' + + ' def __setattr__(obj, name, val):\n' + + ' obj.__class__ = L\nobject = Replacement\n' + + 'class K:\n def __aiter__(self):\n' + + ' object.__setattr__(self, "label", "x")\n return self\n' + consumer)) + + def test_pr103_three_defaulted_setter_formal_origins(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for setter in ('setter', 'alias'): + for calls in ('list(map(install, [mutate]))', + 'install()\nlist(map(install, [mutate]))', + 'install(mutate)', 'install()', 'install(setattr)'): + with self.subTest(value=value, setter=setter, calls=calls): + result = self.inspect('import os\nclass L:\n' + + ' async def __anext__(self):\n return ' + value + '\n' + + 'def mutate(obj, name, val):\n' + + ' object.__setattr__(obj, "__class__", L)\nclass K:\n' + + ' async def __anext__(self):\n return "safe"\n' + + 'def install(setter=setattr):\n alias = setter\n' + + ' def start(self):\n ' + setter + + '(self, "label", "x")\n return self\n' + + ' K.__aiter__ = start\n' + calls + '\n' + consumer) + self.assertEqual(result is None, calls in ('install()', 'install(setattr)')) + def test_pr103_four_classmethod_decorator_aliases(self) -> None: for setup, decorator in (('cm = classmethod', 'cm'), ('first = classmethod\ncm = first', 'cm')): From eaa84ceeee9ed9aff0aeb73b2a058159a19cb8fc Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 02:54:25 +0900 Subject: [PATCH 116/123] fix(evidence): close assigned protocol escape gaps --- docs/evidence/g01-recovery-packet.md | 51 +++- .../issue79-pr103-review-6172592368.md | 251 ++++++++++++++++++ .../issue79_regression_test.py | 103 +++++++ 3 files changed, 396 insertions(+), 9 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index cd8c5807..3f8e4bd6 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22830,9 +22830,17 @@ def python_sensitive_read_violation(tree, parents): if not returns or any(returned_async_iterator_sensitive(result, implementation, classdef, seen, set(), ("__await__",)) for result in returns): return True - for method, implementation in assigned_display_methods.get(classdef, ()): - if method in methods and display_implementation_sensitive(implementation, set()): - return True + for method, implementations in assigned_functions.items(): + for implementation in implementations: + if isinstance(implementation, ast.Lambda): + if error_operand_sensitive(implementation.body): + return True + elif any(isinstance(candidate, (ast.Return, ast.Yield, ast.YieldFrom)) + and candidate.value is not None + and python_enclosing_scope(candidate, parents) is implementation + and error_operand_sensitive(candidate.value) + for candidate in ast.walk(implementation)): + return True for entry in classdef.body: if ( isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) @@ -22884,9 +22892,29 @@ def python_sensitive_read_violation(tree, parents): # A first formal proves identity, not an unchanged runtime class. # Refuse these mutations at this boundary, including receiver aliases. positional = list(implementation.args.posonlyargs) + list(implementation.args.args) + def reaches_receiver(value): + return positional and any(isinstance(reference, ast.Name) + and reference.id == positional[0].arg + and python_enclosing_scope(reference, parents) is implementation + for expression in ast.walk(value) if isinstance(expression, ast.expr) + for reference in python_value_provenance(expression, tree, parents)) for part in ast.walk(implementation): if isinstance(part, ast.Attribute) and part.attr == "__class__" and isinstance(part.ctx, (ast.Store, ast.Del)): return True + if (isinstance(part, ast.Name) and isinstance(part.ctx, ast.Load) + and python_enclosing_scope(part, parents) is not implementation + and reaches_receiver(part)): + return True # Captured receivers cannot certify unchanged identity. + if isinstance(part, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) and part.value is not None: + if reaches_receiver(part.value): + targets = part.targets if isinstance(part, ast.Assign) else [part.target] + if not isinstance(part.value, ast.Name) or not all( + isinstance(target, ast.Name) + and python_sensitive_alias_binding_scope(target, parents) is implementation + and all(kind not in {"global", "nonlocal"} for kind, _binding in + getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}).get( + (id(implementation), target.id), ())) for target in targets): + return True # Only proven simple local receiver aliases remain supported. if not isinstance(part, ast.Call): continue known_setter = len(part.args) >= 3 and receiver_setter_proven(part.func, set()) @@ -22900,12 +22928,7 @@ def python_sensitive_read_violation(tree, parents): escaped = list(part.args) + [keyword.value for keyword in part.keywords] if isinstance(part.func, ast.Attribute): escaped.append(part.func.value) - if positional and any(isinstance(reference, ast.Name) - and reference.id == positional[0].arg - and python_enclosing_scope(reference, parents) is implementation - for argument in escaped for expression in ast.walk(argument) - if isinstance(expression, ast.expr) - for reference in python_value_provenance(expression, tree, parents)): + if any(reaches_receiver(argument) for argument in escaped): return True return False @@ -23125,6 +23148,7 @@ def python_sensitive_read_violation(tree, parents): if isinstance(owner, ast.Call) and owner.func is reference: direct = True elif (isinstance(owner, (ast.Assign, ast.AnnAssign)) and owner.value is reference + and python_class_namespace_owner(owner, parents) is None and all(isinstance(target, ast.Name) for target in (owner.targets if isinstance(owner, ast.Assign) else [owner.target]))): continue # The same scan checks all uses of these local aliases. @@ -23215,6 +23239,15 @@ def python_sensitive_read_violation(tree, parents): constructors.extend((constructor.body, constructor.orelse)) elif isinstance(constructor, ast.Subscript): constructors.extend(error_operand_subscript_values(constructor)) + elif isinstance(constructor, ast.Call): + returned_classes = protocol_callable_targets(constructor, set()) + if not returned_classes or any(not isinstance(target, ast.ClassDef) + for target in returned_classes): + return True # Unknown/cyclic returned constructors cannot close this edge. + constructors.extend(returned_classes) + elif isinstance(constructor, ast.ClassDef): + if error_operand_repr_from_class(constructor, set(), methods): + return True if not isinstance(constructor, ast.Name): continue chain = python_lexical_scope_chain( diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index 766844c1..467e7a48 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -2780,3 +2780,254 @@ against `0a805c6ad62b9854f4ae36d48a1fd15e43178647` passed after this result was recorded: exactly the three owned files, with zero added-line matches for all four documented categories. Rollback is a reviewed revert of this bounded three-file correction against that immutable parent. + +### V13 three P1 correction and first frozen checkpoint + +The complete API body of [Codex report 6346898328](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6346898328) +was read against immutable head `84e1f11cf08b3a1914c9446a00511e7739297c01`. +All three P1 findings are reproduced blocking accepts at that head; none is +rebutted or deferred. The current maintainer dispatch explicitly selects +`gpt-6.1-sol` with medium reasoning for this correction. Historical model, +review, full-scan and quick-check receipts above retain their original scope. + +| Finding at the report's immutable source | Correction and finite contrasts | +| --- | --- | +| Receiver assignment escapes, original lines 22887–22891 | Before first-formal identity exemption, refuse receiver storage outside proven simple local aliases and captured receiver loads. Existing provenance and binder scopes distinguish global/nonlocal stores, attribute/subscript stores and local aliases. The authoritative global specimen explicitly declares `global saved`; its module helper can therefore reach the actual receiver. Complete nonlocal closure installation, container storage and nested closure contrasts remain inert. Direct literal labels and two-hop simple local receiver aliases stay accepted. Harmless container storage or closure escape is conservatively unsupported, even when no sensitive effect is demonstrated. | +| Conditional assigned await implementations, original line 23113 | After proving every assigned target, inspect each resolved implementation's lexical return/yield values with the existing sensitivity graph. A valid generator `leak` uses `if False: yield None` and returns the environment; a valid safe generator sibling returns a literal. `flag = True`, ordinary `L.__anext__` returning `Awaitable()`, and `K.__aiter__` returning `L()` supply the complete finite case. Both direct conditional and aliased assignments are covered. Known safe/safe alternatives remain accepted; unknown alternatives refuse. | +| Class-namespace callback alias, original lines 23127–23130 | `mutation_parameter_calls_closed` no longer certifies simple-name assignments in a class suite as ordinary callable aliases. The exact `install(receiver, name="label")`, `Callback.__call__ = install`, `install(None)` and `map(Callback(), ["__str__"])` source is rejected for an unresolved member name, with a two-hop class alias and harmless sibling. Direct default installation and two-hop ordinary callable aliases retain their prior supported behavior. No descriptor execution model was added. | + +Every protocol specimen is source text parsed as AST data. The async sources +use the finite `async for` print/break consumer and `collect().send(None)`; +neither that invocation nor any callback, generator, closure, protocol or +recipe executes. Only the previously inspected trusted scanner/harness runs. +These are static acceptance/refusal observations, not runtime output evidence. + +Actual isolated prefix: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py +``` + +Before any packet edit, prefix plus `-k test_pr103_v13` first produced **three +methods, ten failures, zero errors in 0.349s, exit 1**. One non-subtest assertion +stopped that method early. Converting its remaining contrasts to subtests +produced **three methods, 16 failures, zero errors in 0.386s, exit 1**: eight +sensitive unsafe accepts and eight harmless unsupported accepts, not 16 +demonstrated runtime leaks. Sensitive closure/class-mutation contrasts already +refused at baseline and remain controls. Safe assigned-await alternatives, +unknown-await refusal and direct callable aliases already passed. + +The first packet correction produced **three methods, two failures, zero +errors in 0.363s, exit 1**. Both were an extra factory-shaped nonlocal source +using a nested class returned through `K = build()`. That source remained +accepted; expanding factory/class discovery was not attempted. The writer +disclosed the exact distinction and requested a coordinator scope decision in +`msg_7396d71442c9`. The durable nonlocal contrast instead installs a closure +as the globally declared `K.__aiter__`, keeping the same complete nonlocal +storage/mutation semantics within the active assigned-protocol boundary. +Prefix plus `-k test_pr103_v13` then passed **three methods in 0.364s, exit 0**. +This specimen adjustment is not represented as a fix for factory discovery. + +The final three methods were reversed against the untouched baseline packet +with the following trusted command; **16 expected assertion failures in +0.397s**, zero errors, were observed and the explicit reproduction assertion +passed (command exit 0): + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B - <<'PY' +import runpy, subprocess, unittest +h=runpy.run_path('scripts/evidence_packet/issue79_regression_test.py') +methods=[name for name in dir(h['Issue79RegressionTests']) if name.startswith('test_pr103_v13')] +old=subprocess.run(['/usr/bin/git','--no-replace-objects','-P','-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null','show','84e1f11cf08b3a1914c9446a00511e7739297c01:docs/evidence/g01-recovery-packet.md'],env={'PATH':'/usr/bin:/bin','LC_ALL':'C','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_SYSTEM':'/dev/null','GIT_ATTR_NOSYSTEM':'1'},check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True).stdout +h['_scanner_namespace'].__globals__['PACKET_TEXT']=old +suite=unittest.TestSuite(h['Issue79RegressionTests'](name) for name in methods) +result=unittest.TextTestRunner(verbosity=2).run(suite) +assert result.testsRun==3 and len(result.failures)==16 and not result.errors,(result.testsRun,len(result.failures),len(result.errors)) +print('PASS source reversal: all three V13 methods reproduce 16 expected baseline failures; no specimen executed') +PY +``` + +The unchanged combined focused command recorded above (`-k test_pr103` plus +the identical 21 adjacent selectors) ran once after GREEN and passed **119 +methods in 22.385s, exit 0**, retaining all prior 116 plus the three new methods. +The **exact identical expanded V9 preflight command** recorded above ran once +and passed in **12.545s, exit 0**: 206 root helpers, 328 called original helpers +including nested definitions, seven retained scoped regex-receiver proofs, +zero new collisions or lost proofs. Those two processes overlapped in lifetime. +This is bounded evidence, not a fresh whole-packet PASS. + +Frozen packet is `765c0a77b2e52f176780d6bf28fb9f4393eeb2cb`; frozen harness is +`08aca2cdf850ac59997432d2757691b16396c1b4`. Source inputs remain frozen pending +the coordinator's two independent bounded provisional reviews and scope +decision. A fresh full scan and PASS-only local commit still require the next +coordinator gate. No whole-packet scan, Go/full matrix, duplicate focused suite, +GitHub write, commit, push, merge, live/canary, runner, JIT, credential, Docker, +workflow dispatch or process kill occurred. No allowlist, source-name exception, +generic Python engine or safety-policy relaxation was introduced. Rollback is +a reviewed revert of these three owned files against immutable `84e1f11`; +all historical receipts and scoped rebuttals above remain intact. + +V13 `git diff --check` passed. The identical V10 introduced-delta hygiene +command above, changing only its comparison base to +`84e1f11cf08b3a1914c9446a00511e7739297c01`, passed: exactly the three owned +files and zero added-line matches in all four documented pattern categories. +This remains a bounded pattern check, not an exhaustive secret audit. + +### V13 disclosed unresolved factory-shaped nonlocal acceptance + +Coordinator `msg_62e60092ce61` requested the complete source and exact identities +for the extra acceptance disclosed in `msg_7396d71442c9`. After the focused +and preflight runs completed, a read-only two-namespace authoritative probe +returned **`None` on both** untouched baseline packet `cd8c5807d676814e2ba86a3b3f6a85b2bd4b4137` +at `84e1f11cf08b3a1914c9446a00511e7739297c01` and current frozen packet +`765c0a77b2e52f176780d6bf28fb9f4393eeb2cb`, using final harness +`08aca2cdf850ac59997432d2757691b16396c1b4` (command exit 0). The source was +passed only as a string to `inspect_python_heredoc(source, False)`; neither +the factory nor the protocol or consumer executed: + +```python +import os +class L: + async def __anext__(self): + return os.environ +def build(): + saved = None + def mutate(): + object.__setattr__(saved, "__class__", L) + class K: + def __aiter__(self): + nonlocal saved + saved = self + mutate() + return self + async def __anext__(self): + return "safe" + return K +K = build() +async def collect(): + async for item in K(): + print(item) + break +collect().send(None) +``` + +By Python semantics this is a complete finite sensitive path: `saved` is +declared in the enclosing function, `nonlocal saved` binds the actual receiver +there, `mutate` reaches that same cell, and the ordinary compatible heap classes +permit changing the receiver to `L` before async-next dispatch. This statement +is semantic reasoning, not an observed runtime leak. The current correction +does **not** close this acceptance. The coordinator requested that reviewers +assess a bounded refusal at the existing return/class boundary before deciding +whether architecture expansion is needed. No wider sweep, factory engine, +source edit, full scan or commit follows this disclosure without a new gate; +source remains frozen at the identities above. + +### V13 bounded returned-constructor correction after review HOLDs + +The first V13 freeze did not receive provisional approval. Contract reported +HOLD in `msg_c04e8030f952` with detail `msg_66585d391bc1` and paused-read acknowledgment +`msg_0a0d90617716`; security independently confirmed baseline/current `None` +for the same factory pair and paused in `msg_51ec20457a1a`. Coordinator +`msg_46a01c2e8253` cleared both barriers and explicitly authorized a bounded +existing-graph correction. The known factory acceptance predates V13; it is +not a regression introduced by this correction. Contract's eight small +await/callback contrasts supported the other corrections; security's single +focused receiver method passed in 0.274s. Neither result is a full-suite or +final-source approval. + +The active constructor inventory in `error_operand_repr_sensitive` previously +discarded Call-valued constructor bindings such as `build()` before reaching +the returned nested class. It now uses existing `protocol_callable_targets` +and local-return provenance to prove **all** returned class alternatives and +route each through the same selected protocol checks. An unproven, non-class +or cyclic returned constructor refuses rather than becoming insensitive. +No factory execution, generic effects model or new provenance engine was +introduced. This correction closes the exact finite sensitive nonlocal source +above and conservatively refuses its harmless class-mutation pair. + +Before the constructor edit, the isolated prefix plus +`-k test_pr103_v13_factory` produced **one method, four failed assertions, +zero errors in 0.267s, exit 1**: one sensitive acceptance, one harmless mutation +acceptance, one unknown-alternative acceptance and one cycle acceptance. The +recursive source is a static refusal control, not a finite runtime leak. +Simple safe local factories, returned local class aliases and known/known +class alternatives already passed and remain accepted. After the minimal +edit, prefix plus `-k test_pr103_v13` passed **four methods in 0.422s, exit 0**. +All specimens remained inert AST text. + +The unchanged combined focused command (`-k test_pr103` plus the same 21 +adjacent selectors recorded above) ran once after this GREEN and passed +**120 methods in 23.293s, exit 0**, retaining the preceding 119. The **exact +same expanded V9 preflight command** ran once and passed in **13.729s, exit 0**: +206 root helpers, 328 called original helpers including nested definitions, +seven retained scoped regex-receiver proofs and zero collisions/lost proofs. +The two processes overlapped in lifetime. Earlier V13 results retain their +own source identities and are not relabeled as this renewed candidate. + +Renewed frozen packet is `3f8e4bd6129b2948197d45c29827d6215b0b52cf`; renewed +frozen harness is `fe5e54cb25f347731fb6b988555b2ed71353b6fc`. +`git diff --check` passed. The same introduced-delta hygiene command against +`84e1f11cf08b3a1914c9446a00511e7739297c01` passed after recording these +receipts: exactly the three owned files and zero added-line pattern matches +in all four documented categories. New bounded provisional +reviews and explicit authorization for a single fresh full scan remain +pending; no full scan or commit has run. Rollback remains a reviewed revert +of the bounded three-file correction against `84e1f11`, preserving history. + +### V13 renewed provisional approvals and authorized fresh full scan + +Coordinator `msg_592ad6b61c52` accepted contract `msg_4d5ec1846b32` and security +`msg_3b27aa1a5725` provisional approvals of the exact baseline `84e1f11`, packet +`3f8e4bd6129b2948197d45c29827d6215b0b52cf` and harness +`fe5e54cb25f347731fb6b988555b2ed71353b6fc`. Both reviewers acknowledged no +further source reads/probes. These are provisional internal approvals, +separate from final-SHA and GitHub Codex review. + +The coordinator authorized exactly one fresh full-packet scan on those frozen +inputs and a local three-file commit only after actual PASS plus clean hygiene. +The writer started at **2026-10-22T17:38:04Z**, owned process session `33413`, +after rechecking both source hashes, using: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations +``` + +Only inspected trusted scanner/harness code runs; packet recipes, callbacks, +protocols and specimens remain inert data. Packet/harness stay frozen, with +ledger-only receipts permitted. No second scan, focused/preflight repeat, +commit or pending outcome is represented as PASS. On failure, no source edit +or commit is authorized; concrete evidence must return to the coordinator. + +### V13 fresh whole-packet PASS and authorized local candidate preparation + +The single authorized session `33413` completed with **exit 0, one test in +945.683s, OK**. Completion was confirmed at **2026-10-22T17:53:57Z**; this +is an observation timestamp, not a claimed exact process-exit time. The +trusted harness reported: + +```text +current packet static scan: 261 shell commands, 86 Python heredoc bodies, zero violations +``` + +Post-run hashes still match tested packet +`3f8e4bd6129b2948197d45c29827d6215b0b52cf` and harness +`fe5e54cb25f347731fb6b988555b2ed71353b6fc`. There was no packet/harness edit, +restart, duplicate scan, or specimen execution during the run. The original +119-method checkpoint and renewed 120-method PASS retain their respective +source identities; the 945.683s result is fresh evidence for this renewed +V13 source, rather than the historical V12 PASS. The expanded compatibility +preflight remains a separate 13.729s result. + +Coordinator `msg_592ad6b61c52` authorizes a single local commit of the three +owned files after this actual PASS and final clean diff/hygiene checks against +immutable parent `84e1f11cf08b3a1914c9446a00511e7739297c01`. Exact-final-SHA +independent review remains pending. Push, GitHub writes, hosted quick checks, +GitHub Codex exact-head review, Project state and merge remain coordinator +owned. No live/canary, runner, JIT, credential, Docker or workflow operation +occurred, and no G01/G02 production gate is declared complete here. + +Final `git diff --check` and the identical introduced-delta hygiene command +against `84e1f11` passed after recording the full result: exactly the three +owned files, zero added-line matches in all four documented pattern categories. +Rollback is a reviewed revert of this bounded three-file candidate against that +immutable parent, retaining the earlier RED, HOLD and approval history. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 384505a7..fe5e54cb 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8403,6 +8403,109 @@ def test_pr103_three_defaulted_setter_formal_origins(self) -> None: + ' K.__aiter__ = start\n' + calls + '\n' + consumer) self.assertEqual(result is None, calls in ('install()', 'install(setattr)')) + def test_pr103_v13_receiver_assignment_escapes(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + prefix = ('import os\nclass L:\n async def __anext__(self):\n' + ' return ' + value + '\n') + for setup, body in ( + ('saved = None\ndef mutate():\n object.__setattr__(saved, "__class__", L)\n', + 'global saved\n saved = self\n mutate()'), + ('holder = [None]\ndef mutate():\n object.__setattr__(holder[0], "__class__", L)\n', + 'holder[0] = self\n mutate()'), + ('class Holder: pass\nholder = Holder()\ndef mutate():\n' + ' object.__setattr__(holder.saved, "__class__", L)\n', + 'holder.saved = self\n mutate()'), + ('', 'saved = self\n def mutate():\n' + ' object.__setattr__(saved, "__class__", L)\n mutate()'), + ('', 'box = [self]\n def mutate():\n' + ' object.__setattr__(box[0], "__class__", L)\n mutate()'), + ): + with self.subTest(value=value, body=body): + self.assertIsNotNone(self.inspect(prefix + setup + 'class K:\n' + ' def __aiter__(self):\n ' + body + '\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + # Complete nonlocal scope; the module helper cannot see a method local. + with self.subTest(value=value, storage='nonlocal'): + self.assertIsNotNone(self.inspect(prefix + 'class K:\n' + ' async def __anext__(self):\n return "safe"\n' + 'def install():\n saved = None\n' + ' def mutate():\n object.__setattr__(saved, "__class__", L)\n' + ' def start(self):\n nonlocal saved\n' + ' saved = self\n mutate()\n return self\n' + ' K.__aiter__ = start\ninstall()\n' + consumer)) + for body in ('alias = self\n other = alias\n other.label = "safe"', + 'self.label = "safe"'): + self.assertIsNone(self.inspect('class K:\n def __aiter__(self):\n ' + + body + '\n return self\n async def __anext__(self):\n' + ' return "safe"\n' + consumer)) + # Harmless storage and unknown closure effects remain unsupported. + for body in ('box = [self]', 'saved = self\n def helper():\n' + ' return saved\n helper()'): + with self.subTest(unsupported=body): + self.assertIsNotNone(self.inspect('class K:\n def __aiter__(self):\n ' + + body + '\n return self\n async def __anext__(self):\n' + ' return "safe"\n' + consumer)) + + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + with self.subTest(value=value, boundary='nonlocal factory'): + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + ' async def __anext__(self):\n return ' + value + '\n' + 'def build():\n saved = None\n def mutate():\n' + ' object.__setattr__(saved, "__class__", L)\n' + ' class K:\n def __aiter__(self):\n nonlocal saved\n' + ' saved = self\n mutate()\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + ' return K\nK = build()\n' + consumer)) + for returned in ('Local', 'alias', 'Local if flag else Other', + 'Local if flag else unknown', 'build()'): + with self.subTest(returned=returned): + self.assertEqual(self.inspect('flag = True\ndef build():\n' + ' class Local:\n def __aiter__(self):\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + ' class Other:\n def __aiter__(self):\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + ' alias = Local\n return ' + returned + '\nK = build()\n' + + consumer) is None, returned not in ('Local if flag else unknown', 'build()')) + + def test_pr103_v13_assigned_await_all_targets(self) -> None: + consumer = ('class L:\n def __anext__(self):\n return Awaitable()\n' + 'class K:\n def __aiter__(self):\n return L()\n' + 'async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for target in ('leak if flag else safe', 'alias', 'leak if flag else unknown'): + with self.subTest(value=value, target=target): + self.assertEqual(self.inspect('import os\n' + 'def leak(self):\n if False:\n yield None\n return ' + value + '\n' + 'def safe(self):\n if False:\n yield None\n return "safe"\n' + 'flag = True\nalias = leak if flag else safe\nclass Awaitable: pass\n' + 'Awaitable.__await__ = ' + target + '\n' + consumer) is None, + value == '"safe"' and 'unknown' not in target) + + def test_pr103_v13_class_namespace_callback_escape(self) -> None: + for value in ('str(os.environ)', '"safe"'): + for assignment in ('__call__ = install', 'first = install\n __call__ = first'): + with self.subTest(value=value, assignment=assignment): + result = self.inspect('import os\nclass K: pass\n' + 'def leak(self):\n return ' + value + '\n' + 'def install(receiver, name="label"):\n setattr(K, name, leak)\n' + 'class Callback:\n ' + assignment + '\ninstall(None)\n' + 'list(map(Callback(), ["__str__"]))\nprint(K())\n') + self.assertIsNotNone(result) + self.assertIn('unresolved member name', result) + for calls in ('install(None)', 'first = install\nalias = first\nalias(None)', + 'first = install\nalias = first\nalias(None, "__str__")'): + self.assertEqual(self.inspect('import os\nclass K: pass\n' + 'def leak(self):\n return ' + value + '\n' + 'def install(receiver, name="label"):\n setattr(K, name, leak)\n' + + calls + '\nprint(K())\n') is None, + value == '"safe"' or '"__str__"' not in calls) + def test_pr103_four_classmethod_decorator_aliases(self) -> None: for setup, decorator in (('cm = classmethod', 'cm'), ('first = classmethod\ncm = first', 'cm')): From dc83077613276d4c9ffba0cae3aaddd9696fbef5 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 03:24:51 +0900 Subject: [PATCH 117/123] fix(evidence): refuse augmented receiver escapes --- docs/evidence/g01-recovery-packet.md | 2 + .../issue79-pr103-review-6172592368.md | 134 ++++++++++++++++++ .../issue79_regression_test.py | 33 +++++ 3 files changed, 169 insertions(+) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 3f8e4bd6..1820eaab 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22905,6 +22905,8 @@ def python_sensitive_read_violation(tree, parents): and python_enclosing_scope(part, parents) is not implementation and reaches_receiver(part)): return True # Captured receivers cannot certify unchanged identity. + if isinstance(part, ast.AugAssign) and reaches_receiver(part.value): + return True # Augmentation is a mutation, never a simple local alias. if isinstance(part, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) and part.value is not None: if reaches_receiver(part.value): targets = part.targets if isinstance(part, ast.Assign) else [part.target] diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index 467e7a48..c1728509 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -3031,3 +3031,137 @@ against `84e1f11` passed after recording the full result: exactly the three owned files, zero added-line matches in all four documented pattern categories. Rollback is a reviewed revert of this bounded three-file candidate against that immutable parent, retaining the earlier RED, HOLD and approval history. + +### V14 bounded augmented receiver escape correction + +The complete [P1 report 6347657340](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6347657340) +was read with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6347657340 --jq .body`. +The immutable starting HEAD is `eaa84ceeee9ed9aff0aeb73b2a058159a19cb8fc`, +with packet `3f8e4bd6129b2948197d45c29827d6215b0b52cf`, harness +`fe5e54cb25f347731fb6b988555b2ed71353b6fc` and ledger +`467e7a4805f3a6ebb8a26e9c603e40d89a83dbbd`. The maintainer explicitly +selected Codex `gpt-6.1-sol` with medium reasoning for this bounded writer. +Earlier V13 full-packet, focused, preflight and exact-head quick-check receipts +remain genuine baseline evidence; they do not rebut this new finding or prove +the changed source. + +The authoritative `inspect_python_heredoc` test now contains the complete finite +report: module-level `holder = []`, helper mutation of `holder[0].__class__`, +`global holder; holder += [self]; mutate(); return self` in `K.__aiter__`, +replacement async-next returning `os.environ`, safe original async-next, and +an async consumer that prints once, breaks and is driven by `.send(None)`. +Its identical `"safe"` replacement is a harmless unsupported-policy pair. +Two further finite harmless controls use local `holder = Holder()` with a +no-effect `__iadd__` and RHS `self` or local alias `receiver`. These require +conservative refusal; they are not sensitive runtime leak proofs. The accepted +control retains local receiver aliases, numeric `+= 1`, literal-list augmentation +and an ordinary receiver-label store. + +Before the packet edit, this exact command produced **two methods, four failed +assertions, zero errors in 0.246s, exit 1** on the baseline packet and RED/final +harness `20f2cf9567ad2aa4b05cab4725adeb2dcd9afd7d`: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103_v14 +``` + +All four refusals returned `None` before the fix; the ordinary augmentation +control already passed. A separate two-line `AugAssign` guard now calls the +existing `reaches_receiver(part.value)` provenance predicate and refuses every +receiver-valued RHS at this boundary. It cannot inherit the simple local-Name +assignment exception. Existing V13 code and tests are unchanged. No effects +engine, allowlist, name exception or policy relaxation was added. The same +command then passed **two methods in 0.241s, exit 0**. There were no additional +failed intermediate checks during V14. + +The prior combined selection plus the new two methods ran once after GREEN: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103 -k test_launchers_bound_through_callable_defaults_stay_classified -k test_launchers_nested_in_default_expressions_stay_classified -k test_sensitive_operands_are_rejected_before_implicit_exception_messages -k test_complex_conversions_are_rejected_before_implicit_exception_messages -k test_conversion_callables_in_defaults_and_containers_are_tracked -k test_sensitive_call_arguments_propagate_into_parameters -k test_late_inserted_sensitive_keys_lose_keys_only_exemption -k test_keys_with_sensitive_representations_are_rejected -k test_sensitive_values_are_tainted_into_method_and_lambda_parameters -k test_sensitive_taint_crosses_inline_lambda_and_static_method_parameters -k test_sensitive_mapping_return_survives_unrelated_nested_name_collision -k test_path_filesystem_readers_require_reviewed_paths -k test_resolved_local_paths_are_not_disclosed_to_output_sinks -k test_resolved_paths_cross_expanded_keyword_helpers_and_formatting -k test_container_and_conditional_class_aliases_preserve_return_taint -k test_sensitive_taint_reaches_string_format_arguments -k test_format_callable_aliases_preserve_sensitive_taint -k test_user_defined_format_alias_returning_constant_is_safe -k test_markdown_link_containment_guard_must_be_direct_and_reachable -k test_package_containment_try_requires_direct_reachable_body -k test_git_show_output_and_reader_option_paths_are_reviewed +``` + +It passed **122 methods in 21.382s, exit 0**, retaining all prior 120 and the +actual canonical-body/containment checks. The **exact same expanded V9 preflight +command** preserved above ran once and passed in **12.116s, exit 0**: 206 root +helpers, 328 called original helpers including nested definitions, seven +retained scoped regex-receiver proofs, zero collisions or lost proofs. These +two processes overlapped in lifetime. This is bounded evidence, not a new +whole-packet result. + +Frozen packet is `1820eaaba266d7e3f057793af7818dc4368421a7`; frozen harness is +`20f2cf9567ad2aa4b05cab4725adeb2dcd9afd7d`. Only inspected trusted scanner and +harness definitions executed; specimens, callbacks, protocols, coroutines and +packet recipes remained inert AST/source text. No full-packet scan, Go/full +matrix, commit, push, GitHub write, Project mutation, live/canary, runner, JIT, +credential, Docker, workflow dispatch or cleanup operation occurred. Source is +frozen for the coordinator's two bounded independent reviews; a fresh full +scan and PASS-only commit require the next explicit gate. Rollback is a +reviewed revert of this bounded three-file delta against immutable `eaa84ce`, +preserving all historical receipts and V13 fixes. + +V14 `git diff --check` passed. The exact V10 introduced-delta hygiene command +preserved above, changing only its comparison base to +`eaa84ceeee9ed9aff0aeb73b2a058159a19cb8fc`, passed after these receipts: +exactly the three owned files and zero added-line matches in all four +documented categories. This is a bounded pattern check, not an exhaustive +secret audit. + +### V14 provisional approvals and authorized fresh full scan + +Coordinator `msg_0b3f609b33e5` accepted both bounded exact-source approvals: +contract `msg_1c085b21904d` (two V14 methods PASS in 0.237s) and security +`msg_a8aa0396f253` (two methods PASS in 0.235s and a finite attribute-target +contrast/safe control PASS in 0.315s including loader). These are reviewer +receipts reported by the coordinator, not additional writer invocations. +Both reviewers acknowledged no further source reads/probes. These provisional +internal approvals remain separate from final-SHA and GitHub Codex review. + +The coordinator authorized exactly one fresh full-packet scan on frozen packet +`1820eaaba266d7e3f057793af7818dc4368421a7` and harness +`20f2cf9567ad2aa4b05cab4725adeb2dcd9afd7d`, with ledger-only receipts and a +local exact-three-file commit only after actual PASS and clean hygiene. Both +hashes were rechecked before starting at **2026-10-22T18:08:19Z**, owned session +`18669`, using: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py Issue79RegressionTests.test_current_packet_has_no_static_scanner_violations +``` + +Only inspected trusted harness/scanner definitions run; recipes and specimens +remain inert source data. No outcome is inferred from startup, and no duplicate +focused, preflight or full scan is authorized. A failure requires reporting +concrete evidence without source edits or commit. After a PASS-only local +commit, the writer must remain active for final exact-SHA review settlement; +push and GitHub operations remain coordinator owned. + +### V14 fresh whole-packet PASS and local candidate preparation + +The single authorized original session `18669` completed with **exit 0, one +test in 958.273s, OK**. Completion was confirmed at **2026-10-22T18:24:27Z**; +this is the captured observation timestamp, not a claimed exact exit time. +The trusted harness reported: + +```text +current packet static scan: 261 shell commands, 86 Python heredoc bodies, zero violations +``` + +Post-run hashes still match tested packet +`1820eaaba266d7e3f057793af7818dc4368421a7` and harness +`20f2cf9567ad2aa4b05cab4725adeb2dcd9afd7d`. There was no source edit, +restart, duplicate scan or specimen execution during the run. This result is +fresh V14 evidence; the V13 945.683s receipt remains baseline evidence. +The separate focused PASS122 and expanded preflight retain their own scopes. + +Coordinator `msg_0b3f609b33e5` authorizes a local commit of only the three owned +files after actual PASS and final clean diff/hygiene against immutable parent +`eaa84ceeee9ed9aff0aeb73b2a058159a19cb8fc`. Final exact-SHA independent review +and settlement remain pending; the writer stays active. Push, GitHub Codex +review, hosted quick check, Project mutation and merge remain coordinator +owned. No live/canary, runner, JIT, credential, Docker, workflow operation or +Go/full matrix occurred, and no G01/G02 production gate is declared complete. + +Final `git diff --check` and the identical V10 introduced-delta hygiene command, +changing only the base to `eaa84ce`, passed after recording these full-result +receipts: exactly three owned files, zero added-line matches in all four +documented categories. Rollback is a reviewed revert of this bounded delta +against that immutable parent, preserving historical evidence and V13 fixes. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index fe5e54cb..20f2cf95 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8448,6 +8448,39 @@ def test_pr103_v13_receiver_assignment_escapes(self) -> None: + body + '\n return self\n async def __anext__(self):\n' ' return "safe"\n' + consumer)) + def test_pr103_v14_augmented_receiver_escape(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + # Complete finite report and harmless pair; both remain AST text. + for value in ('os.environ', '"safe"'): + with self.subTest(value=value): + self.assertIsNotNone(self.inspect('import os\nholder = []\n' + 'def mutate():\n object.__setattr__(holder[0], "__class__", L)\n' + 'class L:\n async def __anext__(self):\n return ' + value + '\n' + 'class K:\n def __aiter__(self):\n global holder\n' + ' holder += [self]\n mutate()\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + + def test_pr103_v14_augmented_receiver_policy_controls(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + # A local Name target and receiver-valued Name RHS must not inherit + # the supported simple assignment alias exception. These finite + # harmless mutations are conservatively unsupported, not leak proofs. + for rhs in ('self', 'receiver'): + with self.subTest(rhs=rhs): + self.assertIsNotNone(self.inspect('class Holder:\n' + ' def __iadd__(self, value):\n return self\n' + 'class K:\n def __aiter__(self):\n receiver = self\n' + ' holder = Holder()\n holder += ' + rhs + '\n' + ' return self\n async def __anext__(self):\n' + ' return "safe"\n' + consumer)) + self.assertIsNone(self.inspect('class K:\n def __aiter__(self):\n' + ' receiver = self\n count = 0\n count += 1\n' + ' labels = []\n labels += ["safe"]\n' + ' receiver.label = count\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: consumer = ('async def collect():\n async for item in K():\n' ' print(item)\n break\ncollect().send(None)\n') From 516de3e1f0a0753ed372aca3fae23aed2f23a34f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 03:37:35 +0900 Subject: [PATCH 118/123] fix(evidence): reject augmentation through receiver aliases --- docs/evidence/g01-recovery-packet.md | 5 +- .../issue79-pr103-review-6172592368.md | 108 ++++++++++++++++++ .../issue79_regression_test.py | 32 ++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 1820eaab..6c1bae23 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22905,8 +22905,9 @@ def python_sensitive_read_violation(tree, parents): and python_enclosing_scope(part, parents) is not implementation and reaches_receiver(part)): return True # Captured receivers cannot certify unchanged identity. - if isinstance(part, ast.AugAssign) and reaches_receiver(part.value): - return True # Augmentation is a mutation, never a simple local alias. + if isinstance(part, ast.AugAssign) and any( + reaches_receiver(operand) for operand in (part.target, part.value)): + return True # Target operators and RHS escapes can mutate the receiver. if isinstance(part, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) and part.value is not None: if reaches_receiver(part.value): targets = part.targets if isinstance(part, ast.Assign) else [part.target] diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index c1728509..bf9d6744 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -3165,3 +3165,111 @@ changing only the base to `eaa84ce`, passed after recording these full-result receipts: exactly three owned files, zero added-line matches in all four documented categories. Rollback is a reviewed revert of this bounded delta against that immutable parent, preserving historical evidence and V13 fixes. + +### V15 augmented target receiver correction — RED checkpoint + +Read the complete [P1 report 6348237211](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6348237211) +using `gh api repos/1XP-AI/gh-runnerd/issues/comments/6348237211 --jq .body`. +Starting HEAD is `dc83077613276d4c9ffba0cae3aaddd9696fbef5`, packet +`1820eaaba266d7e3f057793af7818dc4368421a7`, harness +`20f2cf9567ad2aa4b05cab4725adeb2dcd9afd7d`, ledger +`c1728509bcedbcd1f48d9730ba057504faf45678`. The maintainer explicitly selected +Codex `gpt-6.1-sol`/medium for this bounded writer. V14's guard inspected only +the RHS; its prior reviews did not establish target-receiver coverage. The +122-method focused PASS, expanded preflight, 958.273s full PASS and exact-head +quick check `39831348269` remain baseline evidence, not a rebuttal or changed-source +verification. + +Before any packet edit, the command below ran two new methods in **0.255s**, +exit 1, with **four failed assertions and zero errors**: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103_v15 +``` + +The complete finite source defines replacement `L.__anext__`, custom +`K.__iadd__` changing the shared instance's class via `object.__setattr__`, +safe original `K.__anext__`, and `K.__aiter__` returning self after augmentation. +The async consumer prints once, breaks and is driven with `.send(None)`. +Both the one-hop and two-hop alias targets were accepted for `os.environ` +(two sensitive runtime paths) and the identical literal-safe replacement +(two harmless conservative-policy failures). Direct `self += 1` already +refused; unknown-alternative and unresolved-return controls already refused. +These source strings were only parsed/inspected, never compiled, evaluated, +imported or executed as programs. Only the inspected trusted harness/scanner +definitions ran under the isolated environment above. + +### V15 minimal GREEN and frozen first review checkpoint + +The guard now applies the existing `reaches_receiver` predicate to both +`part.target` and `part.value` before granting unchanged runtime class. +Store-context target Names use the existing binding inventory: an AugAssign +adds an unknown binder but does not discard the ordinary alias assignment; +`python_value_provenance` follows that assignment back through each local +alias to the first formal. No synthetic Load node, generic operator/effects +interpreter, allowlist change or new provenance engine was introduced. +The one-hop and two-hop regression assertions fail on the original packet +and pass with this three-line guard, demonstrating this actual Store path. +Unknown-alternative controls preserve refusal and an unknown returned iterator +does not obtain proven receiver identity. This bounded guard does not certify +arbitrary unknown operators as harmless or claim a general effects proof. + +The isolated prefix above plus `-k test_pr103_v15 -k test_pr103_v14` passed +**four methods in 0.276s, exit 0**. Original V14 RHS escape and custom-iadd +policy regressions, genuine numeric/literal-list augmentation and local alias +label stores are unchanged and passed. Sensitive finite class-replacement +paths are security regression evidence; their literal-safe class replacements +and no-effect custom-iadd controls are conservative policy refusals, not leaks. +The four RED assertion failures were the only failed checks in V15 so far. + +The exact V14 combined command recorded above (`-k test_pr103` plus the same +21 adjacent selectors) ran once after GREEN and passed **124 methods in +20.848s, exit 0**, retaining the preceding 122 plus two V15 methods. +The **identical expanded V9 preflight command** preserved above ran once and +passed in **12.064s, exit 0**: 206 root helpers, 328 called original helpers +including nested definitions, seven retained scoped regex-receiver proofs, +zero new collisions or lost proofs. The focused and preflight processes +overlapped in lifetime. Only inspected trusted harness/scanner definitions +ran; every specimen/recipe/callback/protocol/coroutine remained inert AST text. + +Frozen packet is `6c1bae23626e2b8032bb140806686c505ae0eb3b`; frozen harness +is `cecbfe8da5ae444297e01910360965e9545c3c5b` (also the RED harness). +Coordinator instruction `msg_8e7b9b5ad3cb` revised validation sequencing: +**fresh V15 full-packet scan NOT RUN**, pending qualification of the stable +GitHub-approved immutable head before merge, with that result to be recorded +in a PR comment rather than a post-review source amendment. Two bounded +provisional source reviews and explicit authorization remain required before +a local three-file commit; final-SHA review, push, hosted quick check, GitHub +Codex review, Project state and merge remain coordinator owned. No full packet, +Go/full matrix, live/canary, runner, JIT, credential, Docker, workflow dispatch, +process kill or global cleanup ran. No G01/G02 production gate is completed. + +Rollback is a reviewed revert of this bounded three-file delta against +`dc83077613276d4c9ffba0cae3aaddd9696fbef5`, preserving the V14 RHS-only +limitation and all historical receipts. `git diff --check` and the identical +V10 introduced-delta hygiene command preserved above, changing only its base +to that immutable HEAD, passed: exactly the three owned files and zero +added-line matches in all four documented categories. This is a bounded +pattern check, not an exhaustive secret audit. Source is frozen for the +coordinator's independent reviews; no commit or push has occurred. + +### V15 provisional approvals and authorized local candidate preparation + +Coordinator `msg_e25c2f14a0bd` accepted the two exact-frozen source approvals: +security `msg_5e22d41a45a3` and contract `msg_c8b2fa19f05a`. Each reviewer +independently ran the four V14/V15 methods with PASS in 0.268s; these are +coordinator-reported reviewer receipts, not repeated writer invocations. +Both reviewers acknowledged a paused state. The approvals name unchanged +packet `6c1bae23626e2b8032bb140806686c505ae0eb3b`, harness +`cecbfe8da5ae444297e01910360965e9545c3c5b` and baseline `dc830776`. + +That instruction authorizes one local commit of exactly the three owned +files after this receipt and final diff/hygiene, with no source edit or +repeated test. **Fresh V15 full-packet scan NOT RUN**, pending the stable +GitHub-approved immutable head and required BEFORE MERGE; its future result +belongs in a PR comment, not an amendment of reviewed source. Historical +V14 full PASS remains historical evidence. Final-SHA internal approvals, +push, exact-head hosted quick check and GitHub Codex review are still pending +and coordinator owned. The writer remains active as sole owner awaiting +those gates and explicit authorization for the one later full verification. +No live operation or specimen execution occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 20f2cf95..cecbfe8d 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8481,6 +8481,38 @@ def test_pr103_v14_augmented_receiver_policy_controls(self) -> None: ' receiver.label = count\n return self\n' ' async def __anext__(self):\n return "safe"\n' + consumer)) + def test_pr103_v15_augmented_receiver_target(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for body in ('self += 1', 'alias = self\n alias += 1', + 'alias = self\n other = alias\n other += 1'): + with self.subTest(value=value, body=body): + # Finite sensitive class replacement and harmless policy pair. + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + ' async def __anext__(self):\n return ' + value + '\n' + 'class K:\n def __iadd__(self, other):\n' + ' object.__setattr__(self, "__class__", L)\n return self\n' + ' def __aiter__(self):\n ' + body + '\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + + def test_pr103_v15_augmented_target_provenance_controls(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + # Unknown alternatives do not erase the visible receiver origin. These + # are conservative policy controls, not claimed finite runtime leaks. + for body in ('alias = self if flag else unknown\n alias += 1', + 'alias = unknown if flag else self\n other = alias\n other += 1'): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect('flag = True\nclass K:\n' + ' def __iadd__(self, other):\n return self\n' + ' def __aiter__(self):\n ' + body + '\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + # An unresolved returned iterator must not acquire receiver identity. + self.assertIsNotNone(self.inspect('class K:\n def __aiter__(self):\n' + ' alias = unknown\n alias += 1\n return alias\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: consumer = ('async def collect():\n async for item in K():\n' ' print(item)\n break\ncollect().send(None)\n') From 4218cd063a4cba8fc64242e953f13ecce24f82e2 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 03:51:29 +0900 Subject: [PATCH 119/123] fix(evidence): close context binding and subscription gaps --- docs/evidence/g01-recovery-packet.md | 29 ++++- .../issue79-pr103-review-6172592368.md | 114 ++++++++++++++++++ .../issue79_regression_test.py | 46 +++++++ 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 6c1bae23..68046a64 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22898,6 +22898,26 @@ def python_sensitive_read_violation(tree, parents): and python_enclosing_scope(reference, parents) is implementation for expression in ast.walk(value) if isinstance(expression, ast.expr) for reference in python_value_provenance(expression, tree, parents)) + def augmentation_target_unproven(value): + for expression in ast.walk(value): + if not isinstance(expression, ast.expr): + continue + for reference in python_value_provenance(expression, tree, parents): + if not isinstance(reference, ast.Name): + continue + inventory = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + origins = [] + for scope in python_sensitive_alias_scope_chain(reference, parents): + bindings = inventory.get((id(scope), reference.id), ()) + if not bindings or any(kind in {"global", "nonlocal"} for kind, _ in bindings): + continue + origins = [(kind, binding) for kind, binding in bindings + if not (kind == "unknown" and isinstance(binding, ast.Name) + and isinstance(parents.get(binding), ast.AugAssign))] + break + if not origins or any(kind != "assignment" for kind, _ in origins): + return True # Unsupported binders cannot prove a nonreceiver target. + return False for part in ast.walk(implementation): if isinstance(part, ast.Attribute) and part.attr == "__class__" and isinstance(part.ctx, (ast.Store, ast.Del)): return True @@ -22905,8 +22925,9 @@ def python_sensitive_read_violation(tree, parents): and python_enclosing_scope(part, parents) is not implementation and reaches_receiver(part)): return True # Captured receivers cannot certify unchanged identity. - if isinstance(part, ast.AugAssign) and any( - reaches_receiver(operand) for operand in (part.target, part.value)): + if isinstance(part, ast.AugAssign) and (any( + reaches_receiver(operand) for operand in (part.target, part.value)) + or augmentation_target_unproven(part.target)): return True # Target operators and RHS escapes can mutate the receiver. if isinstance(part, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) and part.value is not None: if reaches_receiver(part.value): @@ -23355,6 +23376,10 @@ def python_sensitive_read_violation(tree, parents): if not python_reviewed_read_path(receiver, tree, parents): return f"Python unreviewed Path reader alias {alias!r} is not allowed" for node in ast.walk(tree): + if (isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Load) + and error_operand_repr_sensitive(node.value, set(), ("__getitem__",))): + return ("Python custom subscription may expose sensitive protocol values " + f"on line {node.lineno}") if (isinstance(node, (ast.comprehension, ast.For, ast.AsyncFor)) and error_operand_repr_sensitive(node.iter, set(), ("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__") if isinstance(node, ast.AsyncFor) diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index bf9d6744..4bd62723 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -3273,3 +3273,117 @@ push, exact-head hosted quick check and GitHub Codex review are still pending and coordinator owned. The writer remains active as sole owner awaiting those gates and explicit authorization for the one later full verification. No live operation or specimen execution occurred. + +### V16 two-boundary authoritative RED checkpoint + +Coordinator `msg_d9a0734298bb` authorized the same sole writer to correct the +two new P1 findings in [complete report 6348543817](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6348543817), +read with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6348543817 --jq .body`. +Baseline is `516de3e1f0a0753ed372aca3fae23aed2f23a34f`, packet +`6c1bae23626e2b8032bb140806686c505ae0eb3b`, harness +`cecbfe8da5ae444297e01910360965e9545c3c5b` and ledger +`bf9d6744a8b891e70d7a5bbfa0fba60f42acc500`. V15 focused/preflight/internal +approvals and hosted quick check `39833195273` are genuine baseline evidence, +not rebuttals to the new findings. Fresh V15 full scan was NOT RUN. + +Before packet edits, the isolated command below produced **three methods, +11 failed assertions, zero errors in 0.297s, exit 1**: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103_v16 +``` + +Two sensitive context-manager variants and their two literal-safe mutation +pairs were accepted. These complete sources define both `__enter__` returning +self and valid `__exit__` returning False; `__iadd__` changes the shared class, +the original async-next is safe, and the replacement returns `os.environ` or +a literal. A finite async consumer prints once, breaks and uses `.send(None)`. +Direct as-target and an additional local alias are covered. Three harmless +Manager controls return numeric zero from `__enter__` and demonstrate unsupported +binding/provenance policy failures, including an unknown-origin alternative; +they are not sensitive runtime paths. Four direct-getitem variants were also +accepted with sensitive returns: direct construction, instance/local alias, +two-hop instance alias, and a known-sensitive/unknown receiver alternative. +Their literal-safe pairs and literal container controls already passed. +V15 proved ordinary assignment aliases only; it did not close context-manager +binders. Previous custom-getitem checks covered iterable consumption, not +ordinary subscription. Every specimen remained inert AST text; no context +manager, callback, coroutine or example was executed or compiled as a program. + +### V16 minimal GREEN and frozen first review checkpoint + +The receiver boundary now checks unresolved target binders using the existing +value-provenance and scoped binding inventory, without modeling `__enter__` +effects. Every reachable Name must have ordinary assignment origins after +excluding AugAssign's own unknown store entries; missing or other unsupported +binding kinds conservatively refuse. This follows local alias hops and all +visible conditional alternatives rather than matching a variable spelling or +only a direct with-target. An as binding remains unknown even when a human can +see that its harmless manager returns numeric zero. This is a bounded binder +proof, not a general proof of arbitrary call/operator effects. Existing +receiver reachability still checks target and RHS first. Known local numeric +and literal-list augmentations remain accepted; V14 RHS and V15 target +regressions are retained unchanged. + +Ordinary Load-context Subscript receivers now use +`error_operand_repr_sensitive(receiver, set(), ("__getitem__",))`, sharing +the existing local class/constructor/alias/protocol graph used for iterables. +Sensitive custom-getitem returns refuse before certifying the subscription, +including the direct output path and instance aliases. Literal-safe custom +returns and literal list/dictionary selections remain accepted. Conditional +receivers retain every visible sensitive alternative. This does not extend +the protocol graph into a general interpreter or claim every unknown object +is a proven harmless subscription. Store-context subscripts do not invoke +this read-protocol check. No allowlist or unrelated syntax policy changed. + +After these packet edits, the isolated prefix plus +`-k test_pr103_v16 -k test_pr103_v15 -k test_pr103_v14` passed **seven methods +in 0.339s, exit 0**. There were no additional intermediate failed checks. +The unchanged combined command recorded in V14/V15 (`-k test_pr103` plus +the same 21 adjacent selectors) ran once after GREEN and passed **127 methods +in 22.000s, exit 0**, preserving prior 124 plus the three V16 methods. +The **identical expanded V9 preflight command** preserved above ran once and +passed in **12.182s, exit 0**: 206 root helpers, 328 called original helpers +including nested definitions, seven scoped regex-receiver proofs retained, +zero new collisions or lost proofs. The focused and preflight processes +overlapped in lifetime. All sources remained inert data; only inspected +trusted harness/scanner definitions ran under the isolated environment. + +Frozen packet is `68046a64d607341369be7c90a560d369b20bef2e`; frozen harness +is `b6d290fcefc9e602ff03e78bbd18eacec13a6af9` (also the RED harness). +`git diff --check` and the identical V10 introduced-delta hygiene command, +changing only its comparison base to +`516de3e1f0a0753ed372aca3fae23aed2f23a34f`, passed with exactly three owned +files and zero added-line pattern matches in all four documented categories. +This bounded pattern check is not an exhaustive secret audit. Rollback is a +reviewed revert of this bounded three-file delta against that immutable +baseline, retaining prior receipts and their scoped limitations. + +Fresh V16 full-packet scan **NOT RUN**, pending stable GitHub-approved-head +qualification BEFORE MERGE under the coordinator's revised cadence. Two +provisional independent source reviews and explicit local-commit authorization +remain pending; no commit, push, Go/full matrix or live operation ran for V16. +Final-SHA reviews, hosted quick check, GitHub Codex review, Project state and +merge remain coordinator owned. Packet/harness are frozen for bounded reviews. + +### V16 provisional approvals and authorized local candidate preparation + +Coordinator `msg_4d7b756f6ef6` accepted contract `msg_7da693cc1cee` and security +`msg_d8319b77987b` approvals of the exact frozen packet +`68046a64d607341369be7c90a560d369b20bef2e` and harness +`b6d290fcefc9e602ff03e78bbd18eacec13a6af9` against `516de3e`. +The contract reviewer reported eight finite direct-subscription contrasts +passing in 0.297s. The security reviewer reported six selected methods passing +in 0.310s plus a two-case same-binding contrast preserving the other unknown +with-store. These are coordinator-attributed reviewer receipts, not additional +writer invocations. Both reviewers acknowledged no further source reads. + +That instruction authorizes one local commit of exactly the three owned files +after these receipts and final diff/hygiene. Packet and harness remain unchanged; +no repeated focused/preflight tests are authorized or claimed. **Fresh V16 +full-packet scan NOT RUN**, pending stable GitHub-approved immutable-head +qualification BEFORE MERGE, with that future result in a PR comment rather +than a source amendment. Final-SHA internal reviews, push, hosted quick check, +GitHub Codex review, Project state and merge remain coordinator owned. The +writer stays active as sole owner for those gates and later qualification; +no live operation or specimen execution occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index cecbfe8d..b6d290fc 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8513,6 +8513,52 @@ def test_pr103_v15_augmented_target_provenance_controls(self) -> None: ' alias = unknown\n alias += 1\n return alias\n' ' async def __anext__(self):\n return "safe"\n' + consumer)) + def test_pr103_v16_with_augmented_receiver(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + for value in ('os.environ', '"safe"'): + for body in ('alias += 1', 'other = alias\n other += 1'): + with self.subTest(value=value, body=body): + # Both context-manager methods are present; the sensitive + # source is a complete finite path, never executed here. + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + ' async def __anext__(self):\n return ' + value + '\n' + 'class K:\n def __enter__(self):\n return self\n' + ' def __exit__(self, kind, value, traceback):\n return False\n' + ' def __iadd__(self, other):\n' + ' object.__setattr__(self, "__class__", L)\n return self\n' + ' def __aiter__(self):\n with self as alias:\n ' + + body + '\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + + def test_pr103_v16_augmented_unknown_binding_policy(self) -> None: + consumer = ('async def collect():\n async for item in K():\n' + ' print(item)\n break\ncollect().send(None)\n') + # A harmless local context manager still has an unsupported as binding. + # Unknown alternatives and alias hops cannot certify nonreceiver origin. + for body in ('alias += 1', 'other = alias\n other += 1', + 'other = 0 if flag else alias\n other += 1'): + with self.subTest(body=body): + self.assertIsNotNone(self.inspect('flag = True\nclass Manager:\n' + ' def __enter__(self):\n return 0\n' + ' def __exit__(self, kind, value, traceback):\n return False\n' + 'class K:\n def __aiter__(self):\n with Manager() as alias:\n' + ' ' + body + '\n return self\n' + ' async def __anext__(self):\n return "safe"\n' + consumer)) + + def test_pr103_v16_direct_getitem_protocol(self) -> None: + for value in ('os.environ', '"safe"'): + for body in ('print(K()[0])', 'obj = K()\nprint(obj[0])', + 'obj = K()\nalias = obj\nprint(alias[0])', + 'flag = True\nobj = K() if flag else unknown\nprint(obj[0])'): + with self.subTest(value=value, body=body): + self.assertEqual(self.inspect('import os\nclass K:\n' + ' def __getitem__(self, key):\n return ' + value + '\n' + + body + '\n') is None, value == '"safe"') + self.assertIsNone(self.inspect('class K:\n def __getitem__(self, key):\n' + ' return "safe"\nflag = True\nobj = K() if flag else ["literal"]\n' + 'print(obj[0])\nprint(["safe"][0])\nprint({"key": "safe"}["key"])\n')) + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: consumer = ('async def collect():\n async for item in K():\n' ' print(item)\n break\ncollect().send(None)\n') From 8994900ca4181aa3341601d6466c669326e388b4 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 04:07:37 +0900 Subject: [PATCH 120/123] fix(evidence): require proven custom subscription results --- docs/evidence/g01-recovery-packet.md | 44 ++++++- .../issue79-pr103-review-6172592368.md | 113 ++++++++++++++++++ .../issue79_regression_test.py | 37 ++++++ 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 68046a64..38e9e8ef 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22787,6 +22787,32 @@ def python_sensitive_read_violation(tree, parents): return None return [target for targets in resolved for target in targets] + def subscription_result_proven(value, seen): + # Bounded literal-result proof, not a bound-method/effects interpreter. + if value is None or id(value) in seen or error_operand_sensitive(value): + return False + seen = seen | {id(value)} + if isinstance(value, ast.Constant): + return True + if isinstance(value, (ast.List, ast.Tuple, ast.Set)): + return all(subscription_result_proven(item, seen) for item in value.elts) + if isinstance(value, ast.Dict): + return all(key is not None and subscription_result_proven(key, seen) + and subscription_result_proven(item, seen) + for key, item in zip(value.keys, value.values)) + if isinstance(value, ast.Call): + targets = protocol_callable_targets(value.func, set()) + if not targets or any(isinstance(target, ast.ClassDef) for target in targets): + return False # Object and unresolved bound-call results refuse, even if harmless. + alternatives = python_local_call_return_values(value, tree, parents) + elif isinstance(value, (ast.Name, ast.IfExp, ast.Subscript)): + alternatives = [reference for reference in python_value_provenance(value, tree, parents) + if reference is not value] + else: + return False + return bool(alternatives) and all(subscription_result_proven(reference, seen) + for reference in alternatives) + def error_operand_repr_from_class(classdef, seen, methods): if methods == ("__await__",) and not any( isinstance(entry, ast.FunctionDef) and entry.name == "__await__" @@ -22830,6 +22856,17 @@ def python_sensitive_read_violation(tree, parents): if not returns or any(returned_async_iterator_sensitive(result, implementation, classdef, seen, set(), ("__await__",)) for result in returns): return True + for method in {"__getitem__", "__class_getitem__"}.intersection(methods): + implementations = [entry for entry in classdef.body + if isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) and entry.name == method] + implementations.extend(assigned_functions.get(method, ())) + for implementation in implementations: + returns = ([implementation.body] if isinstance(implementation, ast.Lambda) else + [part.value for part in ast.walk(implementation) + if isinstance(part, ast.Return) and part.value is not None + and python_enclosing_scope(part, parents) is implementation]) + if not returns or any(not subscription_result_proven(result, set()) for result in returns): + return True for method, implementations in assigned_functions.items(): for implementation in implementations: if isinstance(implementation, ast.Lambda): @@ -23157,7 +23194,7 @@ def python_sensitive_read_violation(tree, parents): return True assigned_display_methods = {} - display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__aiter__", "__anext__", "__await__"} + display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__class_getitem__", "__aiter__", "__anext__", "__await__"} def mutation_parameter_calls_closed(parameter): implementation = parents.get(parents.get(parameter)) if not isinstance(implementation, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): @@ -23377,7 +23414,10 @@ def python_sensitive_read_violation(tree, parents): return f"Python unreviewed Path reader alias {alias!r} is not allowed" for node in ast.walk(tree): if (isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Load) - and error_operand_repr_sensitive(node.value, set(), ("__getitem__",))): + and (error_operand_repr_sensitive(node.value, set(), ("__getitem__",)) + or any(isinstance(reference, ast.ClassDef) + and error_operand_repr_from_class(reference, set(), ("__class_getitem__",)) + for reference in python_value_provenance(node.value, tree, parents, receiver_origins=True)))): return ("Python custom subscription may expose sensitive protocol values " f"on line {node.lineno}") if (isinstance(node, (ast.comprehension, ast.For, ast.AsyncFor)) diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index 4bd62723..01d6975e 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -3387,3 +3387,116 @@ than a source amendment. Final-SHA internal reviews, push, hosted quick check, GitHub Codex review, Project state and merge remain coordinator owned. The writer stays active as sole owner for those gates and later qualification; no live operation or specimen execution occurred. + +### V17 three custom-subscription results — authoritative RED + +Coordinator `msg_a823c8e2f6c1` authorized this bounded three-P1 batch on +`4218cd063a4cba8fc64242e953f13ecce24f82e2`. The complete +[report 6348814082](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6348814082) +was read using `gh api repos/1XP-AI/gh-runnerd/issues/comments/6348814082 --jq .body`. +Baseline packet is `68046a64d607341369be7c90a560d369b20bef2e`, harness +`b6d290fcefc9e602ff03e78bbd18eacec13a6af9` and ledger +`4bd62723521a7f162f461f5f05b6cc41a1d7d3b5`. V16's 127-method focused, +preflight, internal approvals and hosted quick `39835247060` PASS remain +genuine baseline evidence, not rebuttals. Fresh V16 full scan was NOT RUN. +The maintainer's Codex `gpt-6.1-sol`/medium override remains in force. + +Before packet edits, this isolated authoritative-entry command ran **three +methods in 0.364s, exit 1, with 12 failed assertions and zero errors**: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103_v17 +``` + +Seven assertions represent sensitive paths: custom-getitem returning `L()` +whose `__str__` returns `str(os.environ)` (direct and unknown-alternative +forms), custom-getitem returning `self.leak()` where the local method returns +`os.environ` (same two forms), and class-getitem returning `os.environ` +(direct, alias, conditional alias). The five other failures are unsupported +policy controls: two harmless object returns, two harmless bound-helper returns +and one wholly unknown return. Literal-safe class subscriptions and the safe +literal/local-alias/known-helper/conditional/list result controls already passed. +These are complete finite source strings or explicitly unresolved policy +controls, never executed/compiled/evaluated/imported as example programs. +V16 checked direct result sensitivity but did not close returned object display, +unresolved bound-helper results or class-subscription protocols. + +### V17 bounded literal-result policy — GREEN checkpoint + +The coordinator explicitly preferred conservative refusal over a new bound +method/descriptor/effects interpreter. Custom `__getitem__` and +`__class_getitem__` returns now require a bounded literal-result proof after +existing sensitivity checks. Literal constants and containers are supported; +ordinary local aliases, conditional alternatives and literal selections use +existing value provenance. Proven local callable targets and their lexical +returns use existing protocol-target and local-return helpers. Every result +alternative must close; unknown/cyclic values, object construction and +unresolved bound calls refuse, including harmless pairs. This is not a claim +that those harmless object/bound-return programs leak. No specimen is executed, +and no generalized descriptor or effects model was introduced. The existing +safe literal/local-alias/known-helper/conditional/list controls remain accepted. + +Ordinary Load subscriptions additionally inspect ClassDef origins from existing +`python_value_provenance(..., receiver_origins=True)` for `__class_getitem__`. +That mode distinguishes class values from constructed instances and preserves +visible class aliases/conditional alternatives. The class protocol joins the +existing assigned-protocol inventory, so assigned or inherited implementations +use the same proof path. No source spelling exception or allowlist relaxation +was added. V16 receiver-binding logic is unchanged. The previous V16 approval +is retained with its limited direct-result scope rather than rewritten. + +The isolated prefix plus `-k test_pr103_v17 -k test_pr103_v16 +-k test_pr103_final_custom_iterator_values` passed **seven methods in 0.362s, +exit 0**. There were no intermediate failed checks beyond the recorded RED. +The exact combined V14/V15 command (`-k test_pr103` plus the same 21 adjacent +selectors) ran once after GREEN and passed **130 methods in 22.416s, exit 0**, +preserving prior 127 plus three V17 methods. The **identical expanded V9 +preflight command** ran once and passed in **12.802s, exit 0**: 206 root +helpers, 328 called original helpers including nested definitions, seven +retained scoped regex-receiver proofs, zero new collisions or lost proofs. +Focused and preflight process lifetimes overlapped. Every specimen, recipe, +callback, protocol and coroutine remained inert AST data; only inspected +trusted harness/scanner definitions ran under isolated Python 3.14. + +Source is frozen for two bounded independent reviews. Fresh V17 full-packet +scan **NOT RUN**, pending stable GitHub-approved-head qualification BEFORE +MERGE, recorded by the coordinator in a PR receipt without amending reviewed +source. No V17 commit/push, Go/full matrix, GH write, live/canary, runner, JIT, +credential/token, Docker, workflow dispatch, process kill or global cleanup ran. +Final-SHA reviews, hosted quick check, GitHub Codex review, Project and merge +remain coordinator owned. Rollback is a reviewed revert of this bounded +three-file delta against `4218cd063a4cba8fc64242e953f13ecce24f82e2`, +preserving all historical receipts and their scoped limitations. + +V17 frozen packet is `38e9e8ef8d1eec6e860e408c83a73f58c8072025`; frozen +harness is `23f95ee1e9723dc1c417daaac530be86894aacff` (also the RED harness). +`git diff --check` and the identical V10 introduced-delta hygiene command, +changing only its comparison base to `4218cd063a4cba8fc64242e953f13ecce24f82e2`, +passed: exactly the three owned files and zero added-line pattern matches in +all four documented categories. This is a bounded pattern check, not an +exhaustive secret audit. + +### V17 provisional approvals and authorized local candidate preparation + +Coordinator `msg_98df115e8caf` accepted contract `msg_6db2b172001c` (seven +methods PASS in 0.377s plus four inert contrasts) and security +`msg_eb921d4262eb` (three methods PASS in 0.269s plus 12 inert contrasts). +Both approved exact frozen packet `38e9e8ef8d1eec6e860e408c83a73f58c8072025` +and harness `23f95ee1e9723dc1c417daaac530be86894aacff`, with no blockers. +These are coordinator-attributed reviewer receipts, not extra writer runs. + +The routine security clarification is closed by recording its actual scope: +assigned class-body, attribute and setattr protocol variants reject sensitive +results and accept literal results; inherited protocol pairs refuse earlier +under the unreviewed-base policy. The preceding description of a shared +inherited implementation path is not evidence of authoritative inherited +subscription support. No inheritance policy relaxation or new work is needed. + +The coordinator authorized one exact-three-file local commit after these +receipts and final diff/hygiene only. Packet/harness stay unchanged and no +focused/preflight repeats ran. **Fresh V17 full-packet scan NOT RUN**, pending +stable exact GitHub-approved immutable-head qualification BEFORE MERGE, with +future results in a PR receipt without amending reviewed source. Final-SHA +internal review, push, hosted quick check and GitHub Codex review remain +coordinator owned. The writer remains active as sole owner after the local +commit; no push/GH write/full scan/live action or specimen execution occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index b6d290fc..23f95ee1 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8559,6 +8559,43 @@ def test_pr103_v16_direct_getitem_protocol(self) -> None: ' return "safe"\nflag = True\nobj = K() if flag else ["literal"]\n' 'print(obj[0])\nprint(["safe"][0])\nprint({"key": "safe"}["key"])\n')) + def test_pr103_v17_subscription_display_result(self) -> None: + for value in ('str(os.environ)', '"safe"'): + for result in ('L()', 'L() if flag else unknown'): + with self.subTest(value=value, result=result): + # Safe object results remain unsupported policy controls. + self.assertIsNotNone(self.inspect('import os\nflag = True\nclass L:\n' + ' def __str__(self):\n return ' + value + '\n' + 'class K:\n def __getitem__(self, key):\n return ' + + result + '\nprint(K()[0])\n')) + + def test_pr103_v17_subscription_bound_result(self) -> None: + for value in ('os.environ', '"safe"'): + for result in ('self.leak()', 'self.leak() if flag else unknown'): + with self.subTest(value=value, result=result): + self.assertIsNotNone(self.inspect('import os\nflag = True\nclass K:\n' + ' def leak(self):\n return ' + value + '\n' + ' def __getitem__(self, key):\n return ' + result + + '\nprint(K()[0])\n')) + self.assertIsNotNone(self.inspect('class K:\n def __getitem__(self, key):\n' + ' return unknown\nprint(K()[0])\n')) + + def test_pr103_v17_class_subscription(self) -> None: + for value in ('os.environ', '"safe"'): + for body in ('print(K[0])', 'alias = K\nprint(alias[0])', + 'flag = True\nalias = K if flag else unknown\nprint(alias[0])'): + with self.subTest(value=value, body=body): + self.assertEqual(self.inspect('import os\nclass K:\n' + ' def __class_getitem__(cls, key):\n return ' + value + + '\n' + body + '\n') is None, value == '"safe"') + for result in ('literal', 'literal if flag else "other"', 'safe()', '["safe"]'): + with self.subTest(safe_result=result): + self.assertIsNone(self.inspect('literal = "safe"\nflag = True\n' + 'def safe():\n return "safe"\nclass K:\n' + ' def __getitem__(self, key):\n return ' + result + '\n' + ' def __class_getitem__(cls, key):\n return ' + result + '\n' + 'alias = K\nprint(K()[0])\nprint(alias[0])\n')) + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: consumer = ('async def collect():\n async for item in K():\n' ' print(item)\n break\ncollect().send(None)\n') From d6e6686ee840bf483cbc790101482420d242edea Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 04:27:37 +0900 Subject: [PATCH 121/123] fix(evidence): close subscription provenance and state gaps --- docs/evidence/g01-recovery-packet.md | 85 +++++++- .../issue79-pr103-review-6172592368.md | 184 ++++++++++++++++++ .../issue79_regression_test.py | 72 +++++++ 3 files changed, 333 insertions(+), 8 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index 38e9e8ef..a04a0b35 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22802,7 +22802,8 @@ def python_sensitive_read_violation(tree, parents): for key, item in zip(value.keys, value.values)) if isinstance(value, ast.Call): targets = protocol_callable_targets(value.func, set()) - if not targets or any(isinstance(target, ast.ClassDef) for target in targets): + if not targets or any(isinstance(target, ast.ClassDef) + or subscription_implementation_unproven(target) for target in targets): return False # Object and unresolved bound-call results refuse, even if harmless. alternatives = python_local_call_return_values(value, tree, parents) elif isinstance(value, (ast.Name, ast.IfExp, ast.Subscript)): @@ -22813,6 +22814,67 @@ def python_sensitive_read_violation(tree, parents): return bool(alternatives) and all(subscription_result_proven(reference, seen) for reference in alternatives) + def subscription_implementation_unproven(implementation): + if any(isinstance(part, (ast.Yield, ast.YieldFrom)) + and python_enclosing_scope(part, parents) is implementation for part in ast.walk(implementation)): + return True # A generator's Return is not its exposed result. + state_names = {"__defaults__", "__kwdefaults__", "__code__", "__globals__", "__closure__", "__dict__"} + for part in ast.walk(tree): + if (isinstance(part, ast.Attribute) and part.attr in state_names + and implementation in python_value_provenance(part.value, tree, parents)): + return True # Protected callable state access stays unsupported, even if harmless. + if (isinstance(part, ast.Call) and len(part.args) >= 2 + and (receiver_setter_proven(part.func, set()) + or python_unshadowed_builtin_reference(part.func, {"getattr", "delattr"}, tree, parents)) + and implementation in python_value_provenance(part.args[0], tree, parents)): + names = mutation_literal_names(part.args[1], set()) + if not names or names & state_names: + return True + return False + + def subscription_wrapper_proven(value, seen): + wrappers = {"dict", "list", "tuple", "set", "frozenset", "iter", "next"} + if python_unshadowed_builtin_reference(value, wrappers, tree, parents): + return True + if id(value) in seen or not isinstance(value, (ast.Name, ast.IfExp, ast.Subscript)): + return False + alternatives = [reference for reference in python_value_provenance(value, tree, parents) + if reference is not value] + if isinstance(value, ast.Name): + inventory = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in python_sensitive_alias_scope_chain(value, parents): + bindings = inventory.get((id(scope), value.id), ()) + if not bindings or any(kind in {"global", "nonlocal"} for kind, _ in bindings): + continue + if any(kind != "assignment" for kind, _ in bindings): + return False + break + return bool(alternatives) and all(subscription_wrapper_proven(reference, seen | {id(value)}) + for reference in alternatives) + + def subscription_receiver_classes(value): + pending, visited, classes = [value], set(), set() + while pending: + expression = pending.pop() + if id(expression) in visited: + continue + visited.add(id(expression)) + for reference in python_value_provenance(expression, tree, parents, receiver_origins=True): + if isinstance(reference, ast.ClassDef): + classes.add(reference) + elif isinstance(reference, ast.Subscript): + pending.append(reference.value) + elif (isinstance(reference, ast.Call) and any(python_unshadowed_builtin_reference( + target, {"dict", "list", "tuple", "set", "frozenset", "iter", "next"}, tree, parents) + for target in python_value_provenance(reference.func, tree, parents))): + if not subscription_wrapper_proven(reference.func, set()): + return None # One visible builtin cannot certify unknown competing targets. + pending.extend(reference.args) + for argument in reference.args: + pending.extend(python_value_provenance(argument, tree, parents, + initial_selection="iterate", receiver_origins=True)) + return classes + def error_operand_repr_from_class(classdef, seen, methods): if methods == ("__await__",) and not any( isinstance(entry, ast.FunctionDef) and entry.name == "__await__" @@ -22857,10 +22919,16 @@ def python_sensitive_read_violation(tree, parents): classdef, seen, set(), ("__await__",)) for result in returns): return True for method in {"__getitem__", "__class_getitem__"}.intersection(methods): + if any(isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) + and entry.name == method and python_class_namespace_owner(entry, parents) is classdef + and parents.get(entry) is not classdef for entry in ast.walk(classdef)): + return True # Nested class-suite protocol installation is unsupported. implementations = [entry for entry in classdef.body if isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) and entry.name == method] implementations.extend(assigned_functions.get(method, ())) for implementation in implementations: + if subscription_implementation_unproven(implementation): + return True returns = ([implementation.body] if isinstance(implementation, ast.Lambda) else [part.value for part in ast.walk(implementation) if isinstance(part, ast.Return) and part.value is not None @@ -23413,13 +23481,14 @@ def python_sensitive_read_violation(tree, parents): if not python_reviewed_read_path(receiver, tree, parents): return f"Python unreviewed Path reader alias {alias!r} is not allowed" for node in ast.walk(tree): - if (isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Load) - and (error_operand_repr_sensitive(node.value, set(), ("__getitem__",)) - or any(isinstance(reference, ast.ClassDef) - and error_operand_repr_from_class(reference, set(), ("__class_getitem__",)) - for reference in python_value_provenance(node.value, tree, parents, receiver_origins=True)))): - return ("Python custom subscription may expose sensitive protocol values " - f"on line {node.lineno}") + if isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Load): + receiver_classes = subscription_receiver_classes(node.value) + if (receiver_classes is None + or error_operand_repr_sensitive(node.value, set(), ("__getitem__",)) + or any(error_operand_repr_from_class(reference, set(), ("__class_getitem__",)) + for reference in receiver_classes)): + return ("Python custom subscription may expose sensitive protocol values " + f"on line {node.lineno}") if (isinstance(node, (ast.comprehension, ast.For, ast.AsyncFor)) and error_operand_repr_sensitive(node.iter, set(), ("__iter__", "__next__", "__getitem__", "__aiter__", "__anext__") if isinstance(node, ast.AsyncFor) diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index 01d6975e..bc831492 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -3500,3 +3500,187 @@ future results in a PR receipt without amending reviewed source. Final-SHA internal review, push, hosted quick check and GitHub Codex review remain coordinator owned. The writer remains active as sole owner after the local commit; no push/GH write/full scan/live action or specimen execution occurred. + +### V18 four subscription boundaries — authoritative RED + +Coordinator `msg_af3285ece822` authorized four fresh P1 corrections against +`8994900ca4181aa3341601d6466c669326e388b4`. Read the complete +[report 6349169304](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6349169304) +with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6349169304 --jq .body`. +Baseline packet is `38e9e8ef8d1eec6e860e408c83a73f58c8072025`, harness +`23f95ee1e9723dc1c417daaac530be86894aacff`, ledger +`01d6975e909ed337d42de4f781227f58e8ad4172`. V17 focused/preflight/internal +approvals and hosted quick check `39837495127` PASS remain genuine historical +evidence, not rebuttals. Fresh V17 full scan was NOT RUN. + +Before packet edits this isolated command produced **four methods, 33 failed +assertions, zero errors in 0.479s, exit 1**: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103_v18 +``` + +The 18 sensitive paths were: three class receivers from `next`/generator, +`next(iter(...))` and tuple selection; two generator getitem implementations +using Yield/YieldFrom of a sensitive-display object before a literal return; +eight getitem/class-getitem definitions in finite if/try/with/loop class suites; +and five callable-state changes (defaults, keyword-default item, callable +alias defaults, setattr defaults and code replacement). The 15 harmless +unsupported-policy pairs were two generators, eight nested definitions and +five state changes. Three safe wrapped class receivers already passed. +The nested-with sources include complete harmless Manager enter/exit methods, +the try has a finally suite, and the loop is finite. All examples remained +inert AST source; no specimen/protocol/generator/callback was executed, +compiled, evaluated or imported as a program. V17's original direct-return +proof did not establish these four boundaries. + +### V18 initial GREEN and callable-state closure checkpoint + +The first bounded edit passed the isolated prefix plus +`-k test_pr103_v18 -k test_pr103_v17 -k test_pr103_v16 +-k test_pr103_final_custom_iterator_values`: **11 methods in 0.465s, exit 0**. +The unchanged combined selection ran once and passed **134 methods in 21.541s**; +the identical expanded V9 preflight ran once and passed in **12.139s** with +206/328 helpers, seven regex proofs and zero collisions/lost proofs. Their +process lifetimes overlapped. Those receipts apply to initial packet +`e9b7447bfb6f253b43d36e2a267902ca8a43f462` and harness +`08dc28441c6cbf472a3585e9e10111ba4ae28857`, not to a later source revision. + +Before freezing, the same callable-state boundary was checked through a proven +local helper: getitem returns `helper()` and `helper.__defaults__` is replaced. +The isolated prefix plus `-k test_pr103_v18_subscription_helper_state_closure` +produced **one method, two failed assertions, zero errors in 0.227s, exit 1** +on that initial packet. One is a sensitive runtime path and the literal-safe +pair is a policy refusal control. This concrete unresolved concern justifies +renewed focused/preflight verification after closing the helper dependency; +the initial PASS is not relabeled as final source verification. All helper +and mutation examples remained inert AST text. + +### V18 renewed GREEN and frozen review checkpoint + +The same protected callable-state predicate now also checks every proven local +helper target used by the literal-result proof. No new call effects model is +needed. The isolated prefix plus the same V18/V17/V16/custom-iterator selectors +passed **12 methods in 0.454s, exit 0**. Renewed combined selection ran once +on this changed source and passed **135 methods in 21.508s, exit 0**, retaining +prior 130 plus five V18 methods. The identical expanded V9 preflight ran once +on these inputs and passed in **12.336s, exit 0**: 206 root helpers, 328 called +original helpers including nested definitions, seven scoped regex-receiver +proofs retained, zero collisions/lost proofs. Their process lifetimes overlapped. +The original 33 RED failures and the two helper-closure RED failures are the +only failed checks; the initial 134-method PASS remains a separate source receipt. + +The bounded correction has four parts: + +- Class receiver discovery reuses existing receiver origins and `iterate` + projections for the same proven unshadowed collection/iterator builtins + already recognized at this boundary. It follows wrapper arguments and local + alias graphs without executing collection or iterator operations; literal-safe + wrapped class subscriptions remain accepted. +- Generator protocol implementations refuse on own-scope Yield/YieldFrom before + trusting their Return, including harmless generators. This is conservative + policy rather than a new generator execution model. +- Nested class-suite getitem/class-getitem method definitions refuse using + `python_class_namespace_owner`, without crossing nested function/class scopes. + Harmless nested definitions are unsupported too; suite execution is not modeled. +- Protected state access to proven subscription implementations and helper + targets refuses for defaults, keyword defaults, code, globals, closure and + function dictionary state. Existing provenance closes callable aliases; + existing setter/literal-name proofs and builtin getattr/delattr recognition + close reviewed dynamic attribute forms. Reads are conservatively refused as + well as writes; no general mutable-callable or descriptor interpreter is claimed. + +No allowlist relaxation, source-spelling exception or unrelated syntax sweep +was introduced. The unchanged 130-method baseline selection and all prior +augmentation/receiver/ordinary literal-subscription controls passed. Every +example remained inert AST text; only inspected trusted harness/scanner code ran. + +Frozen packet is `4b5a93f64a3c1f3e46f97c0ec7da16e01cdc2153`; frozen harness +is `85cd498635b39c5bc829df399353657693f19c12`. `git diff --check` and the +identical V10 introduced-delta hygiene command, changing only its base to +`8994900ca4181aa3341601d6466c669326e388b4`, passed: exactly three owned files, +zero added-line pattern matches in all four documented categories. This is a +bounded pattern check, not an exhaustive secret audit. Rollback is a reviewed +revert of the bounded three-file delta against that immutable baseline, +preserving all prior source-scoped evidence and limitations. + +Fresh V18 full-packet scan **NOT RUN**, pending stable GitHub-approved immutable +head qualification BEFORE MERGE and a coordinator PR receipt without source +amendment. Two provisional source reviews and explicit local-commit authorization +remain pending. No V18 commit/push, GH write, Go/full matrix, live/canary, runner, +JIT, credential/token, Docker, workflow dispatch, process kill or global cleanup +ran. Final-SHA reviews, hosted quick check, GitHub Codex review, Project and merge +remain coordinator owned. Packet/harness are frozen for bounded review. + +### V18 internal HOLD — aliased builtin wrapper provenance + +Coordinator `msg_a2e70cd0bfd0` forwarded contract reviewer +`msg_1a9ca1ab66b4` HOLD: `ctor = tuple; A = K` followed by +`print((ctor([A])[0])[0])` bypasses class-protocol inspection. The complete +inert source defines local class-getitem returning `os.environ`. The reviewer +reported 12 existing methods PASS in 0.477s and 16 contrasts with one mismatch. +Security `msg_5f4c8828859a` provisionally approved the preceding frozen +`4b5a93f`/`85cd498` scope; that approval does not rebut this HOLD. Both same +reviewers remain assigned for revised source; no new writer is dispatched. + +Before correction, the isolated prefix plus +`-k test_pr103_v18_wrapper_alias_proof` produced one method with five failures, +zero errors in 0.244s. A competing-binding assertion outside a subTest stopped +that first method before its safe outer iteration. Moving that assertion into +a subTest permits every contrast; the complete selector was rerun before any +packet change. The new method covers direct/two-hop tuple aliases, next and +iter aliases, their safe pairs and competing known/unknown targets. All remain +inert AST text. Prior 135-method/preflight PASS receipts remain scoped to the +preceding inputs; this concrete HOLD requires renewed verification. + +The complete RED rerun produced **one method, six failed assertions, zero +errors in 0.255s, exit 1**: five sensitive finite paths and one harmless +competing-target policy failure. Four stable-wrapper safe pairs already passed. +The correction applies the existing scoped binding inventory and value graph +to every visible wrapper-callee alternative, then proves terminal builtins with +the existing unshadowed-builtin predicate. Ordinary stable local alias hops +remain supported; cyclic/unknown/unsupported competing targets cannot inherit +one known sibling's proof. Receiver discovery reports that uncertainty as a +refusal rather than silently dropping the argument's class origin. No generic +call/effects interpreter, existential alias shortcut or allowlist relaxation +was introduced. + +The same isolated boundary selection passed **13 methods in 0.497s, exit 0**. +Renewed combined selection ran once on these revised inputs and passed **136 +methods in 21.747s, exit 0**, retaining prior 135 plus the new alias method. +The identical expanded V9 preflight ran once and passed in **12.256s, exit 0**: +206 root helpers, 328 called original helpers including nested definitions, +seven retained regex proofs, zero collisions/lost proofs. Focused/preflight +lifetimes overlapped. Earlier 134/135-method results and security approval keep +their own source scope; they are not reattributed to this revised candidate. +No new failure occurred after this alias GREEN. All source specimens stayed +inert AST data. Fresh V18 full scan remains NOT RUN; no commit/push/GH write or +live operation has run. Revised source is frozen for the same two reviewers. + +Revised frozen packet is `a04a0b3574575ed4514e3569c7dd6bfa191ef464`; revised +frozen harness is `63eec033d6a28972a0dbe1c6218316a1f18424dc`. Diff and the +identical introduced-delta hygiene command against `8994900` passed after +recording these receipts: exactly the three owned files, zero added-line +matches in all four documented categories. The same bounded rollback and +stable-head premerge full-qualification gate remain unchanged. + +### V18 revised approvals and authorized local candidate preparation + +Coordinator `msg_7ffb955f01c4` accepted contract `msg_7ef9d5dae428` and security +`msg_09332756a5df` revised provisional approvals of packet +`a04a0b3574575ed4514e3569c7dd6bfa191ef464` and harness +`63eec033d6a28972a0dbe1c6218316a1f18424dc`. Contract explicitly resolved its +original HOLD, reporting the alias method PASS in 0.252s plus the same 16 inert +contrasts passing. Security reported the alias method PASS in 0.270s and +retained its original bounded scope of 12 methods in 0.477s, five contrasts +and the unsupported factory pair. These are coordinator-attributed reviewer +receipts, not repeated writer invocations or broader support claims. + +That instruction authorizes one exact-three-file local commit after appending +these receipts/resolution and final diff/hygiene only. Packet/harness remain +unchanged; no test repeats ran. **Fresh V18 full-packet scan NOT RUN**, pending +stable exact GitHub-approved-head qualification BEFORE MERGE, with its future +receipt in a PR comment rather than an amendment of reviewed source. Final-SHA +reviews, push, hosted quick check, GitHub Codex review, Project and merge remain +coordinator owned. The writer stays active as sole owner after the local +commit; no full scan, push, GH write, live action or specimen execution occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 23f95ee1..63eec033 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8596,6 +8596,78 @@ def test_pr103_v17_class_subscription(self) -> None: ' def __class_getitem__(cls, key):\n return ' + result + '\n' 'alias = K\nprint(K()[0])\nprint(alias[0])\n')) + def test_pr103_v18_class_receiver_wrappers(self) -> None: + for value in ('os.environ', '"safe"'): + for receiver in ('next(x for x in [K])', 'next(iter([K]))', 'tuple([K])[0]'): + with self.subTest(value=value, receiver=receiver): + self.assertEqual(self.inspect('import os\nclass K:\n' + ' def __class_getitem__(cls, key):\n return ' + value + + '\nprint((' + receiver + ')[0])\n') is None, value == '"safe"') + + def test_pr103_v18_generator_subscription_policy(self) -> None: + for value in ('str(os.environ)', '"safe"'): + for yielded in ('yield L()', 'yield from [L()]'): + with self.subTest(value=value, yielded=yielded): + self.assertIsNotNone(self.inspect('import os\nclass L:\n' + ' def __str__(self):\n return ' + value + '\n' + 'class K:\n def __getitem__(self, key):\n ' + yielded + + '\n return "safe"\nprint(next(K()[0]))\n')) + + def test_pr103_v18_nested_subscription_policy(self) -> None: + manager = ('class Manager:\n def __enter__(self):\n return self\n' + ' def __exit__(self, kind, value, traceback):\n return False\n') + for value in ('os.environ', '"safe"'): + for suite, tail in (('if True:', ''), ('try:', ' finally:\n pass\n'), + ('with Manager():', ''), ('for ignored in [0]:', '')): + for method, receiver in (('__getitem__', 'K()'), ('__class_getitem__', 'K')): + with self.subTest(value=value, suite=suite, method=method): + self.assertIsNotNone(self.inspect('import os\n' + manager + + 'class K:\n ' + suite + '\n def ' + method + + '(self, key):\n return ' + value + '\n' + tail + + 'print(' + receiver + '[0])\n')) + + def test_pr103_v18_subscription_callable_state_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for signature, mutation in ( + ('self, key, value="safe"', 'K.__getitem__.__defaults__ = (VALUE,)'), + ('self, key, *, value="safe"', 'K.__getitem__.__kwdefaults__["value"] = VALUE'), + ('self, key, value="safe"', 'alias = K.__getitem__\nalias.__defaults__ = (VALUE,)'), + ('self, key, value="safe"', 'setattr(K.__getitem__, "__defaults__", (VALUE,))'), + ('self, key, value="safe"', 'K.__getitem__.__code__ = replacement.__code__'), + ): + with self.subTest(value=value, mutation=mutation): + self.assertIsNotNone(self.inspect('import os\ndef replacement(self, key, value="safe"):\n' + ' return ' + value + '\nclass K:\n def __getitem__(' + signature + + '):\n return value\n' + mutation.replace('VALUE', value) + + '\nprint(K()[0])\n')) + + def test_pr103_v18_subscription_helper_state_closure(self) -> None: + # A literal result also relies on every proven local helper remaining + # unchanged; this uses the same callable-state boundary, not effects. + for value in ('os.environ', '"safe"'): + with self.subTest(value=value): + self.assertIsNotNone(self.inspect('import os\ndef helper(value="safe"):\n' + ' return value\nclass K:\n def __getitem__(self, key):\n' + ' return helper()\nhelper.__defaults__ = (' + value + ',)\n' + 'print(K()[0])\n')) + + def test_pr103_v18_wrapper_alias_proof(self) -> None: + for value in ('os.environ', '"safe"'): + for body in ('ctor = tuple\nA = K\nprint((ctor([A])[0])[0])', + 'ctor = tuple\nother = ctor\nA = K\nprint((other([A])[0])[0])', + 'advance = next\nprint(advance(x for x in [K])[0])', + 'acquire = iter\nadvance = next\nprint(advance(acquire([K]))[0])'): + with self.subTest(value=value, body=body): + self.assertEqual(self.inspect('import os\nclass K:\n' + ' def __class_getitem__(cls, key):\n return ' + value + + '\n' + body + '\n') is None, value == '"safe"') + # Every visible wrapper target must be proven, even with a safe + # selected result; an unknown sibling is a policy refusal. + with self.subTest(value=value, competing=True): + self.assertIsNotNone(self.inspect('import os\nflag = True\nclass K:\n' + ' def __class_getitem__(cls, key):\n return ' + value + '\n' + 'ctor = tuple if flag else unknown\nprint(ctor([K])[0][0])\n')) + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: consumer = ('async def collect():\n async for item in K():\n' ' print(item)\n break\ncollect().send(None)\n') From 3b44ecc7628a98b90f2d7a5a94e5e1c8a5d1adc9 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 04:43:34 +0900 Subject: [PATCH 122/123] fix(evidence): refuse unproven subscription dependencies --- docs/evidence/g01-recovery-packet.md | 28 +++- .../issue79-pr103-review-6172592368.md | 124 ++++++++++++++++++ .../issue79_regression_test.py | 46 +++++++ 3 files changed, 192 insertions(+), 6 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index a04a0b35..bc6048e5 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22791,6 +22791,10 @@ def python_sensitive_read_violation(tree, parents): # Bounded literal-result proof, not a bound-method/effects interpreter. if value is None or id(value) in seen or error_operand_sensitive(value): return False + if any(isinstance(part, ast.Name) and any(isinstance(reference, (ast.List, ast.Tuple, ast.Dict, ast.Set)) + and any(isinstance(item, (ast.List, ast.Dict, ast.Set)) for item in ast.walk(reference)) + for reference in python_value_provenance(part, tree, parents)) for part in ast.walk(value)): + return False # Named mutable dependencies/defaults cannot certify original literal contents. seen = seen | {id(value)} if isinstance(value, ast.Constant): return True @@ -22820,16 +22824,18 @@ def python_sensitive_read_violation(tree, parents): return True # A generator's Return is not its exposed result. state_names = {"__defaults__", "__kwdefaults__", "__code__", "__globals__", "__closure__", "__dict__"} for part in ast.walk(tree): - if (isinstance(part, ast.Attribute) and part.attr in state_names - and implementation in python_value_provenance(part.value, tree, parents)): - return True # Protected callable state access stays unsupported, even if harmless. + if isinstance(part, ast.Attribute) and part.attr in state_names: + targets = protocol_callable_targets(part.value, set()) + if not targets or implementation in targets or any(isinstance(target, ast.ClassDef) for target in targets): + return True # Computed/unknown protected-state targets cannot prove disjoint identity. if (isinstance(part, ast.Call) and len(part.args) >= 2 and (receiver_setter_proven(part.func, set()) - or python_unshadowed_builtin_reference(part.func, {"getattr", "delattr"}, tree, parents)) - and implementation in python_value_provenance(part.args[0], tree, parents)): + or python_unshadowed_builtin_reference(part.func, {"getattr", "delattr"}, tree, parents))): names = mutation_literal_names(part.args[1], set()) if not names or names & state_names: - return True + targets = protocol_callable_targets(part.args[0], set()) + if not targets or implementation in targets or any(isinstance(target, ast.ClassDef) for target in targets): + return True return False def subscription_wrapper_proven(value, seen): @@ -22873,6 +22879,10 @@ def python_sensitive_read_violation(tree, parents): for argument in reference.args: pending.extend(python_value_provenance(argument, tree, parents, initial_selection="iterate", receiver_origins=True)) + if any(python_unshadowed_builtin_reference(target, {"dict"}, tree, parents) + for target in python_value_provenance(reference.func, tree, parents)): + pending.extend(python_value_provenance(argument, tree, parents, + initial_selection=True, receiver_origins=True)) return classes def error_operand_repr_from_class(classdef, seen, methods): @@ -22919,6 +22929,12 @@ def python_sensitive_read_violation(tree, parents): classdef, seen, set(), ("__await__",)) for result in returns): return True for method in {"__getitem__", "__class_getitem__"}.intersection(methods): + bindings = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}).get((id(classdef), method), ()) + if any(not (kind == "unknown" and isinstance(binding, (ast.FunctionDef, ast.AsyncFunctionDef)) + and parents.get(binding) is classdef + or kind == "assignment" and isinstance(binding, (ast.Assign, ast.AnnAssign))) + for kind, binding in bindings): + return True # Unsupported class-namespace protocol binders cannot install a proven method. if any(isinstance(entry, (ast.FunctionDef, ast.AsyncFunctionDef)) and entry.name == method and python_class_namespace_owner(entry, parents) is classdef and parents.get(entry) is not classdef for entry in ast.walk(classdef)): diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index bc831492..8f585f4f 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -3684,3 +3684,127 @@ receipt in a PR comment rather than an amendment of reviewed source. Final-SHA reviews, push, hosted quick check, GitHub Codex review, Project and merge remain coordinator owned. The writer stays active as sole owner after the local commit; no full scan, push, GH write, live action or specimen execution occurred. + +### V19 four mutable-dependency/provenance gaps — authoritative RED + +Coordinator `msg_8b253a1bb6ea` authorized this bounded batch on +`d6e6686ee840bf483cbc790101482420d242edea`. Read the complete +[P1 report 6349573581](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6349573581) +using `gh api repos/1XP-AI/gh-runnerd/issues/comments/6349573581 --jq .body`. +Baseline packet is `a04a0b3574575ed4514e3569c7dd6bfa191ef464`, harness +`63eec033d6a28972a0dbe1c6218316a1f18424dc`, ledger +`bc8314927e25e7b1da00144d7c72c37793805a2e`. Prior 136-method/preflight/internal +approvals and quick check `39840428929` PASS remain genuine baseline evidence, +not rebuttals. Fresh V18 full scan was NOT RUN. + +Before any packet edit, this isolated command ran **four methods in 0.287s, +exit 1, with 17 failed assertions and zero errors**: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103_v19 +``` + +Nine sensitive paths were accepted: two constructed-mapping receivers +(direct/alias), two computed implementation targets (tuple selection/next-iter) +with compatible code replacement, three class-protocol binders (finite loop, +destructuring, named expression), and two factory-captured containers with +subsequent list/dict writes. Eight harmless controls also failed their intended +conservative refusal: two computed targets, three protocol binders, two +captured containers and one literal mutable default. Safe mapping pairs +already passed. The literal mutable default alone is not a leak demonstration. +All complete finite examples and policy controls remained inert AST text; +no factory, callback, replacement code or protocol was executed/compiled/ +evaluated/imported as a program. V18's scoped proofs did not establish mapping +values, computed protected-state identity, every class binder or unchanged +mutable container dependencies. + +The initial bounded edit passed the isolated prefix plus V19/V18/V17/V16 +and custom-iterator selectors: **17 methods in 0.553s, exit 0**. A further +same-boundary control put the captured list inside an immutable tuple before +mutating its element. The mutable-dependency selector was run before fixing +that case; its actual receipt is recorded below. This tests transitive literal +container mutability, not a request for a general mutation interpreter. + +The tuple-containing-list control produced **one method, two failed assertions, +zero errors in 0.256s, exit 1**: one sensitive path and its harmless policy pair. +The named mutable dependency guard now checks nested literal containers too; +it does not mistake an immutable outer tuple for immutable captured contents. +The same boundary selection then passed **17 methods in 0.548s, exit 0**. +These and the initial 17 RED failures are the only failed V19 checks. + +### V19 bounded GREEN and frozen first review checkpoint + +The final correction uses existing graphs and conservative refusal: + +- Proven dictionary wrapper targets add the existing mapping-value projection + as well as the key iteration projection. This conservatively retains possible + value classes rather than asserting a precise reconstructed mapping/key. + Literal-safe direct and alias mapping receivers remain accepted. +- Protected callable-state targets use the existing all-target callable proof. + Unknown/computed targets cannot establish disjoint implementation identity + and refuse, including harmless pairs. Known unrelated callable targets do not + acquire an identity match merely from an unclosed provenance edge. +- Class-namespace protocol bindings use the existing scoped binding inventory. + Direct method definitions and supported simple Assign/AnnAssign bindings keep + their proof path; unsupported binder kinds refuse, including class loop, + destructuring and named-expression forms. No suite execution model was added. +- Named mutable literal dependencies/defaults refuse before projecting original + contents. Captured list/dict/set data and tuples containing mutable literals + cannot certify an unchanged result, even when harmless. Direct fresh literal + returns, immutable string aliases and existing safe local helper controls + remain supported. No general container mutation/effects interpreter was built. + +The final combined V14/V15 command (`-k test_pr103` plus the same 21 adjacent +selectors) ran once after this batch and passed **140 methods in 21.635s, +exit 0**, retaining the prior 136 plus four V19 methods. The **identical +expanded V9 preflight command** ran once and passed in **12.363s, exit 0**: +206 root helpers, 328 called original helpers including nested definitions, +seven retained scoped regex-receiver proofs, zero collisions/lost proofs. +Focused/preflight process lifetimes overlapped. No existing positive conflict +or new architecture/interface was needed. Every example remained inert AST +text; only inspected trusted harness/scanner definitions ran under isolated +Python 3.14. No allowlist relaxation or source-spelling exception was added. + +Frozen packet is `bc6048e50d520d91ebe64b52f7a35a0019ebcb55`; frozen harness +is `1087c58de47f403d39d5f7926bc73eda29884009`. `git diff --check` and the +identical V10 introduced-delta hygiene command, changing only its base to +`d6e6686ee840bf483cbc790101482420d242edea`, passed: exactly the three owned +files, zero added-line matches in all four documented categories. This bounded +pattern check is not an exhaustive secret audit. Rollback is a reviewed revert +of this bounded three-file delta against that immutable baseline, preserving +all prior evidence and its source/scope limitations. + +Fresh V19 full-packet scan **NOT RUN**, pending stable exact GitHub-approved +immutable-head qualification BEFORE MERGE and a coordinator PR receipt without +source amendment. Two provisional source reviews and explicit local-commit +authorization remain pending. No V19 commit/push, GH write, Go/full matrix, +live/canary, runner, JIT, credential/token, Docker, workflow dispatch, process +kill or global cleanup ran. Final-SHA reviews, hosted quick check, GitHub Codex +review, Project and merge remain coordinator owned. Source is frozen for review. + +### V19 provisional approvals and authorized local candidate preparation + +Coordinator `msg_04185a881fb7` accepted contract `msg_cd23ac3693f9` and security +`msg_46d4486c6dd5` exact-source approvals of packet +`bc6048e50d520d91ebe64b52f7a35a0019ebcb55` and harness +`1087c58de47f403d39d5f7926bc73eda29884009`. Contract reported four V19 +methods PASS in 0.267s plus 15 inert contrasts; security reported four V19 +methods PASS in 0.276s, 16 V16–V19 methods PASS in 0.515s and seven inert +contrasts. These are coordinator-attributed reviewer receipts, not extra +writer invocations or a broader proof claim. + +While frozen, the writer sent speculative concern `msg_39349121d3a8` about a +tuple containing an alias to a separate mutable inner list, without another +writer probe or source edit. Security's actual authoritative probes found +direct and aliased inner-list tuple cases refused for both sensitive and +literal-safe variants. This is a bounded refusal result, not a discovered +leak, an extra writer RED or a general transitive-mutability proof. + +The coordinator authorized one exact-three-file local commit after these +receipts and final diff/hygiene only. Packet/harness remain unchanged; no test +repeat ran. **Fresh V19 full-packet scan NOT RUN**, pending stable exact +GitHub-approved-head qualification BEFORE MERGE, with the future receipt in +a PR comment without a source amendment. Final-SHA reviews, push, hosted quick +check, GitHub Codex review, Project and merge remain coordinator owned. The +writer remains active as sole owner after the local commit; no full scan, +push, GH write, live action or specimen execution occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 63eec033..1087c58d 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8668,6 +8668,52 @@ def test_pr103_v18_wrapper_alias_proof(self) -> None: ' def __class_getitem__(cls, key):\n return ' + value + '\n' 'ctor = tuple if flag else unknown\nprint(ctor([K])[0][0])\n')) + def test_pr103_v19_mapping_value_class_receiver(self) -> None: + for value in ('os.environ', '"safe"'): + for body in ('print(dict({"x": K})["x"][0])', + 'ctor = dict\nA = K\nprint(ctor({"x": A})["x"][0])'): + with self.subTest(value=value, body=body): + self.assertEqual(self.inspect('import os\nclass K:\n' + ' def __class_getitem__(cls, key):\n return ' + value + + '\n' + body + '\n') is None, value == '"safe"') + + def test_pr103_v19_computed_callable_state_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for target in ('tuple([K.__getitem__])[0]', 'next(iter([K.__getitem__]))'): + with self.subTest(value=value, target=target): + self.assertIsNotNone(self.inspect('import os\n' + 'def replacement(self, key):\n return ' + value + '\nclass K:\n' + ' def __getitem__(self, key):\n return "safe"\n' + 'target = ' + target + '\ntarget.__code__ = replacement.__code__\n' + 'print(K()[0])\n')) + + def test_pr103_v19_class_protocol_binder_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for binder in ('for __class_getitem__ in [leak]:\n pass', + '__class_getitem__, other = (leak, None)', + '(__class_getitem__ := leak)'): + with self.subTest(value=value, binder=binder): + self.assertIsNotNone(self.inspect('import os\ndef leak(cls, key):\n' + ' return ' + value + '\nclass K:\n ' + binder + '\nprint(K[0])\n')) + + def test_pr103_v19_mutable_protocol_dependencies_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for setup, returned, mutation in ( + ('state = ["safe"]', 'state[0]', 'state[0] = ' + value), + ('state = {"x": "safe"}', 'state["x"]', 'state["x"] = ' + value), + ('state = (["safe"],)', 'state[0][0]', 'state[0][0] = ' + value), + ): + with self.subTest(value=value, setup=setup): + self.assertIsNotNone(self.inspect('import os\ndef factory():\n ' + setup + + '\n class K:\n def __getitem__(self, key):\n return ' + + returned + '\n ' + mutation + '\n return K\n' + 'K = factory()\nprint(K()[0])\n')) + # A mutable default with an unclosed write is also unsupported, while + # the literal argument/default itself is not a sensitive value. + self.assertIsNotNone(self.inspect('class K:\n' + ' def __getitem__(self, key, state=["safe"]):\n return state[0]\n' + 'print(K()[0])\n')) + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: consumer = ('async def collect():\n async for item in K():\n' ' print(item)\n break\ncollect().send(None)\n') From 66c33aa19ab72603e48a8deb67fbe7d11064da6f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 23 Oct 2026 04:58:13 +0900 Subject: [PATCH 123/123] fix(evidence): close keyword member hook and callback proofs --- docs/evidence/g01-recovery-packet.md | 44 +++++- .../issue79-pr103-review-6172592368.md | 129 ++++++++++++++++++ .../issue79_regression_test.py | 58 ++++++++ 3 files changed, 225 insertions(+), 6 deletions(-) diff --git a/docs/evidence/g01-recovery-packet.md b/docs/evidence/g01-recovery-packet.md index bc6048e5..9d9a0dfb 100644 --- a/docs/evidence/g01-recovery-packet.md +++ b/docs/evidence/g01-recovery-packet.md @@ -22791,7 +22791,7 @@ def python_sensitive_read_violation(tree, parents): # Bounded literal-result proof, not a bound-method/effects interpreter. if value is None or id(value) in seen or error_operand_sensitive(value): return False - if any(isinstance(part, ast.Name) and any(isinstance(reference, (ast.List, ast.Tuple, ast.Dict, ast.Set)) + if any(isinstance(part, (ast.Name, ast.Attribute)) and any(isinstance(reference, (ast.List, ast.Tuple, ast.Dict, ast.Set)) and any(isinstance(item, (ast.List, ast.Dict, ast.Set)) for item in ast.walk(reference)) for reference in python_value_provenance(part, tree, parents)) for part in ast.walk(value)): return False # Named mutable dependencies/defaults cannot certify original literal contents. @@ -22818,6 +22818,21 @@ def python_sensitive_read_violation(tree, parents): return bool(alternatives) and all(subscription_result_proven(reference, seen) for reference in alternatives) + def subscription_state_targets(value): + for reference in python_value_provenance(value, tree, parents): + if not isinstance(reference, ast.Name): + continue + inventory = getattr(tree, "_issue79_sensitive_imported_alias_bindings", {}) + for scope in python_sensitive_alias_scope_chain(reference, parents): + bindings = inventory.get((id(scope), reference.id), ()) + if not bindings or any(kind in {"global", "nonlocal"} for kind, _ in bindings): + continue + if any(kind == "unknown" and isinstance(binding, ast.arg) + and not mutation_parameter_calls_closed(binding) for kind, binding in bindings): + return None # Callback-supplied parameters cannot close from defaults alone. + break + return protocol_callable_targets(value, set()) + def subscription_implementation_unproven(implementation): if any(isinstance(part, (ast.Yield, ast.YieldFrom)) and python_enclosing_scope(part, parents) is implementation for part in ast.walk(implementation)): @@ -22825,7 +22840,7 @@ def python_sensitive_read_violation(tree, parents): state_names = {"__defaults__", "__kwdefaults__", "__code__", "__globals__", "__closure__", "__dict__"} for part in ast.walk(tree): if isinstance(part, ast.Attribute) and part.attr in state_names: - targets = protocol_callable_targets(part.value, set()) + targets = subscription_state_targets(part.value) if not targets or implementation in targets or any(isinstance(target, ast.ClassDef) for target in targets): return True # Computed/unknown protected-state targets cannot prove disjoint identity. if (isinstance(part, ast.Call) and len(part.args) >= 2 @@ -22833,7 +22848,7 @@ def python_sensitive_read_violation(tree, parents): or python_unshadowed_builtin_reference(part.func, {"getattr", "delattr"}, tree, parents))): names = mutation_literal_names(part.args[1], set()) if not names or names & state_names: - targets = protocol_callable_targets(part.args[0], set()) + targets = subscription_state_targets(part.args[0]) if not targets or implementation in targets or any(isinstance(target, ast.ClassDef) for target in targets): return True return False @@ -22875,8 +22890,9 @@ def python_sensitive_read_violation(tree, parents): for target in python_value_provenance(reference.func, tree, parents))): if not subscription_wrapper_proven(reference.func, set()): return None # One visible builtin cannot certify unknown competing targets. - pending.extend(reference.args) - for argument in reference.args: + arguments = list(reference.args) + [keyword.value for keyword in reference.keywords] + pending.extend(arguments) + for argument in arguments: pending.extend(python_value_provenance(argument, tree, parents, initial_selection="iterate", receiver_origins=True)) if any(python_unshadowed_builtin_reference(target, {"dict"}, tree, parents) @@ -23278,7 +23294,7 @@ def python_sensitive_read_violation(tree, parents): return True assigned_display_methods = {} - display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__class_getitem__", "__aiter__", "__anext__", "__await__"} + display_protocol_names = {"__str__", "__repr__", "__format__", "__iter__", "__next__", "__getitem__", "__class_getitem__", "__aiter__", "__anext__", "__await__", "__set_name__"} def mutation_parameter_calls_closed(parameter): implementation = parents.get(parents.get(parameter)) if not isinstance(implementation, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): @@ -23353,6 +23369,22 @@ def python_sensitive_read_violation(tree, parents): for classdef in classes: assigned_display_methods.setdefault(classdef, []).append((method, candidate.value)) + for part in ast.walk(tree): + if (isinstance(part, ast.Attribute) and part.attr == "__set_name__" + and isinstance(part.ctx, (ast.Store, ast.Del))): + return "Python descriptor construction hooks are outside the reviewed protocol model" + if (isinstance(part, ast.Call) and len(part.args) >= 3 + and receiver_setter_proven(part.func, set()) + and "__set_name__" in mutation_literal_names(part.args[1], set())): + return "Python descriptor construction hooks are outside the reviewed protocol model" + for classdef in (part for part in ast.walk(tree) if isinstance(part, ast.ClassDef)): + if (any(method == "__set_name__" for method, _ in assigned_display_methods.get(classdef, ())) + or any(python_class_namespace_owner(part, parents) is classdef and ( + isinstance(part, (ast.FunctionDef, ast.AsyncFunctionDef)) and part.name == "__set_name__" + or isinstance(part, ast.Name) and isinstance(part.ctx, ast.Store) and part.id == "__set_name__") + for part in ast.walk(classdef))): + return "Python descriptor construction hooks are outside the reviewed protocol model" + def error_operand_repr_sensitive(value, seen, methods=("__repr__",)): # Factory/parameter graphs can contain cycles and many shared paths. # Expand each expression once, rather than cloning a recursive path set. diff --git a/docs/evidence/issue79-pr103-review-6172592368.md b/docs/evidence/issue79-pr103-review-6172592368.md index 8f585f4f..3b7806ef 100644 --- a/docs/evidence/issue79-pr103-review-6172592368.md +++ b/docs/evidence/issue79-pr103-review-6172592368.md @@ -3808,3 +3808,132 @@ a PR comment without a source amendment. Final-SHA reviews, push, hosted quick check, GitHub Codex review, Project and merge remain coordinator owned. The writer remains active as sole owner after the local commit; no full scan, push, GH write, live action or specimen execution occurred. + +### V20 keyword/member/hook/callback proof closure — authoritative RED + +Coordinator `msg_bfad44887904` authorized this bounded batch on +`3b44ecc7628a98b90f2d7a5a94e5e1c8a5d1adc9`. Read the complete +[P1 report 6349836840](https://github.com/1XP-AI/gh-runnerd/pull/103#issuecomment-6349836840) +with `gh api repos/1XP-AI/gh-runnerd/issues/comments/6349836840 --jq .body`. +Baseline packet is `bc6048e50d520d91ebe64b52f7a35a0019ebcb55`, harness +`1087c58de47f403d39d5f7926bc73eda29884009`, ledger +`8f585f4f6136998468294e67d26052fa3cd87672`. V19's 140-method/preflight/internal +approvals and quick check `39842750602` PASS remain genuine baseline evidence, +not rebuttals. Fresh V19 full scan was NOT RUN. + +Before packet edits this isolated command produced **four methods, 15 failed +assertions, zero errors in 0.287s, exit 1**: + +```sh +env -i PATH=/opt/homebrew/bin:/usr/bin:/bin LC_ALL=C /opt/homebrew/bin/python3.14 -I -B scripts/evidence_packet/issue79_regression_test.py -k test_pr103_v20 +``` + +Nine sensitive accepts were three dict keyword/double-star/alias receivers, +two direct/alias Holder mutable member reads, two directly defined/assigned +descriptor construction hooks, and two direct/aliased map callbacks replacing +getitem code through a defaulted parameter. Six literal-safe policy pairs +also accepted: two members, two hooks and two callbacks. Three safe keyword +mapping forms and the proven direct-only mutator control already passed. +All callback/descriptor programs are complete finite inert source strings, +never executed, compiled, evaluated or imported as programs. V19 did not prove +keyword receiver values, member mutability, construction hooks or closure of +callback invocation origins. + +### V20 initial GREEN and computed hook-installation closure + +Initial packet edits passed the isolated prefix plus V20/V19/V18/V17/V16 and +custom-iterator selectors: **21 methods in 0.624s, exit 0**. Initial combined +selection passed **144 methods in 21.959s**, and the identical expanded V9 +preflight passed in **12.336s**, with 206/328 helpers, seven regex proofs and +zero collisions/lost proofs. Lifetimes overlapped. These receipts apply to +initial packet `ca3b7587066c1116e0ba5f967640294f26508604` and harness +`7cdb641ef82354999c5d811445b367c6120e611b`; they are not final-source evidence. + +Before freezing, the construction-hook refusal was tested through +`alias = tuple([Descriptor])[0]`, followed by direct hook attribute installation +or `setattr(alias, "__set_name__", install)`. The isolated prefix plus +`-k test_pr103_v20_computed_hook_installation_policy` produced **one method, +four failed assertions, zero errors in 0.249s, exit 1**: two sensitive paths +and two harmless policy pairs. The complete source still constructs Descriptor +as K's field and prints the resulting class subscription; it remains inert. +This concrete unresolved same-boundary concern requires renewed focused and +preflight verification after refusing explicit hook installation independently +of an unproven computed class receiver. It does not justify a descriptor interpreter. + +### V20 renewed GREEN and frozen first review checkpoint + +Explicit `__set_name__` member stores/deletes and proven-setter installation +now refuse independently of whether the receiver class can be reconstructed. +The same isolated boundary selection passed **22 methods in 0.632s, exit 0**. +The renewed combined V14/V15 selection ran once on the revised batch and passed +**145 methods in 22.030s, exit 0**, retaining prior 140 plus five V20 methods. +The **identical expanded V9 preflight command** ran once on these revised +inputs and passed in **12.456s, exit 0**: 206 root helpers, 328 called original +helpers including nested definitions, seven scoped regex-receiver proofs +retained, zero collisions/lost proofs. Their process lifetimes overlapped. +The initial 144-method/preflight receipts retain their own input hashes; they +are not reattributed to this revised source. The initial 15 RED failures and +four computed-hook RED failures are the only failed checks in V20. + +The bounded proof changes are: + +- Wrapper discovery includes keyword and double-star values in the existing + argument and mapping-value projections. Proven literal-safe keyword mapping + subscriptions remain accepted. +- The same mutable-dependency refusal checks Attribute/member provenance as + well as Names, so original Holder contents cannot certify unchanged output. + Harmless mutable member cases conservatively refuse. +- Descriptor construction hooks are unsupported. Direct class hook definitions, + class-suite hook stores, assigned hooks and explicit proven-setter/member + installation refuse, including harmless hooks. There is no descriptor/hook + execution model or claim of generalized class-construction support. +- Protected-state target proof checks reachable parameter bindings with existing + `mutation_parameter_calls_closed` before resolving callable targets. Unknown + callback invocations cannot prove disjoint identity from a default alone. + Direct-only known mutator calls keep their proven default-target control; + callback and aliased callback safe pairs conservatively refuse. + +No architecture/interface expansion, allowlist relaxation or source-spelling +exception was needed, and no existing positive conflict was found. Every +recipe/specimen/callback/descriptor/coroutine remained inert AST text; only +inspected trusted scanner/harness definitions ran under isolated Python 3.14. + +Frozen packet is `9d9a0dfba93d08f04c62f5cb9f527e48cad5375a`; frozen harness +is `76a232bff88a0da828c6f52f60c329b0a91c1462`. `git diff --check` and the +identical V10 introduced-delta hygiene command against +`3b44ecc7628a98b90f2d7a5a94e5e1c8a5d1adc9` passed: exactly three owned +files, zero added-line pattern matches in all four documented categories. +This remains a bounded pattern check, not an exhaustive secret audit. Rollback +is a reviewed revert of this bounded three-file delta against that immutable +baseline, preserving all historical evidence with its actual source and scope. + +Fresh V20 full-packet scan **NOT RUN**, pending stable exact GitHub-approved +immutable-head qualification BEFORE MERGE and a coordinator PR receipt without +source amendment. Two provisional source reviews and explicit local-commit +authorization remain pending. No V20 commit/push, GH write, Go/full matrix, +live/canary, runner, JIT, credential/token, Docker, workflow dispatch, process +kill or global cleanup ran. Final-SHA reviews, hosted quick check, GitHub Codex +review, Project and merge remain coordinator owned. Source is frozen for review. + +### V20 provisional approvals and authorized local candidate preparation + +Coordinator `msg_bb7ad0b81ada` accepted contract `msg_a855fb80010d` and security +`msg_b1266d7a0d82` exact-source approvals of packet +`9d9a0dfba93d08f04c62f5cb9f527e48cad5375a` and harness +`76a232bff88a0da828c6f52f60c329b0a91c1462`. Contract reported five methods +PASS in 0.289s plus eight finite inert contrasts. Security reported 22 methods +PASS in 0.639s and 12 contrasts (eight refusals, four direct-default accepts). +These are coordinator-attributed reviewer receipts, not extra writer runs. +Security explicitly classified keyword-only callback contrasts as policy +controls: map cannot supply a keyword-only argument, so those cases are not +runtime leak demonstrations. The writer's positional callback specimens and +the earlier source-scoped RED results keep their own stated classifications. + +The coordinator authorized one exact-three-file local commit after these +receipts and final diff/hygiene only. Packet/harness remain unchanged and no +test repeat ran. **Fresh V20 full-packet scan NOT RUN**, pending stable exact +GitHub-approved-head qualification BEFORE MERGE, with its future receipt in +a PR comment without a source amendment. Final-SHA reviews, push, hosted quick +check, GitHub Codex review, Project and merge remain coordinator owned. The +writer stays active as sole owner after the local commit; no full scan, push, +GH write, live action or specimen execution occurred. diff --git a/scripts/evidence_packet/issue79_regression_test.py b/scripts/evidence_packet/issue79_regression_test.py index 1087c58d..76a232bf 100644 --- a/scripts/evidence_packet/issue79_regression_test.py +++ b/scripts/evidence_packet/issue79_regression_test.py @@ -8714,6 +8714,64 @@ def test_pr103_v19_mutable_protocol_dependencies_policy(self) -> None: ' def __getitem__(self, key, state=["safe"]):\n return state[0]\n' 'print(K()[0])\n')) + def test_pr103_v20_keyword_mapping_receiver(self) -> None: + for value in ('os.environ', '"safe"'): + for body in ('print(dict(x=K)["x"][0])', + 'print(dict(**{"x": K})["x"][0])', + 'values = {"x": K}\nctor = dict\nprint(ctor(**values)["x"][0])'): + with self.subTest(value=value, body=body): + self.assertEqual(self.inspect('import os\nclass K:\n' + ' def __class_getitem__(cls, key):\n return ' + value + + '\n' + body + '\n') is None, value == '"safe"') + + def test_pr103_v20_member_mutable_dependency_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for returned in ('Holder.state[0]', 'alias.state[0]'): + with self.subTest(value=value, returned=returned): + self.assertIsNotNone(self.inspect('import os\nclass Holder:\n' + ' state = ["safe"]\nalias = Holder\nclass K:\n' + ' def __getitem__(self, key):\n return ' + returned + + '\nHolder.state[0] = ' + value + '\nprint(K()[0])\n')) + + def test_pr103_v20_descriptor_construction_hook_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for setup in ('class Descriptor:\n def __set_name__(self, owner, name):\n' + ' owner.__class_getitem__ = lambda key: VALUE\n', + 'def install(self, owner, name):\n' + ' owner.__class_getitem__ = lambda key: VALUE\n' + 'class Descriptor: pass\nDescriptor.__set_name__ = install\n'): + with self.subTest(value=value, setup=setup): + self.assertIsNotNone(self.inspect('import os\n' + setup.replace('VALUE', value) + + 'class K:\n field = Descriptor()\nprint(K[0])\n')) + + def test_pr103_v20_callback_state_target_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for invoke in ('list(map(mutate, [K.__getitem__]))', + 'alias = mutate\nlist(map(alias, [K.__getitem__]))'): + with self.subTest(value=value, invoke=invoke): + self.assertIsNotNone(self.inspect('import os\n' + 'def replacement(self, key):\n return ' + value + '\n' + 'def safe(self, key):\n return "safe"\n' + 'def mutate(target=safe):\n target.__code__ = replacement.__code__\n' + 'class K:\n def __getitem__(self, key):\n return "safe"\n' + + invoke + '\nprint(K()[0])\n')) + self.assertIsNone(self.inspect('def safe(self, key):\n return "safe"\n' + 'def replacement(self, key):\n return "safe"\n' + 'def mutate(target=safe):\n target.__code__ = replacement.__code__\n' + 'class K:\n def __getitem__(self, key):\n return "safe"\n' + 'mutate()\nprint(K()[0])\n')) + + def test_pr103_v20_computed_hook_installation_policy(self) -> None: + for value in ('os.environ', '"safe"'): + for mutation in ('alias.__set_name__ = install', + 'setattr(alias, "__set_name__", install)'): + with self.subTest(value=value, mutation=mutation): + self.assertIsNotNone(self.inspect('import os\n' + 'def install(self, owner, name):\n' + ' owner.__class_getitem__ = lambda key: ' + value + '\n' + 'class Descriptor: pass\nalias = tuple([Descriptor])[0]\n' + + mutation + '\nclass K:\n field = Descriptor()\nprint(K[0])\n')) + def test_pr103_v13_factory_returned_protocol_classes(self) -> None: consumer = ('async def collect():\n async for item in K():\n' ' print(item)\n break\ncollect().send(None)\n')