release: v0.51.506 — harden destructive workspace Git paths vs repo-local execution (#3879, #3777)

6-round RCE-class security hardening, converged. Codex SAFE (live exploit probes) + Opus SAFE (flag-off-reachability enumeration) + full suite 9508 on the byte-identical gated head. Nathan signed off.

Co-authored-by: Rod Boev <rod.boev@gmail.com>

Closes #3879
Closes #3777
This commit is contained in:
nesquena-hermes committed 2026-06-19 02:14:13 +00:00
1 parent 9e79fc3697
commit fa92baa63d
4 files changed
+1816 -32

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased] ## [Unreleased]
## [v0.51.506] — 2026-06-18 — Release RQ (harden destructive workspace Git paths against repo-local execution)
### Security
- **Opening or operating on an untrusted Git repository in the workspace can no longer execute arbitrary commands the repo planted in its own config or hooks (#3777).** Workspace Git operations now run with a hardened configuration that neutralizes repo-local execution vectors — `core.fsmonitor`, `core.sshCommand`, `core.gitProxy`, `credential.helper`, `core.askPass`, `protocol.ext.allow`, GPG/signing programs, `core.alternateRefsCommand`, submodule recursion, and `filter`/`merge`/remote-helper programs — and redirects Git hooks to an empty directory on every ref-mutating and destructive operation (including Fetch, which fires the `reference-transaction` hook). Repo-derived config overrides are delivered exclusively via the `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` environment form so an attacker-controlled filter/section name containing `=` cannot inject configuration. When a repository defines local content filters, the content-rewriting actions (stage, discard, pull, checkout/switch, stash restore, selected-commit staging) fail closed with a clear message rather than silently writing unfiltered bytes. Thanks @rodboev.
## [v0.51.505] — 2026-06-18 — Release RP (allow macOS Mail `message:` links in chat markdown) ## [v0.51.505] — 2026-06-18 — Release RP (allow macOS Mail `message:` links in chat markdown)
### Fixed ### Fixed
+480 -32
View File
@@ -8,15 +8,20 @@ pathspecs, and keeps all Git subprocess calls shell-free and bounded.
from __future__ import annotations from __future__ import annotations
import difflib import difflib
import logging
import os import os
import shutil
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
import re
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
logger = logging.getLogger(__name__)
from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored
@@ -55,13 +60,41 @@ _GIT_HARDENED_CONFIG = (
# Neutralize repo-local core.gitProxy, which specifies an external proxy # Neutralize repo-local core.gitProxy, which specifies an external proxy
# command reachable on `git fetch` against a git:// remote. # command reachable on `git fetch` against a git:// remote.
("core.gitProxy", ""), ("core.gitProxy", ""),
# Prevent submodule operations from recursing into nested repos, which
# could trigger hooks or fetch from attacker-controlled submodule URLs.
("submodule.recurse", "false"),
("fetch.recurseSubmodules", "false"),
)
_GIT_DESTRUCTIVE_HARDENED_CONFIG = (
# Disable signing helper command resolution while performing destructive
# Git operations. Hooks are redirected to a temporary empty directory in
# _run_git() so Git never falls back to .git/hooks.
("commit.gpgSign", "false"),
("push.gpgSign", "false"),
("gpg.program", ""),
("gpg.ssh.program", ""),
("gpg.x509.program", ""),
("core.alternateRefsCommand", ""),
) )
def _hardened_git_argv(args: list[str]) -> list[str]: def _hardened_git_argv(
args: list[str],
*,
destructive: bool = False,
attributes_file: str | None = None,
hooks_path: str | None = None,
) -> list[str]:
argv = ["git"] argv = ["git"]
for key, value in _GIT_HARDENED_CONFIG: for key, value in _GIT_HARDENED_CONFIG:
argv.extend(["-c", f"{key}={value}"]) argv.extend(["-c", f"{key}={value}"])
if destructive:
for key, value in _GIT_DESTRUCTIVE_HARDENED_CONFIG:
argv.extend(["-c", f"{key}={value}"])
if hooks_path:
argv.extend(["-c", f"core.hooksPath={hooks_path}"])
if attributes_file:
argv.extend(["-c", f"core.attributesFile={attributes_file}"])
argv.extend(args) argv.extend(args)
return argv return argv
@@ -164,12 +197,51 @@ def _run_git(
timeout: int = GIT_TIMEOUT, timeout: int = GIT_TIMEOUT,
check: bool = False, check: bool = False,
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
destructive: bool = False,
force_destructive_hardening: bool = False,
disable_filter_attributes: bool = False,
neutralize_filter_programs: bool = False,
neutralize_remote_helpers: bool = False,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
cwd = ctx_or_cwd.repo_root if isinstance(ctx_or_cwd, GitContext) else ctx_or_cwd cwd = ctx_or_cwd.repo_root if isinstance(ctx_or_cwd, GitContext) else ctx_or_cwd
run_env = _clean_git_env(env) run_env = _clean_git_env(env)
effective_destructive = destructive and workspace_git_destructive_enabled()
hardened_destructive_path = effective_destructive or force_destructive_hardening
attributes_file = None
hooks_path = None
extra_configs: list[tuple[str, str]] = []
temporary_attributes: list[str] = []
temporary_dirs: list[str] = []
try: try:
if disable_filter_attributes:
fd, attributes_path = tempfile.mkstemp(prefix="hermes-webui-git-attrs-")
os.close(fd)
attributes_file = attributes_path
temporary_attributes = [attributes_path]
if disable_filter_attributes or neutralize_filter_programs:
# Read/status/fetch paths treat repo-local filter programs as
# untrusted code. Prefer raw-byte visibility over executing them.
extra_configs.extend(_destructive_filter_overrides(cwd, run_env))
if effective_destructive:
extra_configs.extend(_destructive_merge_driver_overrides(cwd, run_env))
if effective_destructive or neutralize_remote_helpers:
extra_configs.extend(_destructive_remote_helper_overrides(cwd, run_env))
args = _destructive_remote_command_args(args, cwd, run_env)
if hardened_destructive_path:
hooks_path = tempfile.mkdtemp(prefix="hermes-webui-git-hooks-")
temporary_dirs = [hooks_path]
if extra_configs:
run_env["GIT_CONFIG_COUNT"] = str(len(extra_configs))
for i, (key, value) in enumerate(extra_configs):
run_env[f"GIT_CONFIG_KEY_{i}"] = key
run_env[f"GIT_CONFIG_VALUE_{i}"] = value
result = subprocess.run( result = subprocess.run(
_hardened_git_argv(args), _hardened_git_argv(
args,
destructive=hardened_destructive_path,
attributes_file=attributes_file,
hooks_path=hooks_path,
),
cwd=str(cwd), cwd=str(cwd),
shell=False, shell=False,
capture_output=True, capture_output=True,
@@ -183,12 +255,202 @@ def _run_git(
raise GitWorkspaceError("Git is not installed or not available on PATH", "missing_git") from exc raise GitWorkspaceError("Git is not installed or not available on PATH", "missing_git") from exc
except OSError as exc: except OSError as exc:
raise GitWorkspaceError(str(exc), _classify_git_error(str(exc), args)) from exc raise GitWorkspaceError(str(exc), _classify_git_error(str(exc), args)) from exc
finally:
for path in temporary_attributes:
Path(path).unlink(missing_ok=True)
for path in temporary_dirs:
shutil.rmtree(path, ignore_errors=True)
if check and result.returncode != 0: if check and result.returncode != 0:
message = (result.stderr or result.stdout or "Git command failed").strip() message = (result.stderr or result.stdout or "Git command failed").strip()
raise GitWorkspaceError(message, _classify_git_error(message, args)) raise GitWorkspaceError(message, _classify_git_error(message, args))
return result return result
_FILTER_CONFIG_RE = re.compile(r"^filter\.(.+)\.(clean|smudge|process|required)$")
_MERGE_DRIVER_CONFIG_RE = re.compile(r"^merge\.(.+)\.driver$")
_REMOTE_HELPER_CONFIG_RE = re.compile(r"^remote\.(.+)\.(uploadpack|receivepack)$")
def _config_names_for_scope(
scope: str,
cwd: Path,
env: dict[str, str],
config_pattern: str,
name_re: re.Pattern[str],
*,
ignore_unsupported: bool = False,
) -> set[str]:
result = subprocess.run(
["git", "config", "--includes", scope, "--name-only", "--get-regexp", config_pattern],
cwd=str(cwd),
shell=False,
text=True,
capture_output=True,
timeout=GIT_TIMEOUT,
env=env,
)
if result.returncode not in {0, 1}:
if ignore_unsupported:
return set()
message = (result.stderr or result.stdout or "Git command failed").strip()
raise GitWorkspaceError(message, _classify_git_error(message, ["config"]))
names: set[str] = set()
for line in (result.stdout or "").splitlines():
match = name_re.match(line.strip())
if match:
names.add(match.group(1))
return names
def _filter_names_for_scope(
scope: str,
cwd: Path,
env: dict[str, str],
*,
ignore_unsupported: bool = False,
) -> set[str]:
return _config_names_for_scope(
scope,
cwd,
env,
r"^filter\..*\.(clean|smudge|process|required)$",
_FILTER_CONFIG_RE,
ignore_unsupported=ignore_unsupported,
)
def _merge_driver_names_for_scope(
scope: str,
cwd: Path,
env: dict[str, str],
*,
ignore_unsupported: bool = False,
) -> set[str]:
return _config_names_for_scope(
scope,
cwd,
env,
r"^merge\..*\.driver$",
_MERGE_DRIVER_CONFIG_RE,
ignore_unsupported=ignore_unsupported,
)
def _remote_helper_names_for_scope(
scope: str,
cwd: Path,
env: dict[str, str],
*,
ignore_unsupported: bool = False,
) -> set[str]:
return _config_names_for_scope(
scope,
cwd,
env,
r"^remote\..*\.(uploadpack|receivepack)$",
_REMOTE_HELPER_CONFIG_RE,
ignore_unsupported=ignore_unsupported,
)
def _destructive_filter_overrides(cwd: Path, env: dict[str, str]) -> list[tuple[str, str]]:
names = _filter_names_for_scope("--local", cwd, env)
names |= _filter_names_for_scope(
"--worktree",
cwd,
env,
ignore_unsupported=True,
)
overrides: list[tuple[str, str]] = []
for name in sorted(names):
if "\n" in name or "\0" in name:
logger.warning("Skipping filter name with illegal characters: %r", name)
continue
overrides.extend(
[
(f"filter.{name}.clean", "cat"),
(f"filter.{name}.smudge", "cat"),
(f"filter.{name}.process", ""),
(f"filter.{name}.required", "false"),
]
)
return overrides
def _destructive_merge_driver_overrides(cwd: Path, env: dict[str, str]) -> list[tuple[str, str]]:
names = _merge_driver_names_for_scope("--local", cwd, env)
names |= _merge_driver_names_for_scope(
"--worktree",
cwd,
env,
ignore_unsupported=True,
)
# Replace repo-defined merge drivers with Git's trusted three-way merge
# binary so stash restores cannot invoke workspace-controlled helpers.
overrides: list[tuple[str, str]] = []
for name in sorted(names):
if "\n" in name or "\0" in name:
logger.warning("Skipping merge driver name with illegal characters: %r", name)
continue
overrides.append((f"merge.{name}.driver", 'git merge-file "%A" "%O" "%B"'))
return overrides
def _destructive_remote_helper_overrides(cwd: Path, env: dict[str, str]) -> list[tuple[str, str]]:
names = _remote_helper_names_for_scope("--local", cwd, env)
names |= _remote_helper_names_for_scope(
"--worktree",
cwd,
env,
ignore_unsupported=True,
)
overrides: list[tuple[str, str]] = []
for name in sorted(names):
if "\n" in name or "\0" in name:
logger.warning("Skipping remote helper name with illegal characters: %r", name)
continue
overrides.extend(
[
(f"remote.{name}.uploadpack", "git-upload-pack"),
(f"remote.{name}.receivepack", "git-receive-pack"),
]
)
return overrides
def _destructive_remote_command_args(args: list[str], cwd: Path, env: dict[str, str]) -> list[str]:
if not args:
return args
names = _remote_helper_names_for_scope("--local", cwd, env)
names |= _remote_helper_names_for_scope(
"--worktree",
cwd,
env,
ignore_unsupported=True,
)
if not names:
return args
command = args[0]
if command in {"fetch", "pull"}:
return [command, "--upload-pack=git-upload-pack", *args[1:]]
if command == "push":
return [command, "--receive-pack=git-receive-pack", *args[1:]]
return args
def _has_repo_local_filters(cwd: Path, env: dict[str, str]) -> bool:
names = _filter_names_for_scope("--local", cwd, env)
names |= _filter_names_for_scope("--worktree", cwd, env, ignore_unsupported=True)
return bool(names)
def _block_filtered_destructive_write(ctx: GitContext, message: str) -> None:
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
raise GitWorkspaceError(message, "filtered_path")
def resolve_git_context(workspace: str | Path) -> GitContext | None: def resolve_git_context(workspace: str | Path) -> GitContext | None:
ws = Path(workspace).expanduser().resolve() ws = Path(workspace).expanduser().resolve()
result = _run_git(ws, ["rev-parse", "--show-toplevel"], check=False) result = _run_git(ws, ["rev-parse", "--show-toplevel"], check=False)
@@ -288,12 +550,19 @@ def _parse_path_list(text: str, ctx: GitContext) -> set[str]:
def _collect_diff_paths(ctx: GitContext, cached: bool, *, ignore_cr_at_eol: bool = True) -> set[str] | None: def _collect_diff_paths(ctx: GitContext, cached: bool, *, ignore_cr_at_eol: bool = True) -> set[str] | None:
args = ["diff", "--name-only", "-z"] args = ["diff", "--name-only", "-z"]
args.append("--no-textconv")
if ignore_cr_at_eol: if ignore_cr_at_eol:
args.append("--ignore-cr-at-eol") args.append("--ignore-cr-at-eol")
if cached: if cached:
args.append("--cached") args.append("--cached")
args.extend(["--", _workspace_pathspec(ctx)]) args.extend(["--", _workspace_pathspec(ctx)])
result = _run_git(ctx, args, check=False) result = _run_git(
ctx,
args,
check=False,
disable_filter_attributes=workspace_git_destructive_enabled(),
neutralize_filter_programs=True,
)
if result.returncode != 0: if result.returncode != 0:
return None return None
return _parse_path_list(result.stdout, ctx) return _parse_path_list(result.stdout, ctx)
@@ -306,12 +575,19 @@ def _collect_numstat(
ignore_cr_at_eol: bool = True, ignore_cr_at_eol: bool = True,
) -> dict[str, tuple[int, int, bool]]: ) -> dict[str, tuple[int, int, bool]]:
args = ["diff", "--numstat"] args = ["diff", "--numstat"]
args.append("--no-textconv")
if ignore_cr_at_eol: if ignore_cr_at_eol:
args.append("--ignore-cr-at-eol") args.append("--ignore-cr-at-eol")
if cached: if cached:
args.append("--cached") args.append("--cached")
args.extend(["--", _workspace_pathspec(ctx)]) args.extend(["--", _workspace_pathspec(ctx)])
result = _run_git(ctx, args, check=False) result = _run_git(
ctx,
args,
check=False,
disable_filter_attributes=workspace_git_destructive_enabled(),
neutralize_filter_programs=True,
)
if result.returncode != 0: if result.returncode != 0:
return {} return {}
return _parse_numstat(result.stdout, ctx) return _parse_numstat(result.stdout, ctx)
@@ -354,6 +630,8 @@ def git_status(workspace: str | Path) -> dict:
_workspace_pathspec(ctx), _workspace_pathspec(ctx),
], ],
check=True, check=True,
disable_filter_attributes=workspace_git_destructive_enabled(),
neutralize_filter_programs=True,
) )
staged_stats = _collect_numstat(ctx, cached=True) staged_stats = _collect_numstat(ctx, cached=True)
unstaged_stats = _collect_numstat(ctx, cached=False) unstaged_stats = _collect_numstat(ctx, cached=False)
@@ -661,7 +939,8 @@ def _validate_new_branch_name(ctx: GitContext, name: str) -> str:
def _dirty_worktree(ctx: GitContext) -> bool: def _dirty_worktree(ctx: GitContext) -> bool:
result = _run_git(ctx, ["status", "--porcelain=v2", "--untracked-files=all"], check=True) result = _run_git(ctx, ["status", "--porcelain=v2", "--untracked-files=all"],
check=True, neutralize_filter_programs=True)
return bool(result.stdout.strip()) return bool(result.stdout.strip())
@@ -703,12 +982,23 @@ def _hermes_branch_switch_stashes(ctx: GitContext) -> list[dict]:
def _restore_branch_switch_stash_locked(ctx: GitContext, branch: str) -> dict: def _restore_branch_switch_stash_locked(ctx: GitContext, branch: str) -> dict:
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
return {
"restore_blocked": True,
"restore_reason": "Repository defines local filter programs",
}
if _dirty_worktree(ctx): if _dirty_worktree(ctx):
return {} return {}
for item in _hermes_branch_switch_stashes(ctx): for item in _hermes_branch_switch_stashes(ctx):
if item.get("branch") != branch: if item.get("branch") != branch:
continue continue
result = _run_git(ctx, ["stash", "pop", "--index", item["ref"]], check=False) result = _run_git(
ctx,
["stash", "pop", "--index", item["ref"]],
check=False,
destructive=True,
disable_filter_attributes=True,
)
if result.returncode == 0: if result.returncode == 0:
return {"restored_stash": item} return {"restored_stash": item}
return { return {
@@ -753,31 +1043,66 @@ def _perform_checkout_locked(
new_branch: str | None, new_branch: str | None,
track: bool, track: bool,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
raise GitWorkspaceError(
"Cannot checkout: repository defines local filter programs that would alter file content",
"filtered_path",
)
if mode == "local": if mode == "local":
target = _validate_local_branch(ctx, ref) target = _validate_local_branch(ctx, ref)
return _run_git(ctx, ["switch", target], check=True) return _run_git(
ctx,
["switch", "--recurse-submodules=no", target],
check=True,
destructive=True,
disable_filter_attributes=True,
)
if mode in {"new", "create"}: if mode in {"new", "create"}:
branch = _validate_new_branch_name(ctx, new_branch or ref) branch = _validate_new_branch_name(ctx, new_branch or ref)
start_ref = _validate_checkout_start(ctx, ref if (new_branch and ref and ref != new_branch) else "HEAD") start_ref = _validate_checkout_start(ctx, ref if (new_branch and ref and ref != new_branch) else "HEAD")
return _run_git(ctx, ["switch", "-c", branch, start_ref], check=True) return _run_git(
ctx,
["switch", "--recurse-submodules=no", "-c", branch, start_ref],
check=True,
destructive=True,
disable_filter_attributes=True,
)
if mode == "remote": if mode == "remote":
remote_ref = _validate_remote_branch(ctx, ref) remote_ref = _validate_remote_branch(ctx, ref)
branch_name = str(new_branch or remote_ref.split("/", 1)[-1]).strip() branch_name = str(new_branch or remote_ref.split("/", 1)[-1]).strip()
exists = _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{branch_name}"], check=False) exists = _run_git(ctx, ["show-ref", "--verify", f"refs/heads/{branch_name}"], check=False)
if exists.returncode == 0: if exists.returncode == 0:
result = _run_git(ctx, ["switch", branch_name], check=True) result = _run_git(
ctx,
["switch", "--recurse-submodules=no", branch_name],
check=True,
destructive=True,
disable_filter_attributes=True,
)
if track: if track:
_run_git(ctx, ["branch", "--set-upstream-to", remote_ref, branch_name], check=False) _run_git(ctx, ["branch", "--set-upstream-to", remote_ref, branch_name], check=False)
return result return result
branch = _validate_new_branch_name(ctx, branch_name) branch = _validate_new_branch_name(ctx, branch_name)
args = ["switch", "-c", branch] args = ["switch", "--recurse-submodules=no", "-c", branch]
if track: if track:
args.append("--track") args.append("--track")
args.append(remote_ref) args.append(remote_ref)
return _run_git(ctx, args, check=True) return _run_git(
ctx,
args,
check=True,
destructive=True,
disable_filter_attributes=True,
)
if mode in {"detached", "detach"}: if mode in {"detached", "detach"}:
target = _validate_checkout_start(ctx, ref) target = _validate_checkout_start(ctx, ref)
return _run_git(ctx, ["switch", "--detach", target], check=True) return _run_git(
ctx,
["switch", "--recurse-submodules=no", "--detach", target],
check=True,
destructive=True,
disable_filter_attributes=True,
)
raise GitWorkspaceError("Unsupported checkout mode", "invalid_ref") raise GitWorkspaceError("Unsupported checkout mode", "invalid_ref")
@@ -831,16 +1156,33 @@ def git_stash_and_checkout(
restored: dict = {} restored: dict = {}
with _git_mutation_lock(ctx): with _git_mutation_lock(ctx):
_validate_checkout_request_locked(ctx, ref, mode, new_branch) _validate_checkout_request_locked(ctx, ref, mode, new_branch)
if workspace_git_destructive_enabled() and _has_repo_local_filters(ctx.repo_root, _clean_git_env()):
raise GitWorkspaceError(
"Cannot stash: repository defines local filter programs that would alter file content",
"filtered_path",
)
stashed = False stashed = False
if _dirty_worktree(ctx): if _dirty_worktree(ctx):
stash_result = _run_git(ctx, ["stash", "push", "-u", "-m", stash_name], check=True) stash_result = _run_git(
ctx,
["stash", "push", "-u", "-m", stash_name],
check=True,
destructive=True,
disable_filter_attributes=True,
)
stash_text = _remote_message(stash_result) stash_text = _remote_message(stash_result)
stashed = "No local changes to save" not in stash_text stashed = "No local changes to save" not in stash_text
try: try:
result = _perform_checkout_locked(ctx, workspace, ref, mode, new_branch, track) result = _perform_checkout_locked(ctx, workspace, ref, mode, new_branch, track)
except Exception: except Exception:
if stashed: if stashed:
_run_git(ctx, ["stash", "pop", "--index", "stash@{0}"], check=False) _run_git(
ctx,
["stash", "pop", "--index", "stash@{0}"],
check=False,
destructive=True,
disable_filter_attributes=True,
)
raise raise
current_branch = _current_checkout_label(ctx) current_branch = _current_checkout_label(ctx)
restored = _restore_branch_switch_stash_locked(ctx, current_branch) restored = _restore_branch_switch_stash_locked(ctx, current_branch)
@@ -930,11 +1272,11 @@ def git_diff(workspace: str | Path, path: str, kind: str = "unstaged") -> dict:
payload = _synthetic_untracked_diff(ctx.workspace / workspace_rel, workspace_rel) payload = _synthetic_untracked_diff(ctx.workspace / workspace_rel, workspace_rel)
return {"path": workspace_rel, "kind": kind, **payload} return {"path": workspace_rel, "kind": kind, **payload}
args = ["diff", "--no-ext-diff", "--unified=3"] args = ["diff", "--no-ext-diff", "--no-textconv", "--unified=3"]
if kind == "staged": if kind == "staged":
args.append("--cached") args.append("--cached")
args.extend(["--", repo_rel]) args.extend(["--", repo_rel])
result = _run_git(ctx, args, check=True) result = _run_git(ctx, args, check=True, neutralize_filter_programs=True)
diff = result.stdout diff = result.stdout
binary = "Binary files " in diff or "GIT binary patch" in diff binary = "Binary files " in diff or "GIT binary patch" in diff
too_large = len(diff.encode("utf-8", errors="replace")) > DIFF_SIZE_LIMIT too_large = len(diff.encode("utf-8", errors="replace")) > DIFF_SIZE_LIMIT
@@ -972,7 +1314,17 @@ def git_stage(workspace: str | Path, paths: Iterable[str]) -> dict:
if ctx is None: if ctx is None:
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo") raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
with _git_mutation_lock(ctx): with _git_mutation_lock(ctx):
_run_git(ctx, ["add", "--", *_pathspecs(ctx, paths)], check=True) _block_filtered_destructive_write(
ctx,
"Repository uses local Git filters; stage may corrupt index content. Use the terminal to stage manually.",
)
_run_git(
ctx,
["add", "--", *_pathspecs(ctx, paths)],
check=True,
destructive=True,
disable_filter_attributes=True,
)
return git_status(workspace) return git_status(workspace)
@@ -982,9 +1334,14 @@ def git_unstage(workspace: str | Path, paths: Iterable[str]) -> dict:
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo") raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
specs = _pathspecs(ctx, paths) specs = _pathspecs(ctx, paths)
with _git_mutation_lock(ctx): with _git_mutation_lock(ctx):
result = _run_git(ctx, ["restore", "--staged", "--", *specs], check=False) result = _run_git(
ctx,
["restore", "--staged", "--", *specs],
check=False,
destructive=True,
)
if result.returncode != 0: if result.returncode != 0:
_run_git(ctx, ["reset", "HEAD", "--", *specs], check=True) _run_git(ctx, ["reset", "HEAD", "--", *specs], check=True, destructive=True)
return git_status(workspace) return git_status(workspace)
@@ -993,6 +1350,11 @@ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked
if ctx is None: if ctx is None:
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo") raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
with _git_mutation_lock(ctx): with _git_mutation_lock(ctx):
_block_filtered_destructive_write(
ctx,
"Repository uses local Git filters; discard may corrupt working-tree content. "
"Use the terminal to discard manually.",
)
status = git_status(workspace) status = git_status(workspace)
by_path = {f["path"]: f for f in status.get("files", [])} by_path = {f["path"]: f for f in status.get("files", [])}
for path in _clean_paths(paths): for path in _clean_paths(paths):
@@ -1017,7 +1379,13 @@ def git_discard(workspace: str | Path, paths: Iterable[str], *, delete_untracked
# reported it but before this discard reaches unlink. # reported it but before this discard reaches unlink.
pass pass
continue continue
_run_git(ctx, ["restore", "--worktree", "--", repo_rel], check=True) _run_git(
ctx,
["restore", "--worktree", "--", repo_rel],
check=True,
destructive=True,
disable_filter_attributes=True,
)
return git_status(workspace) return git_status(workspace)
@@ -1042,11 +1410,13 @@ def _staged_diff_text(ctx: GitContext) -> tuple[str, bool]:
"diff", "diff",
"--cached", "--cached",
"--no-ext-diff", "--no-ext-diff",
"--no-textconv",
"--unified=3", "--unified=3",
"--", "--",
_workspace_pathspec(ctx), _workspace_pathspec(ctx),
], ],
check=True, check=True,
neutralize_filter_programs=True,
) )
diff = result.stdout or "" diff = result.stdout or ""
encoded = diff.encode("utf-8", errors="replace") encoded = diff.encode("utf-8", errors="replace")
@@ -1056,17 +1426,35 @@ def _staged_diff_text(ctx: GitContext) -> tuple[str, bool]:
def _selected_temp_index_env(ctx: GitContext, specs: list[str]) -> tuple[dict[str, str], str]: def _selected_temp_index_env(ctx: GitContext, specs: list[str]) -> tuple[dict[str, str], str]:
_block_filtered_destructive_write(
ctx,
"Repository uses local Git filters; selected commit staging may corrupt index content. "
"Use the terminal to commit manually.",
)
fd, index_path = tempfile.mkstemp(prefix="hermes-webui-git-index-") fd, index_path = tempfile.mkstemp(prefix="hermes-webui-git-index-")
os.close(fd) os.close(fd)
Path(index_path).unlink(missing_ok=True) Path(index_path).unlink(missing_ok=True)
env = {"GIT_INDEX_FILE": index_path} env = {"GIT_INDEX_FILE": index_path}
try: try:
head = _run_git(ctx, ["rev-parse", "--verify", "HEAD"], check=False, env=env) head = _run_git(
ctx,
["rev-parse", "--verify", "HEAD"],
check=False,
env=env,
destructive=True,
)
if head.returncode == 0: if head.returncode == 0:
_run_git(ctx, ["read-tree", "HEAD"], check=True, env=env) _run_git(ctx, ["read-tree", "HEAD"], check=True, env=env, destructive=True)
else: else:
_run_git(ctx, ["read-tree", "--empty"], check=True, env=env) _run_git(ctx, ["read-tree", "--empty"], check=True, env=env, destructive=True)
_run_git(ctx, ["add", "-A", "--", *specs], check=True, env=env) _run_git(
ctx,
["add", "-A", "--", *specs],
check=True,
env=env,
destructive=True,
disable_filter_attributes=True,
)
return env, index_path return env, index_path
except Exception: except Exception:
Path(index_path).unlink(missing_ok=True) Path(index_path).unlink(missing_ok=True)
@@ -1102,9 +1490,11 @@ def _selected_diff_text(ctx: GitContext, specs: list[str]) -> tuple[str, bool]:
try: try:
result = _run_git( result = _run_git(
ctx, ctx,
["diff", "--cached", "--no-ext-diff", "--unified=3", "--", *specs], ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--unified=3", "--", *specs],
check=True, check=True,
env=env, env=env,
destructive=True,
disable_filter_attributes=True,
) )
diff = result.stdout or "" diff = result.stdout or ""
encoded = diff.encode("utf-8", errors="replace") encoded = diff.encode("utf-8", errors="replace")
@@ -1223,7 +1613,14 @@ def git_commit(workspace: str | Path, message: str) -> dict:
if ctx is None: if ctx is None:
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo") raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
with _git_mutation_lock(ctx): with _git_mutation_lock(ctx):
_run_git(ctx, ["commit", "-m", msg], timeout=10, check=True) _run_git(
ctx,
["commit", "-m", msg],
timeout=10,
check=True,
destructive=True,
disable_filter_attributes=True,
)
sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip() sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
return {"ok": True, "commit": sha, "status": git_status(workspace)} return {"ok": True, "commit": sha, "status": git_status(workspace)}
@@ -1239,11 +1636,31 @@ def git_commit_selected(workspace: str | Path, message: str, paths: Iterable[str
specs, workspace_paths, _selected_files_list = _selected_files(ctx, paths) specs, workspace_paths, _selected_files_list = _selected_files(ctx, paths)
env, index_path = _selected_temp_index_env(ctx, specs) env, index_path = _selected_temp_index_env(ctx, specs)
try: try:
quiet = _run_git(ctx, ["diff", "--cached", "--quiet", "--", *specs], check=False, env=env) quiet = _run_git(
ctx,
["diff", "--cached", "--quiet", "--no-textconv", "--", *specs],
check=False,
env=env,
destructive=True,
disable_filter_attributes=True,
)
if quiet.returncode == 0: if quiet.returncode == 0:
raise GitWorkspaceError("Selected paths have no committable changes") raise GitWorkspaceError("Selected paths have no committable changes")
_run_git(ctx, ["commit", "-m", msg], timeout=10, check=True, env=env) _run_git(
_run_git(ctx, ["reset", "-q", "HEAD", "--", *specs], check=True) ctx,
["commit", "-m", msg],
timeout=10,
check=True,
env=env,
destructive=True,
disable_filter_attributes=True,
)
_run_git(
ctx,
["reset", "-q", "HEAD", "--", *specs],
check=True,
destructive=True,
)
finally: finally:
Path(index_path).unlink(missing_ok=True) Path(index_path).unlink(missing_ok=True)
sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip() sha = _run_git(ctx, ["rev-parse", "--short", "HEAD"], check=True).stdout.strip()
@@ -1266,7 +1683,16 @@ def git_fetch(workspace: str | Path) -> dict:
if ctx is None: if ctx is None:
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo") raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
with _git_mutation_lock(ctx): with _git_mutation_lock(ctx):
result = _run_git(ctx, ["fetch", "--prune"], timeout=GIT_REMOTE_TIMEOUT, check=True) result = _run_git(
ctx,
["fetch", "--prune", "--no-recurse-submodules"],
timeout=GIT_REMOTE_TIMEOUT,
check=True,
force_destructive_hardening=True,
disable_filter_attributes=workspace_git_destructive_enabled(),
neutralize_filter_programs=True,
neutralize_remote_helpers=True,
)
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)} return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
@@ -1275,7 +1701,20 @@ def git_pull(workspace: str | Path) -> dict:
if ctx is None: if ctx is None:
raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo") raise GitWorkspaceError("Workspace is not a Git repository", "not_a_repo")
with _git_mutation_lock(ctx): with _git_mutation_lock(ctx):
result = _run_git(ctx, ["pull", "--ff-only"], timeout=GIT_REMOTE_TIMEOUT, check=True) _block_filtered_destructive_write(
ctx,
"Repository uses local Git filters; pull may corrupt working-tree content. Use the terminal to pull manually.",
)
result = _run_git(
ctx,
["pull", "--ff-only", "--no-recurse-submodules"],
timeout=GIT_REMOTE_TIMEOUT,
check=True,
destructive=True,
disable_filter_attributes=True,
neutralize_filter_programs=True,
neutralize_remote_helpers=True,
)
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)} return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
@@ -1292,5 +1731,14 @@ def git_push(workspace: str | Path) -> dict:
if "origin" not in remotes: if "origin" not in remotes:
raise GitWorkspaceError("No upstream branch or origin remote is configured", "no_upstream") raise GitWorkspaceError("No upstream branch or origin remote is configured", "no_upstream")
args.extend(["-u", "origin", branch]) args.extend(["-u", "origin", branch])
result = _run_git(ctx, args, timeout=GIT_REMOTE_TIMEOUT, check=True) result = _run_git(
ctx,
args,
timeout=GIT_REMOTE_TIMEOUT,
check=True,
destructive=True,
disable_filter_attributes=True,
neutralize_filter_programs=True,
neutralize_remote_helpers=True,
)
return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)} return {"ok": True, "message": _remote_message(result), "status": git_status(workspace)}
@@ -12,10 +12,16 @@ class _ConfigState:
self.old_cache = config._available_models_cache self.old_cache = config._available_models_cache
self.old_cache_ts = config._available_models_cache_ts self.old_cache_ts = config._available_models_cache_ts
self.old_cache_fp = config._available_models_cache_source_fingerprint self.old_cache_fp = config._available_models_cache_source_fingerprint
self.old_live_budget = config._LIVE_REBUILD_BUDGET_SECONDS
config._cfg_mtime = 0.0 config._cfg_mtime = 0.0
config._available_models_cache = None config._available_models_cache = None
config._available_models_cache_ts = 0.0 config._available_models_cache_ts = 0.0
config._available_models_cache_source_fingerprint = None config._available_models_cache_source_fingerprint = None
# This file pins custom-endpoint error shaping, not the separate async
# provider-catalog budget fallback. Force the synchronous rebuild path
# so slower Python 3.11 shards cannot fall onto the global 4s fallback
# and mask the endpoint-specific error contract under test.
config._LIVE_REBUILD_BUDGET_SECONDS = 0.0
return self return self
def __exit__(self, exc_type, exc, tb): def __exit__(self, exc_type, exc, tb):
@@ -24,6 +30,7 @@ class _ConfigState:
config._available_models_cache = self.old_cache config._available_models_cache = self.old_cache
config._available_models_cache_ts = self.old_cache_ts config._available_models_cache_ts = self.old_cache_ts
config._available_models_cache_source_fingerprint = self.old_cache_fp config._available_models_cache_source_fingerprint = self.old_cache_fp
config._LIVE_REBUILD_BUDGET_SECONDS = self.old_live_budget
return False return False
File diff suppressed because it is too large. Load diff