2026-05-13
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
codex_guarded.sh [--cwd DIR] [--sandbox MODE] [--approval POLICY] [--] [codex args...]
|
||||
|
||||
Defaults:
|
||||
--approval never
|
||||
--sandbox read-only
|
||||
--cwd current working directory
|
||||
|
||||
Description:
|
||||
Launch Codex with deterministic non-interactive safety defaults.
|
||||
EOF
|
||||
}
|
||||
|
||||
CWD="$(pwd)"
|
||||
SANDBOX="read-only"
|
||||
APPROVAL="never"
|
||||
FORWARD_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--cwd)
|
||||
CWD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sandbox)
|
||||
SANDBOX="$2"
|
||||
shift 2
|
||||
;;
|
||||
--approval)
|
||||
APPROVAL="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
FORWARD_ARGS+=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
FORWARD_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#FORWARD_ARGS[@]} -gt 0 ]]; then
|
||||
exec codex -a "$APPROVAL" -s "$SANDBOX" -C "$CWD" "${FORWARD_ARGS[@]}"
|
||||
else
|
||||
exec codex -a "$APPROVAL" -s "$SANDBOX" -C "$CWD"
|
||||
fi
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
guard_apply_patch.sh PATCH_FILE [--cwd DIR] [--allow-deletes] [--max-files N] [--max-changed-lines N] [--allow-path-prefix PREFIX ...]
|
||||
|
||||
Description:
|
||||
Validates a patch and applies it only if policy checks pass.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || "${#}" -lt 1 ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PATCH_FILE="$1"
|
||||
shift
|
||||
|
||||
CWD="$(pwd)"
|
||||
VALIDATOR_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--cwd)
|
||||
CWD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--allow-deletes)
|
||||
VALIDATOR_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
--max-files|--max-changed-lines|--allow-path-prefix)
|
||||
VALIDATOR_ARGS+=("$1" "$2")
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
VALIDATOR_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$PATCH_FILE" ]]; then
|
||||
echo "Patch file not found: $PATCH_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VALIDATOR="$SCRIPT_DIR/guard_validate_patch.py"
|
||||
|
||||
python3 "$VALIDATOR" "$PATCH_FILE" --cwd "$CWD" "${VALIDATOR_ARGS[@]}"
|
||||
|
||||
echo "Validation passed. Running dry-run apply..."
|
||||
patch -p1 --dry-run -d "$CWD" < "$PATCH_FILE"
|
||||
|
||||
echo "Applying patch..."
|
||||
patch -p1 -d "$CWD" < "$PATCH_FILE"
|
||||
|
||||
echo "Done."
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generic patch validator scoped to a target working directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
DEFAULT_FORBIDDEN_BASENAMES = {
|
||||
"Package.resolved",
|
||||
"Podfile.lock",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"Cargo.lock",
|
||||
"Gemfile.lock",
|
||||
}
|
||||
|
||||
DEFAULT_FORBIDDEN_SUFFIXES = {
|
||||
".xcconfig",
|
||||
}
|
||||
|
||||
DEFAULT_FORBIDDEN_PATH_PATTERNS = (
|
||||
re.compile(r"(^|/)\.git(/|$)"),
|
||||
re.compile(r"(^|/)Info\.plist$"),
|
||||
re.compile(r"(^|/)project\.pbxproj$"),
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Validate unified diff patch by policy.")
|
||||
parser.add_argument("patch_file", type=pathlib.Path)
|
||||
parser.add_argument("--cwd", type=pathlib.Path, default=pathlib.Path.cwd())
|
||||
parser.add_argument("--max-files", type=int, default=12)
|
||||
parser.add_argument("--max-changed-lines", type=int, default=2000)
|
||||
parser.add_argument("--allow-deletes", action="store_true")
|
||||
parser.add_argument(
|
||||
"--allow-path-prefix",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Allowed path prefix relative to --cwd. Repeatable. Defaults to all paths under --cwd.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"FAIL: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def normalize_patch_path(raw: str) -> str | None:
|
||||
raw = raw.strip()
|
||||
if raw == "/dev/null":
|
||||
return None
|
||||
if raw.startswith("a/") or raw.startswith("b/"):
|
||||
raw = raw[2:]
|
||||
return raw
|
||||
|
||||
|
||||
def is_safe_relative_path(path: str) -> bool:
|
||||
p = pathlib.PurePosixPath(path)
|
||||
if p.is_absolute():
|
||||
return False
|
||||
if ".." in p.parts:
|
||||
return False
|
||||
if path.startswith("./"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if not args.patch_file.exists():
|
||||
fail(f"patch file not found: {args.patch_file}")
|
||||
|
||||
cwd = args.cwd.resolve()
|
||||
if not cwd.exists() or not cwd.is_dir():
|
||||
fail(f"--cwd is not a directory: {cwd}")
|
||||
|
||||
text = args.patch_file.read_text(encoding="utf-8")
|
||||
lines = text.splitlines()
|
||||
|
||||
added_lines = 0
|
||||
removed_lines = 0
|
||||
touched_files: list[str] = []
|
||||
deleted_files: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("+++ "):
|
||||
path = normalize_patch_path(line[4:])
|
||||
if path:
|
||||
touched_files.append(path)
|
||||
continue
|
||||
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
added_lines += 1
|
||||
continue
|
||||
|
||||
if line.startswith("-") and not line.startswith("---"):
|
||||
removed_lines += 1
|
||||
|
||||
for match in re.finditer(r"^--- (.+)\n\+\+\+ (.+)$", text, flags=re.MULTILINE):
|
||||
before = normalize_patch_path(match.group(1))
|
||||
after = normalize_patch_path(match.group(2))
|
||||
if before and after is None:
|
||||
deleted_files.append(before)
|
||||
|
||||
unique_files = sorted(set(touched_files + deleted_files))
|
||||
if not unique_files:
|
||||
fail("no file changes detected")
|
||||
|
||||
if len(unique_files) > args.max_files:
|
||||
fail(f"too many files changed: {len(unique_files)} > {args.max_files}")
|
||||
|
||||
changed_line_count = added_lines + removed_lines
|
||||
if changed_line_count > args.max_changed_lines:
|
||||
fail(
|
||||
f"too many changed lines: {changed_line_count} > {args.max_changed_lines}"
|
||||
)
|
||||
|
||||
allowed_prefixes = [p.strip("/") for p in args.allow_path_prefix if p.strip("/")]
|
||||
|
||||
for rel_path in unique_files:
|
||||
if not is_safe_relative_path(rel_path):
|
||||
fail(f"unsafe path in patch: {rel_path}")
|
||||
|
||||
if allowed_prefixes and not any(
|
||||
rel_path == prefix or rel_path.startswith(prefix + "/")
|
||||
for prefix in allowed_prefixes
|
||||
):
|
||||
fail(f"path not in allowed prefixes: {rel_path}")
|
||||
|
||||
basename = pathlib.PurePosixPath(rel_path).name
|
||||
if basename in DEFAULT_FORBIDDEN_BASENAMES:
|
||||
fail(f"forbidden file basename: {rel_path}")
|
||||
|
||||
if any(rel_path.endswith(s) for s in DEFAULT_FORBIDDEN_SUFFIXES):
|
||||
fail(f"forbidden file suffix: {rel_path}")
|
||||
|
||||
if any(pattern.search(rel_path) for pattern in DEFAULT_FORBIDDEN_PATH_PATTERNS):
|
||||
fail(f"forbidden file path: {rel_path}")
|
||||
|
||||
target_path = cwd / rel_path
|
||||
try:
|
||||
target_path.resolve().relative_to(cwd)
|
||||
except ValueError:
|
||||
fail(f"path escapes cwd scope: {rel_path}")
|
||||
|
||||
if deleted_files and not args.allow_deletes:
|
||||
fail(
|
||||
"deletions are blocked (use --allow-deletes): "
|
||||
+ ", ".join(sorted(set(deleted_files)))
|
||||
)
|
||||
|
||||
print("OK: patch passed validation")
|
||||
print(f"cwd={cwd}")
|
||||
print(f"files={len(unique_files)} changed_lines={changed_line_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user