From 387a647355e48d44333954fadf48d5b02335290c Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Sat, 26 Sep 2026 20:24:56 +0900 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 07/28] 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 08/28] 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 09/28] 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 10/28] 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 11/28] 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 12/28] 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 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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 23/28] 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 24/28] 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 25/28] 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 26/28] 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 27/28] 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 28/28] 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/'))