From 8748732d5881656e6efb7f658b5689ccbe6d0903 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:18:36 -0500 Subject: [PATCH 1/5] Preserve normal Gate13 PowerShell and bind exact SSH identity --- docs/GATE13_WINDOWS_SSH.md | 9 +++ scripts/gate13_gcp_provider.py | 93 +++++++++++++++++------ scripts/gate13_host_job.py | 3 +- scripts/gate13_windows_client_startup.ps1 | 70 ++++++++++++----- tests/test_gate13_client_startup.py | 29 ++++++- tests/test_gate13_gcp_provider.py | 66 ++++++++++++++++ tests/test_gate13_host_job.py | 8 ++ 7 files changed, 232 insertions(+), 46 deletions(-) create mode 100644 docs/GATE13_WINDOWS_SSH.md diff --git a/docs/GATE13_WINDOWS_SSH.md b/docs/GATE13_WINDOWS_SSH.md new file mode 100644 index 000000000..1b1082f45 --- /dev/null +++ b/docs/GATE13_WINDOWS_SSH.md @@ -0,0 +1,9 @@ +# Gate 13 Windows SSH + +Gate 13 SSH enters the machine's normal Windows PowerShell explicitly because Windows OpenSSH may start commands in cmd.exe. Script commands use UTF-16LE EncodedCommand to preserve quotes and shell metacharacters. Profiles remain enabled; the harness does not replace HOME or isolate PowerShell. The stage uses its fixed script path, and scheduled qualification and cleanup children also retain profiles. + +The zero-input launcher reads optional `ssh_private_key` (absolute local path) and `ssh_public_fingerprint` (`SHA256:` public-key fingerprint from `ssh-keygen -lf PATH.pub -E sha256`) from `config/gate13_gcp.json`. Without these fields it uses the existing `~/.ssh/google_compute_engine` identity. It does not select the last project metadata key. Every SSH and SCP operation passes the same explicit private-key path. Before client creation the provider derives its public key from that private key, verifies an existing .pub file matches, and checks the optional fingerprint. Missing configured identities and mismatches stop before metadata installation. Do not put private-key contents in configuration. + +Client metadata contains only that exact public key. Startup refreshes authorized keys even when the ready marker already exists. The installer replaces stale explicit ACL entries, disables inheritance, and verifies the resulting SID-based allow list. The ordinary user owns its profile key; program-data key files permit only Administrators and SYSTEM, preserving the existing OpenSSH Match configuration. IAP transport and existing host-key verification remain unchanged. + +Local qualification exercises native cmd.exe-to-PowerShell execution, real temporary ssh-keygen identities, and native ACL replacement on owned temporary files. These tests do not establish a new GCE/IAP cloud acceptance run. diff --git a/scripts/gate13_gcp_provider.py b/scripts/gate13_gcp_provider.py index 136f70d63..9d9ef54e6 100644 --- a/scripts/gate13_gcp_provider.py +++ b/scripts/gate13_gcp_provider.py @@ -134,6 +134,29 @@ def json(self, argv: Sequence[str | os.PathLike[str]], *, action: str, timeout: raise CommandError(f"{action} returned invalid JSON") from exc +def _windows_powershell_command(script: str) -> str: + """Cross cmd.exe without parsing script metacharacters; retain normal profiles.""" + encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") + return f"powershell.exe -NoLogo -NonInteractive -EncodedCommand {encoded}" + + +def _ssh_public_identity(value: str) -> tuple[str, str]: + fields = value.strip().split() + if len(value.splitlines()) != 1 or len(fields) < 2 or fields[0] not in {"ssh-rsa", "ssh-ed25519"}: + raise Gate13CloudError("SSH public key must contain one supported key") + try: + blob = base64.b64decode(fields[1], validate=True) + except ValueError as exc: + raise Gate13CloudError("SSH public key encoding is invalid") from exc + type_length = int.from_bytes(blob[:4], "big") + if blob[4:4 + type_length] != fields[0].encode("ascii"): + raise Gate13CloudError("SSH public key algorithm does not match encoded key") + if len(blob) < 32: + raise Gate13CloudError("SSH public key is truncated") + fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode("ascii").rstrip("=") + return " ".join(fields[:2]), fingerprint + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: @@ -476,14 +499,19 @@ class GcpConfig: route_wheel_path: str route_wheel_sha256: str route_wheel_bytes: int + ssh_private_key: str | None = None + ssh_public_fingerprint: str | None = None @classmethod def load(cls, path: Path) -> "GcpConfig": value = _strict_object(path.read_text(encoding="utf-8"), "GCP one-click configuration") - expected = set(cls.__dataclass_fields__) + optional = {"ssh_private_key", "ssh_public_fingerprint"} + expected = set(cls.__dataclass_fields__) - optional string_fields = expected - {"route_wheel_bytes"} if ( - set(value) != expected + not expected.issubset(value) + or set(value) - expected - optional + or any(value.get(field) is not None and (not isinstance(value[field], str) or not value[field]) for field in optional) or not all(isinstance(value[field], str) and value[field] for field in string_fields) or not isinstance(value["route_wheel_bytes"], int) or isinstance(value["route_wheel_bytes"], bool) @@ -491,6 +519,10 @@ def load(cls, path: Path) -> "GcpConfig": ): raise Gate13CloudError("GCP one-click configuration fields are invalid") result = cls(**value) + if result.ssh_private_key is not None and not Path(result.ssh_private_key).is_absolute(): + raise Gate13CloudError("configured SSH private key must be absolute") + if result.ssh_public_fingerprint is not None and not re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", result.ssh_public_fingerprint): + raise Gate13CloudError("configured SSH public fingerprint is invalid") for commit in ( result.route_source_commit, result.catalog_source_commit, @@ -523,9 +555,13 @@ def __init__( signed_url: Callable[[PackageArtifact], str], progress: Callable[[str], None] = print, sleeper: Callable[[float], None] = time.sleep, + ssh_private_key: Path | None = None, + ssh_public_fingerprint: str | None = None, ) -> None: if not _RUN_RE.fullmatch(run_id): raise Gate13CloudError("GCP run ID is invalid") + self.ssh_private_key = (ssh_private_key or (Path(config.ssh_private_key) if config.ssh_private_key else None) or Path.home() / ".ssh" / "google_compute_engine").resolve() + self.ssh_public_fingerprint = ssh_public_fingerprint or config.ssh_public_fingerprint self.run_id = run_id self.repository_root = repository_root self.output_root = output_root @@ -596,6 +632,8 @@ def _ssh( "--zone", self.config.zone, "--tunnel-through-iap", + "--ssh-key-file", + os.fspath(self.ssh_private_key), "--quiet", "--command", command, @@ -622,6 +660,8 @@ def _scp( "--zone", self.config.zone, "--tunnel-through-iap", + "--ssh-key-file", + os.fspath(self.ssh_private_key), "--quiet", action=action, timeout=timeout, @@ -710,25 +750,30 @@ def _wait_ssh( raise Gate13CloudError(f"{action} exceeded its time bound") def _ensure_ssh_key(self) -> Path: - root = Path.home() / ".ssh" - private = root / "google_compute_engine" - public = private.with_suffix(".pub") - root.mkdir(mode=0o700, exist_ok=True) - if public.is_file() and 32 <= public.stat().st_size <= 16_384: - return public - if private.is_file(): - result = self.runner.run( - ["ssh-keygen", "-y", "-f", private], action="Deriving the GCP SSH public key", timeout=60 - ) - public.write_text(result.stdout.strip() + "\n", encoding="ascii", newline="\n") - else: + private = self.ssh_private_key + public = private.with_suffix(private.suffix + ".pub") + private.parent.mkdir(mode=0o700, exist_ok=True) + if not private.is_file(): + if public.exists() or self.ssh_public_fingerprint is not None: + raise Gate13CloudError("configured SSH private key is unavailable") self.runner.run( ["ssh-keygen", "-t", "rsa", "-b", "3072", "-N", "", "-f", private], - action="Creating the GCP SSH key", - timeout=120, + action="Creating the GCP SSH key", timeout=120, ) - if not public.is_file(): - raise Gate13CloudError("GCP SSH public key is unavailable") + result = self.runner.run( + ["ssh-keygen", "-y", "-f", private], action="Checking the exact GCP SSH public key", timeout=60, + ) + identity, fingerprint = _ssh_public_identity(result.stdout.strip()) + if self.ssh_public_fingerprint is not None and fingerprint != self.ssh_public_fingerprint: + raise Gate13CloudError("configured SSH public fingerprint does not match private key") + if public.exists(): + if not public.is_file() or public.stat().st_size > 16_384: + raise Gate13CloudError("GCP SSH public key is invalid") + existing, _ = _ssh_public_identity(public.read_text(encoding="ascii").strip()) + if existing != identity: + raise Gate13CloudError("GCP SSH public key does not match private key") + else: + public.write_text(identity + "\n", encoding="ascii", newline="\n") return public def _resource_absence(self) -> tuple[list[str], list[str], list[str]]: @@ -1750,13 +1795,12 @@ def prepare_client(self, platform: str, package: PackageArtifact) -> Mapping[str name = self.clients[platform] if platform == "windows": user = "Gate13Admin" - ready_command = ( - "powershell.exe -NoLogo -NoProfile -NonInteractive -Command " - "\"if (!(Test-Path -LiteralPath 'C:\\Gate13Bootstrap\\ready.txt' " + ready_command = _windows_powershell_command( + "if (!(Test-Path -LiteralPath 'C:\\Gate13Bootstrap\\ready.txt' " "-PathType Leaf)) { exit 1 }; " "$p=@(Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | " "Where-Object {$_.UserName -like '*\\M'}); " - 'if ($p.Count -ne 1 -or $p[0].SessionId -lt 1) { exit 1 }"' + 'if ($p.Count -ne 1 -or $p[0].SessionId -lt 1) { exit 1 }' ) else: user = None @@ -1789,7 +1833,7 @@ def prepare_client(self, platform: str, package: PackageArtifact) -> Mapping[str ) if platform == "windows": command = ( - "powershell.exe -NoLogo -NoProfile -NonInteractive " + "powershell.exe -NoLogo -NonInteractive " "-ExecutionPolicy Bypass -File C:\\Gate13Run\\stage.ps1" ) else: @@ -1902,8 +1946,7 @@ def _capture_host_failure(self, platform: str, instance: str) -> Path: "[Console]::Out.Write([IO.File]::ReadAllText(" f"'C:\\Gate13Run\\{filename}'))" ) - encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") - command = f"powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}" + command = _windows_powershell_command(script) user = "Gate13Admin" else: command = f"sudo cat /qualification/{filename}" diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index 181f71054..ad43feb23 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -56,6 +56,7 @@ "PROGRAMFILES(X86)", "PROGRAMW6432", "PUBLIC", + "PSModulePath", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", @@ -544,7 +545,6 @@ def _entrypoint_argv(config: HostJobConfig) -> list[str]: return [ r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "-NoLogo", - "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", @@ -869,7 +869,6 @@ def _powershell_argv(script: str) -> list[str]: return [ r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "-NoLogo", - "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded, diff --git a/scripts/gate13_windows_client_startup.ps1 b/scripts/gate13_windows_client_startup.ps1 index a3be52f1f..020753136 100644 --- a/scripts/gate13_windows_client_startup.ps1 +++ b/scripts/gate13_windows_client_startup.ps1 @@ -7,6 +7,54 @@ $bootstrapRoot = "C:\Gate13Bootstrap" $runRoot = "C:\Gate13Run" $downloadRoot = "C:\Gate13Download" New-Item -ItemType Directory -Force -Path $bootstrapRoot, $runRoot, $downloadRoot | Out-Null +function Set-Gate13KeyAcl { + param([string]$Path, [string[]]$AllowedSids, [switch]$Directory) + $item = Get-Item -LiteralPath $Path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "SSH key path is a reparse point" } + if ($Directory) { $acl = [Security.AccessControl.DirectorySecurity]::new() } + else { $acl = [Security.AccessControl.FileSecurity]::new() } + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner([Security.Principal.SecurityIdentifier]::new($AllowedSids[0])) + foreach ($sid in $AllowedSids) { + $identity = [Security.Principal.SecurityIdentifier]::new($sid) + if ($Directory) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + } else { + $rule = [Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'Allow') + } + $acl.AddAccessRule($rule) + } + $item.SetAccessControl($acl) + $actual = $item.GetAccessControl() + $rules = @($actual.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + if (!$actual.AreAccessRulesProtected -or $rules.Count -ne $AllowedSids.Count) { throw "SSH key ACL verification failed" } + foreach ($rule in $rules) { + if ($rule.IsInherited -or $rule.IdentityReference.Value -notin $AllowedSids -or $rule.AccessControlType -ne 'Allow' -or $rule.FileSystemRights -ne 'FullControl') { throw "SSH key ACL verification failed" } + } +} + +function Install-Gate13SshKey { + $publicKey = (Invoke-RestMethod -Headers $metadataHeaders -Uri "$metadataRoot/gate13-ssh-public-key").Trim() + if ($publicKey -notmatch '^ssh-(rsa|ed25519) [A-Za-z0-9+/]+={0,2}( [^\r\n]*)?$') { throw "Expected one exact SSH public key" } + $ordinarySid = (Get-LocalUser -Name 'M').SID.Value + $sshRoot = 'C:\Users\M\.ssh' + $programDataSsh = Join-Path $env:ProgramData 'ssh' + New-Item -ItemType Directory -Force -Path $sshRoot, $programDataSsh | Out-Null + Set-Gate13KeyAcl -Path $sshRoot -AllowedSids @($ordinarySid, 'S-1-5-18') -Directory + foreach ($name in @('authorized_keys')) { + $path = Join-Path $sshRoot $name + if (Test-Path -LiteralPath $path) { Set-Gate13KeyAcl -Path $path -AllowedSids @($ordinarySid, 'S-1-5-18') } + [IO.File]::WriteAllText($path, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) + Set-Gate13KeyAcl -Path $path -AllowedSids @($ordinarySid, 'S-1-5-18') + } + foreach ($name in @('administrators_authorized_keys', 'communityai_gate13_m_authorized_keys')) { + $path = Join-Path $programDataSsh $name + if (Test-Path -LiteralPath $path) { Set-Gate13KeyAcl -Path $path -AllowedSids @('S-1-5-32-544', 'S-1-5-18') } + [IO.File]::WriteAllText($path, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) + Set-Gate13KeyAcl -Path $path -AllowedSids @('S-1-5-32-544', 'S-1-5-18') + } +} + $readyMarker = Join-Path $bootstrapRoot "ready.txt" if (Test-Path -LiteralPath $readyMarker -PathType Leaf) { Set-Service -Name sshd -StartupType Automatic @@ -17,6 +65,7 @@ if (Test-Path -LiteralPath $readyMarker -PathType Leaf) { } else { Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True -Profile Any } + Install-Gate13SshKey return } @@ -59,23 +108,8 @@ if ($openSshMemberNames -notcontains "$env:COMPUTERNAME\M") { } Remove-LocalGroupMember -Group "Administrators" -Member "M" -ErrorAction SilentlyContinue -$publicKey = (Invoke-RestMethod -Headers $metadataHeaders -Uri "$metadataRoot/gate13-ssh-public-key").Trim() -$profileRoot = "C:\Users\M" -$sshRoot = Join-Path $profileRoot ".ssh" -New-Item -ItemType Directory -Force -Path $profileRoot, $sshRoot | Out-Null -$authorizedKeys = Join-Path $sshRoot "authorized_keys" -[IO.File]::WriteAllText($authorizedKeys, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) -& icacls.exe $sshRoot /inheritance:r /grant:r "M:(OI)(CI)F" "SYSTEM:(OI)(CI)F" | Out-Null -& icacls.exe $authorizedKeys /inheritance:r /grant:r "M:F" "SYSTEM:F" | Out-Null - -$programDataSsh = Join-Path $env:ProgramData "ssh" -New-Item -ItemType Directory -Force -Path $programDataSsh | Out-Null -$administratorKeys = Join-Path $programDataSsh "administrators_authorized_keys" -$ordinaryKeys = Join-Path $programDataSsh "communityai_gate13_m_authorized_keys" -[IO.File]::WriteAllText($administratorKeys, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) -[IO.File]::WriteAllText($ordinaryKeys, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) -& icacls.exe $administratorKeys /inheritance:r /grant:r "Administrators:F" "SYSTEM:F" | Out-Null -& icacls.exe $ordinaryKeys /inheritance:r /grant:r "Administrators:F" "SYSTEM:F" | Out-Null +Install-Gate13SshKey +$programDataSsh = Join-Path $env:ProgramData 'ssh' $sshdConfig = Join-Path $programDataSsh "sshd_config" if (-not (Test-Path -LiteralPath $sshdConfig -PathType Leaf)) { Copy-Item -LiteralPath "$env:WINDIR\System32\OpenSSH\sshd_config_default" -Destination $sshdConfig @@ -132,7 +166,7 @@ Set-ItemProperty -Path $winlogon -Name DefaultUserName -Value "M" -Type String Set-ItemProperty -Path $winlogon -Name DefaultDomainName -Value $env:COMPUTERNAME -Type String Set-ItemProperty -Path $winlogon -Name DefaultPassword -Value $plainPassword -Type String Set-ItemProperty -Path $winlogon -Name AutoLogonCount -Value 1 -Type DWord -$clearArgument = '-NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 60; $p = ''HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon''; Remove-ItemProperty -Path $p -Name DefaultPassword -ErrorAction SilentlyContinue; Set-ItemProperty -Path $p -Name AutoAdminLogon -Value ''0'' -Type String"' +$clearArgument = '-WindowStyle Hidden -Command "Start-Sleep -Seconds 60; $p = ''HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon''; Remove-ItemProperty -Path $p -Name DefaultPassword -ErrorAction SilentlyContinue; Set-ItemProperty -Path $p -Name AutoAdminLogon -Value ''0'' -Type String"' $clearAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $clearArgument $clearTrigger = New-ScheduledTaskTrigger -AtLogOn -User "M" Register-ScheduledTask -TaskName "Gate13ClearAutoLogon" -Action $clearAction -Trigger $clearTrigger -User "SYSTEM" -RunLevel Highest -Force | Out-Null diff --git a/tests/test_gate13_client_startup.py b/tests/test_gate13_client_startup.py index 6f43009fe..f7515d7d4 100644 --- a/tests/test_gate13_client_startup.py +++ b/tests/test_gate13_client_startup.py @@ -42,7 +42,6 @@ def test_windows_bootstrap_parses_natively(): [ r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "-NoLogo", - "-NoProfile", "-NonInteractive", "-Command", probe, @@ -135,3 +134,31 @@ def test_linux_bootstrap_parses_natively(): timeout=30, ) assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires native Windows ACLs") +def test_key_acl_replaces_extra_grants_and_is_idempotent(tmp_path): + source = WINDOWS.read_text(encoding="utf-8") + function = source[source.index("function Set-Gate13KeyAcl"):source.index("function Install-Gate13SshKey")] + target = tmp_path / "authorized_keys" + target.write_text("test") + probe = function + f"\n$p='{target}';" + ( + "$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;" + "$item=Get-Item -LiteralPath $p;$acl=$item.GetAccessControl();" + "$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new('S-1-1-0','Read','Allow'));" + "$item.SetAccessControl($acl);" + "Set-Gate13KeyAcl -Path $p -AllowedSids @($sid);" + "Set-Gate13KeyAcl -Path $p -AllowedSids @($sid);" + "$a=$item.GetAccessControl();" + "if(@($a.Access).Count -ne 1 -or !$a.AreAccessRulesProtected){exit 2}" + ) + result = subprocess.run(["powershell.exe", "-NoLogo", "-NonInteractive", "-Command", probe], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr or result.stdout + + +def test_ready_restart_refreshes_key_and_profiles_remain_enabled(): + source = WINDOWS.read_text(encoding="utf-8") + ready = source[source.index('if (Test-Path -LiteralPath $readyMarker'):source.index('$capability =')] + assert "Install-Gate13SshKey" in ready + assert "-NoProfile" not in source + assert "-NoProfile" not in (ROOT / "scripts" / "gate13_gcp_provider.py").read_text() diff --git a/tests/test_gate13_gcp_provider.py b/tests/test_gate13_gcp_provider.py index d9bcca15a..a58ce41f6 100644 --- a/tests/test_gate13_gcp_provider.py +++ b/tests/test_gate13_gcp_provider.py @@ -798,3 +798,69 @@ def run(self, argv, **_kwargs): assert item._describe_instance(item.clients["windows"], check=False) is None assert len(calls) == 1 + + +def test_windows_adapter_survives_real_cmd_boundary(): + if sys.platform != "win32": + pytest.skip("requires native Windows shell") + script = "[Console]::Out.Write('literal & | > < % !'); exit 0" + command = gcp._windows_powershell_command(script) + assert command.split()[:4] == ["powershell.exe", "-NoLogo", "-NonInteractive", "-EncodedCommand"] + assert base64.b64decode(command.split()[-1]).decode("utf-16-le") == script + result = subprocess.run(["cmd.exe", "/d", "/s", "/c", command], capture_output=True, timeout=30) + assert result.returncode == 0, result.stderr + assert b"literal & | > < % !" in result.stdout + + +def test_exact_key_matches_real_private_key_and_rejects_stale_public(tmp_path): + import shutil + keygen = shutil.which("ssh-keygen") + if keygen is None: + pytest.skip("requires ssh-keygen") + key = tmp_path / "selected.key" + subprocess.run([keygen, "-t", "ed25519", "-N", "", "-f", str(key)], check=True, capture_output=True) + public = Path(str(key) + ".pub") + identity, fingerprint = gcp._ssh_public_identity(public.read_text().strip()) + item = provider(tmp_path, LoggedRunner(tmp_path / "journal.jsonl", progress=lambda _: None)) + item.ssh_private_key = key + item.ssh_public_fingerprint = fingerprint + assert item._ensure_ssh_key() == public + public.write_text(identity + " unrelated-comment\n", encoding="ascii") + assert item._ensure_ssh_key() == public + item.ssh_public_fingerprint = "SHA256:incorrect" + with pytest.raises(gcp.Gate13CloudError, match="fingerprint"): + item._ensure_ssh_key() + item.ssh_public_fingerprint = fingerprint + other = tmp_path / "other" + subprocess.run([keygen, "-t", "ed25519", "-N", "", "-f", str(other)], check=True, capture_output=True) + public.write_text(Path(str(other) + ".pub").read_text(), encoding="ascii") + with pytest.raises(gcp.Gate13CloudError, match="does not match"): + item._ensure_ssh_key() + + +def test_ssh_and_scp_use_the_same_explicit_private_key(tmp_path): + fake = CreateRunner() + item = provider(tmp_path, fake) + item.ssh_private_key = tmp_path / "exact project.key" + item._ssh("test", "echo test", action="probe") + item._scp([tmp_path / "file"], "test:/tmp/", action="probe") + for argv, _ in fake.calls: + assert argv[argv.index("--ssh-key-file") + 1] == str(item.ssh_private_key) + assert "--tunnel-through-iap" in argv + assert not any("StrictHostKeyChecking=no" in arg for arg in argv) + + +def test_key_config_reaches_launcher_provider_and_rejects_relative_path(tmp_path): + value = json.loads((ROOT / 'config' / 'gate13_gcp.json').read_text()) + value['ssh_private_key'] = str((tmp_path / 'selected.key').resolve()) + value['ssh_public_fingerprint'] = 'SHA256:' + 'A' * 43 + path = tmp_path / 'config.json' + path.write_text(json.dumps(value)) + config = GcpConfig.load(path) + item = GcpProvider(run_id=RUN_ID, repository_root=ROOT, output_root=tmp_path, config=config, runner=CreateRunner(), signed_url=lambda _: '') + assert item.ssh_private_key == Path(value['ssh_private_key']) + assert item.ssh_public_fingerprint == value['ssh_public_fingerprint'] + value['ssh_private_key'] = 'relative.key' + path.write_text(json.dumps(value)) + with pytest.raises(gcp.Gate13CloudError, match='absolute'): + GcpConfig.load(path) diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index 90a66837c..b53cb7181 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -655,3 +655,11 @@ def test_public_cli_failure_is_bounded_and_path_free(capsys, tmp_path): "schema_version": 1, } assert str(missing) not in output + + +def test_windows_host_powershell_retains_normal_profiles(config_factory): + path, _ = config_factory("windows") + config = host_job.load_config(path) + assert "-NoProfile" not in host_job._entrypoint_argv(config) + assert "-NoProfile" not in host_job._powershell_argv("Write-Output 'normal'") + assert "PSModulePath" in host_job.WINDOWS_RUNTIME_ENVIRONMENT From 9adc967c92465baaba9d4c1470704019c3940939 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:19:23 -0500 Subject: [PATCH 2/5] Pin Gate13 Windows bootstrap to qualified SSH fix --- config/gate13_gcp.json | 2 +- tests/test_gate13_gcp_provider.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/config/gate13_gcp.json b/config/gate13_gcp.json index ac00e497d..412fac8c6 100644 --- a/config/gate13_gcp.json +++ b/config/gate13_gcp.json @@ -23,6 +23,6 @@ "windows_image": "windows-server-2025-dc-v20260814", "windows_image_project": "windows-cloud", "windows_machine_type": "e2-standard-8", - "windows_startup_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", + "windows_startup_commit": "8748732d5881656e6efb7f658b5689ccbe6d0903", "zone": "us-central1-b" } diff --git a/tests/test_gate13_gcp_provider.py b/tests/test_gate13_gcp_provider.py index a58ce41f6..ae948a893 100644 --- a/tests/test_gate13_gcp_provider.py +++ b/tests/test_gate13_gcp_provider.py @@ -285,7 +285,7 @@ def test_route_bundle_uses_fixed_runtime_and_unchanged_signed_catalog(tmp_path): assert hashlib.sha256(payload).hexdigest() == digest -def test_client_startup_scripts_are_taken_from_the_successful_run(tmp_path): +def test_client_startup_scripts_are_taken_from_the_pinned_qualified_sources(tmp_path): item = provider( tmp_path, LoggedRunner(tmp_path / "journal.jsonl", progress=lambda _message: None), @@ -294,7 +294,7 @@ def test_client_startup_scripts_are_taken_from_the_successful_run(tmp_path): expected = { "windows": ( 8779, - "3f8600c42a3c0765e100963c2e28cdef7c6b248992924ff3406941aefce7cf47", + "a5622b57b4f9143c871d67eba70de61aea10fa22d33f084349ab6c0997512e93", ), "linux": ( 3808, @@ -864,3 +864,11 @@ def test_key_config_reaches_launcher_provider_and_rejects_relative_path(tmp_path path.write_text(json.dumps(value)) with pytest.raises(gcp.Gate13CloudError, match='absolute'): GcpConfig.load(path) + + +def test_config_pins_the_fixed_windows_startup_blob(tmp_path): + item = provider(tmp_path, LoggedRunner(tmp_path / 'journal.jsonl', progress=lambda _: None)) + actual = item._client_startup_script('windows').read_text(encoding='utf-8') + assert actual == (ROOT / 'scripts' / 'gate13_windows_client_startup.ps1').read_text(encoding='utf-8') + assert '-NoProfile' not in actual + assert 'Install-Gate13SshKey' in actual From 4f8262fb6952ad7c04800a717f4d39889b253780 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:20:50 -0500 Subject: [PATCH 3/5] Format and qualify Gate13 SSH contract regressions --- scripts/gate13_gcp_provider.py | 31 ++++++++++++++--------- tests/test_gate13_client_startup.py | 30 ++++++++++++++--------- tests/test_gate13_gcp_provider.py | 38 +++++++++++++++++------------ 3 files changed, 61 insertions(+), 38 deletions(-) diff --git a/scripts/gate13_gcp_provider.py b/scripts/gate13_gcp_provider.py index 9d9ef54e6..77924620c 100644 --- a/scripts/gate13_gcp_provider.py +++ b/scripts/gate13_gcp_provider.py @@ -149,7 +149,7 @@ def _ssh_public_identity(value: str) -> tuple[str, str]: except ValueError as exc: raise Gate13CloudError("SSH public key encoding is invalid") from exc type_length = int.from_bytes(blob[:4], "big") - if blob[4:4 + type_length] != fields[0].encode("ascii"): + if blob[4 : 4 + type_length] != fields[0].encode("ascii"): raise Gate13CloudError("SSH public key algorithm does not match encoded key") if len(blob) < 32: raise Gate13CloudError("SSH public key is truncated") @@ -511,7 +511,10 @@ def load(cls, path: Path) -> "GcpConfig": if ( not expected.issubset(value) or set(value) - expected - optional - or any(value.get(field) is not None and (not isinstance(value[field], str) or not value[field]) for field in optional) + or any( + value.get(field) is not None and (not isinstance(value[field], str) or not value[field]) + for field in optional + ) or not all(isinstance(value[field], str) and value[field] for field in string_fields) or not isinstance(value["route_wheel_bytes"], int) or isinstance(value["route_wheel_bytes"], bool) @@ -521,7 +524,9 @@ def load(cls, path: Path) -> "GcpConfig": result = cls(**value) if result.ssh_private_key is not None and not Path(result.ssh_private_key).is_absolute(): raise Gate13CloudError("configured SSH private key must be absolute") - if result.ssh_public_fingerprint is not None and not re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", result.ssh_public_fingerprint): + if result.ssh_public_fingerprint is not None and not re.fullmatch( + r"SHA256:[A-Za-z0-9+/]{43}", result.ssh_public_fingerprint + ): raise Gate13CloudError("configured SSH public fingerprint is invalid") for commit in ( result.route_source_commit, @@ -560,7 +565,11 @@ def __init__( ) -> None: if not _RUN_RE.fullmatch(run_id): raise Gate13CloudError("GCP run ID is invalid") - self.ssh_private_key = (ssh_private_key or (Path(config.ssh_private_key) if config.ssh_private_key else None) or Path.home() / ".ssh" / "google_compute_engine").resolve() + self.ssh_private_key = ( + ssh_private_key + or (Path(config.ssh_private_key) if config.ssh_private_key else None) + or Path.home() / ".ssh" / "google_compute_engine" + ).resolve() self.ssh_public_fingerprint = ssh_public_fingerprint or config.ssh_public_fingerprint self.run_id = run_id self.repository_root = repository_root @@ -758,10 +767,13 @@ def _ensure_ssh_key(self) -> Path: raise Gate13CloudError("configured SSH private key is unavailable") self.runner.run( ["ssh-keygen", "-t", "rsa", "-b", "3072", "-N", "", "-f", private], - action="Creating the GCP SSH key", timeout=120, + action="Creating the GCP SSH key", + timeout=120, ) result = self.runner.run( - ["ssh-keygen", "-y", "-f", private], action="Checking the exact GCP SSH public key", timeout=60, + ["ssh-keygen", "-y", "-f", private], + action="Checking the exact GCP SSH public key", + timeout=60, ) identity, fingerprint = _ssh_public_identity(result.stdout.strip()) if self.ssh_public_fingerprint is not None and fingerprint != self.ssh_public_fingerprint: @@ -1800,7 +1812,7 @@ def prepare_client(self, platform: str, package: PackageArtifact) -> Mapping[str "-PathType Leaf)) { exit 1 }; " "$p=@(Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | " "Where-Object {$_.UserName -like '*\\M'}); " - 'if ($p.Count -ne 1 -or $p[0].SessionId -lt 1) { exit 1 }' + "if ($p.Count -ne 1 -or $p[0].SessionId -lt 1) { exit 1 }" ) else: user = None @@ -1832,10 +1844,7 @@ def prepare_client(self, platform: str, package: PackageArtifact) -> Mapping[str timeout=900, ) if platform == "windows": - command = ( - "powershell.exe -NoLogo -NonInteractive " - "-ExecutionPolicy Bypass -File C:\\Gate13Run\\stage.ps1" - ) + command = "powershell.exe -NoLogo -NonInteractive " "-ExecutionPolicy Bypass -File C:\\Gate13Run\\stage.ps1" else: command = "sudo bash /tmp/stage.sh" result = self._ssh( diff --git a/tests/test_gate13_client_startup.py b/tests/test_gate13_client_startup.py index f7515d7d4..06cc581f5 100644 --- a/tests/test_gate13_client_startup.py +++ b/tests/test_gate13_client_startup.py @@ -139,26 +139,32 @@ def test_linux_bootstrap_parses_natively(): @pytest.mark.skipif(sys.platform != "win32", reason="requires native Windows ACLs") def test_key_acl_replaces_extra_grants_and_is_idempotent(tmp_path): source = WINDOWS.read_text(encoding="utf-8") - function = source[source.index("function Set-Gate13KeyAcl"):source.index("function Install-Gate13SshKey")] + function = source[source.index("function Set-Gate13KeyAcl") : source.index("function Install-Gate13SshKey")] target = tmp_path / "authorized_keys" target.write_text("test") - probe = function + f"\n$p='{target}';" + ( - "$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;" - "$item=Get-Item -LiteralPath $p;$acl=$item.GetAccessControl();" - "$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new('S-1-1-0','Read','Allow'));" - "$item.SetAccessControl($acl);" - "Set-Gate13KeyAcl -Path $p -AllowedSids @($sid);" - "Set-Gate13KeyAcl -Path $p -AllowedSids @($sid);" - "$a=$item.GetAccessControl();" - "if(@($a.Access).Count -ne 1 -or !$a.AreAccessRulesProtected){exit 2}" + probe = ( + function + + f"\n$p='{target}';" + + ( + "$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;" + "$item=Get-Item -LiteralPath $p;$acl=$item.GetAccessControl();" + "$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new('S-1-1-0','Read','Allow'));" + "$item.SetAccessControl($acl);" + "Set-Gate13KeyAcl -Path $p -AllowedSids @($sid);" + "Set-Gate13KeyAcl -Path $p -AllowedSids @($sid);" + "$a=$item.GetAccessControl();" + "if(@($a.Access).Count -ne 1 -or !$a.AreAccessRulesProtected){exit 2}" + ) + ) + result = subprocess.run( + ["powershell.exe", "-NoLogo", "-NonInteractive", "-Command", probe], capture_output=True, text=True, timeout=30 ) - result = subprocess.run(["powershell.exe", "-NoLogo", "-NonInteractive", "-Command", probe], capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stderr or result.stdout def test_ready_restart_refreshes_key_and_profiles_remain_enabled(): source = WINDOWS.read_text(encoding="utf-8") - ready = source[source.index('if (Test-Path -LiteralPath $readyMarker'):source.index('$capability =')] + ready = source[source.index("if (Test-Path -LiteralPath $readyMarker") : source.index("$capability =")] assert "Install-Gate13SshKey" in ready assert "-NoProfile" not in source assert "-NoProfile" not in (ROOT / "scripts" / "gate13_gcp_provider.py").read_text() diff --git a/tests/test_gate13_gcp_provider.py b/tests/test_gate13_gcp_provider.py index ae948a893..92c3ae2fc 100644 --- a/tests/test_gate13_gcp_provider.py +++ b/tests/test_gate13_gcp_provider.py @@ -293,7 +293,7 @@ def test_client_startup_scripts_are_taken_from_the_pinned_qualified_sources(tmp_ expected = { "windows": ( - 8779, + 10705, "a5622b57b4f9143c871d67eba70de61aea10fa22d33f084349ab6c0997512e93", ), "linux": ( @@ -814,6 +814,7 @@ def test_windows_adapter_survives_real_cmd_boundary(): def test_exact_key_matches_real_private_key_and_rejects_stale_public(tmp_path): import shutil + keygen = shutil.which("ssh-keygen") if keygen is None: pytest.skip("requires ssh-keygen") @@ -851,24 +852,31 @@ def test_ssh_and_scp_use_the_same_explicit_private_key(tmp_path): def test_key_config_reaches_launcher_provider_and_rejects_relative_path(tmp_path): - value = json.loads((ROOT / 'config' / 'gate13_gcp.json').read_text()) - value['ssh_private_key'] = str((tmp_path / 'selected.key').resolve()) - value['ssh_public_fingerprint'] = 'SHA256:' + 'A' * 43 - path = tmp_path / 'config.json' + value = json.loads((ROOT / "config" / "gate13_gcp.json").read_text()) + value["ssh_private_key"] = str((tmp_path / "selected.key").resolve()) + value["ssh_public_fingerprint"] = "SHA256:" + "A" * 43 + path = tmp_path / "config.json" path.write_text(json.dumps(value)) config = GcpConfig.load(path) - item = GcpProvider(run_id=RUN_ID, repository_root=ROOT, output_root=tmp_path, config=config, runner=CreateRunner(), signed_url=lambda _: '') - assert item.ssh_private_key == Path(value['ssh_private_key']) - assert item.ssh_public_fingerprint == value['ssh_public_fingerprint'] - value['ssh_private_key'] = 'relative.key' + item = GcpProvider( + run_id=RUN_ID, + repository_root=ROOT, + output_root=tmp_path, + config=config, + runner=CreateRunner(), + signed_url=lambda _: "", + ) + assert item.ssh_private_key == Path(value["ssh_private_key"]) + assert item.ssh_public_fingerprint == value["ssh_public_fingerprint"] + value["ssh_private_key"] = "relative.key" path.write_text(json.dumps(value)) - with pytest.raises(gcp.Gate13CloudError, match='absolute'): + with pytest.raises(gcp.Gate13CloudError, match="absolute"): GcpConfig.load(path) def test_config_pins_the_fixed_windows_startup_blob(tmp_path): - item = provider(tmp_path, LoggedRunner(tmp_path / 'journal.jsonl', progress=lambda _: None)) - actual = item._client_startup_script('windows').read_text(encoding='utf-8') - assert actual == (ROOT / 'scripts' / 'gate13_windows_client_startup.ps1').read_text(encoding='utf-8') - assert '-NoProfile' not in actual - assert 'Install-Gate13SshKey' in actual + item = provider(tmp_path, LoggedRunner(tmp_path / "journal.jsonl", progress=lambda _: None)) + actual = item._client_startup_script("windows").read_text(encoding="utf-8") + assert actual == (ROOT / "scripts" / "gate13_windows_client_startup.ps1").read_text(encoding="utf-8") + assert "-NoProfile" not in actual + assert "Install-Gate13SshKey" in actual From 67fffa61a372a17ce7c2d8a76ac0792095e35aa3 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:21:18 -0500 Subject: [PATCH 4/5] Refuse replacement identities for configured missing SSH keys --- scripts/gate13_gcp_provider.py | 3 +++ tests/test_gate13_gcp_provider.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/scripts/gate13_gcp_provider.py b/scripts/gate13_gcp_provider.py index 77924620c..e9dd49641 100644 --- a/scripts/gate13_gcp_provider.py +++ b/scripts/gate13_gcp_provider.py @@ -565,6 +565,7 @@ def __init__( ) -> None: if not _RUN_RE.fullmatch(run_id): raise Gate13CloudError("GCP run ID is invalid") + self.ssh_key_explicit = ssh_private_key is not None or config.ssh_private_key is not None self.ssh_private_key = ( ssh_private_key or (Path(config.ssh_private_key) if config.ssh_private_key else None) @@ -761,6 +762,8 @@ def _wait_ssh( def _ensure_ssh_key(self) -> Path: private = self.ssh_private_key public = private.with_suffix(private.suffix + ".pub") + if not private.is_file() and self.ssh_key_explicit: + raise Gate13CloudError("configured SSH private key is unavailable") private.parent.mkdir(mode=0o700, exist_ok=True) if not private.is_file(): if public.exists() or self.ssh_public_fingerprint is not None: diff --git a/tests/test_gate13_gcp_provider.py b/tests/test_gate13_gcp_provider.py index 92c3ae2fc..f98753e66 100644 --- a/tests/test_gate13_gcp_provider.py +++ b/tests/test_gate13_gcp_provider.py @@ -880,3 +880,21 @@ def test_config_pins_the_fixed_windows_startup_blob(tmp_path): assert actual == (ROOT / "scripts" / "gate13_windows_client_startup.ps1").read_text(encoding="utf-8") assert "-NoProfile" not in actual assert "Install-Gate13SshKey" in actual + + +def test_explicit_missing_key_never_generates_replacement(tmp_path): + fake = CreateRunner() + missing = tmp_path / "missing-directory" / "selected.key" + item = GcpProvider( + run_id=RUN_ID, + repository_root=ROOT, + output_root=tmp_path, + config=GcpConfig.load(ROOT / "config" / "gate13_gcp.json"), + runner=fake, + signed_url=lambda _: "", + ssh_private_key=missing, + ) + with pytest.raises(gcp.Gate13CloudError, match="unavailable"): + item._ensure_ssh_key() + assert fake.calls == [] + assert not missing.parent.exists() From d609a9850c54704b9982f4f104672ff6fa5be75d Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:21:29 -0500 Subject: [PATCH 5/5] Bind Windows bootstrap to final qualified Gate13 source --- config/gate13_gcp.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/gate13_gcp.json b/config/gate13_gcp.json index 412fac8c6..8881281f2 100644 --- a/config/gate13_gcp.json +++ b/config/gate13_gcp.json @@ -23,6 +23,6 @@ "windows_image": "windows-server-2025-dc-v20260814", "windows_image_project": "windows-cloud", "windows_machine_type": "e2-standard-8", - "windows_startup_commit": "8748732d5881656e6efb7f658b5689ccbe6d0903", + "windows_startup_commit": "67fffa61a372a17ce7c2d8a76ac0792095e35aa3", "zone": "us-central1-b" }