fix(orchestrator): P4a baseline-diff guard + Stage 5 commit scope
P4 had two production issues blocking #50 integration audit deployment: 1. Stage 3 guard had no baseline awareness — flagged ALL forbidden-path changes including pre-existing dirty WIP. Empirical: 328 such files already in current working tree (tests/matching/ artifacts etc). #50 would have hit reject loops immediately without Claude doing anything wrong. 2. Stage 5 had no commit-scope guard — if Claude ran `git add -A` and committed user's existing WIP, audit commit would be polluted with unrelated production changes. P4a additions: - _audit_baseline_path / _ensure_audit_baseline / _load_audit_baseline: snapshot working-tree dirty paths at run_issue entry for audit issues. Resumed runs preserve existing baseline (no overwrite). - _check_audit_only_violations(baseline=None): accept baseline set, subtract from violations — only flags NEW forbidden changes introduced after audit start. - _check_audit_commit_scope: verify HEAD commit's file list matches AUDIT_ALLOWED_COMMIT_GLOBS (INTEGRATION-AUDIT-*.md, BACKLOG.md). - run_issue: save baseline on audit-mode entry only — no impact on normal issues. - Stage 5 (commit-push) YES gate: new guard rejects on out-of-scope files with remediation prompt (git reset --soft + force-with-lease). 19 new tests: - baseline subtraction (5): pre-existing removed, None=keep-all, empty-set=catch-all, full-coverage filter, Windows path normalize. - baseline persist (5): roundtrip, no-overwrite on resume, missing fallback, corrupt JSON fallback, non-list fallback. - commit scope detection (7): report-only allowed, backlog allowed, src/ rejected, unrelated docs rejected, git error fail-open, Windows backslash, empty commit pass. - allowed globs sanity (2): every glob has audit marker, all under docs/architecture/. Total: 94/94 pytest pass (75 prior + 19 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
orchestrator.py
131
orchestrator.py
@@ -756,9 +756,20 @@ def _audit_mode(title):
|
||||
# 블랙리스트 — 화이트리스트보다 false positive 적음 (data/runs, .orchestrator artifacts 등 자연 통과).
|
||||
AUDIT_ONLY_FORBIDDEN_PREFIXES = ("src/", "templates/", "tests/")
|
||||
|
||||
def _check_audit_only_violations():
|
||||
"""git status --porcelain 검사. AUDIT_ONLY_FORBIDDEN_PREFIXES 매치 변경 list 반환.
|
||||
Returns: list of violating paths (빈 list = 통과)."""
|
||||
# P4a (2026-05-19) — Stage 5 commit scope guard. HEAD commit 의 file list 가 이 glob 안에만 있어야.
|
||||
AUDIT_ALLOWED_COMMIT_GLOBS = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-*.md",
|
||||
"docs/architecture/INTEGRATION-AUDIT-*/*", # subdirectory 변형 대응
|
||||
"docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md",
|
||||
)
|
||||
|
||||
def _audit_baseline_path(n):
|
||||
"""Per-issue baseline 파일 경로."""
|
||||
return ORCH_DIR / f"audit_baseline_{n}.json"
|
||||
|
||||
def _git_porcelain_paths():
|
||||
"""git status --porcelain 출력 파싱 — 변경 path set 반환. forward-slash 정규화.
|
||||
Empty 또는 git 에러 시 빈 set (fail open)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
@@ -766,29 +777,84 @@ def _check_audit_only_violations():
|
||||
cwd=PROJECT_DIR, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
# git error — fail open (가드 자체 실패는 false 알람 만들지 않음).
|
||||
return []
|
||||
return set()
|
||||
except Exception:
|
||||
return []
|
||||
bad = []
|
||||
return set()
|
||||
paths = set()
|
||||
for line in r.stdout.splitlines():
|
||||
if len(line) < 4: continue
|
||||
# porcelain format: "XY path" — XY 는 staged/unstaged 2-char.
|
||||
path = line[3:].strip()
|
||||
# rename: "XY old -> new" — destination 만 검사.
|
||||
if " -> " in path:
|
||||
path = path.split(" -> ")[-1].strip()
|
||||
# quoted path (special chars) — strip wrapping quotes.
|
||||
if path.startswith('"') and path.endswith('"'):
|
||||
path = path[1:-1]
|
||||
# forward-slash 통일 (Windows backslash 도 처리).
|
||||
norm = path.replace("\\", "/")
|
||||
paths.add(path.replace("\\", "/"))
|
||||
return paths
|
||||
|
||||
def _ensure_audit_baseline(n):
|
||||
"""Audit issue 시작 시점 working tree 의 dirty path set 스냅샷 저장.
|
||||
이미 baseline 파일 있으면 보존 (resumed run 의 가드 일관성 유지)."""
|
||||
p = _audit_baseline_path(n)
|
||||
if p.exists():
|
||||
return
|
||||
paths = _git_porcelain_paths()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(sorted(paths), ensure_ascii=False), encoding="utf-8")
|
||||
log(f" audit baseline saved: {len(paths)} pre-existing paths (file: {p.name})")
|
||||
|
||||
def _load_audit_baseline(n):
|
||||
"""저장된 baseline path set 로드. 파일 없으면 빈 set."""
|
||||
p = _audit_baseline_path(n)
|
||||
if not p.exists():
|
||||
return set()
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return set(data) if isinstance(data, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def _check_audit_only_violations(baseline=None):
|
||||
"""git status --porcelain 검사. AUDIT_ONLY_FORBIDDEN_PREFIXES 매치 변경 list 반환.
|
||||
baseline (set of paths) 가 주어지면 그 path 는 violation 에서 제외 — pre-existing dirty 무시.
|
||||
Returns: list of violating paths (빈 list = 통과)."""
|
||||
paths = _git_porcelain_paths()
|
||||
if not paths:
|
||||
return [] # clean tree or git error — fail open
|
||||
base = baseline if baseline is not None else set()
|
||||
bad = []
|
||||
for norm in paths:
|
||||
if norm in base:
|
||||
continue # pre-existing — not a NEW violation
|
||||
for prefix in AUDIT_ONLY_FORBIDDEN_PREFIXES:
|
||||
if norm.startswith(prefix):
|
||||
bad.append(norm)
|
||||
break
|
||||
return bad
|
||||
|
||||
def _check_audit_commit_scope():
|
||||
"""P4a — Stage 5 commit scope guard.
|
||||
HEAD commit 의 file list 가 AUDIT_ALLOWED_COMMIT_GLOBS 안에만 있는지 검증.
|
||||
Returns: list of paths committed outside allowed scope (빈 list = 통과)."""
|
||||
import fnmatch
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "show", "HEAD", "--name-only", "--pretty=format:"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
cwd=PROJECT_DIR, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return [] # git error — fail open
|
||||
except Exception:
|
||||
return []
|
||||
bad = []
|
||||
for line in r.stdout.splitlines():
|
||||
path = line.strip().replace("\\", "/")
|
||||
if not path:
|
||||
continue
|
||||
if not any(fnmatch.fnmatch(path, g) for g in AUDIT_ALLOWED_COMMIT_GLOBS):
|
||||
bad.append(path)
|
||||
return bad
|
||||
|
||||
# P1-5 (2026-05-18) — Stage 2 compact rule (모든 issue 적용).
|
||||
# Stage 2 의 c-role 에 size budget + code snippet 금지 명시. 29 KB plan 차단.
|
||||
COMPACT_PLAN_RULE = """
|
||||
@@ -1284,15 +1350,18 @@ def run_stage(n, title, body, sid):
|
||||
|
||||
# P4 (2026-05-19) — AUDIT-ONLY guard: Stage 3 (code-edit) YES 직전 git status 검사.
|
||||
# src/templates/tests 변경 있으면 자동 reject + supplement 요청. LLM 양심 무관 deterministic.
|
||||
# P4a (2026-05-19) — baseline subtraction. audit 시작 시점 dirty path 는 제외 —
|
||||
# Claude 가 새로 만든 forbidden 변경만 잡음.
|
||||
if sid == "code-edit" and _audit_mode(title):
|
||||
bad = _check_audit_only_violations()
|
||||
baseline = _load_audit_baseline(n)
|
||||
bad = _check_audit_only_violations(baseline)
|
||||
if bad:
|
||||
log(f"⚠️ AUDIT-ONLY violation — Stage 3 YES rejected: {len(bad)} forbidden file change(s)")
|
||||
log(f" violations (first 5): {bad[:5]}")
|
||||
try: gitea(f"issues/{n}/comments", "POST", {"body":
|
||||
"⚠️ **[Orchestrator]** AUDIT-ONLY mode violation: Stage 3 YES rejected.\n\n"
|
||||
"This issue is in audit-only mode. Production code changes are forbidden.\n\n"
|
||||
f"Forbidden file changes detected ({len(bad)} file(s)):\n" +
|
||||
f"NEW forbidden file changes detected ({len(bad)} file(s), beyond pre-existing baseline):\n" +
|
||||
"\n".join(f" - `{v}`" for v in bad[:20]) +
|
||||
("\n - ... (truncated)" if len(bad) > 20 else "") + "\n\n"
|
||||
"Revert these changes and limit Stage 3 outputs to:\n"
|
||||
@@ -1303,6 +1372,36 @@ def run_stage(n, title, body, sid):
|
||||
except: pass
|
||||
continue
|
||||
|
||||
# P4a (2026-05-19) — Stage 5 commit scope guard.
|
||||
# 'git add -A' 같은 명령으로 dirty WIP 가 audit commit 에 섞이는 사고 방지.
|
||||
# HEAD commit 의 파일 list 가 AUDIT_ALLOWED_COMMIT_GLOBS 안에만 있어야 함.
|
||||
if sid == "commit-push" and _audit_mode(title):
|
||||
out_of_scope = _check_audit_commit_scope()
|
||||
if out_of_scope:
|
||||
log(f"⚠️ AUDIT-ONLY violation — Stage 5 YES rejected: HEAD commit includes {len(out_of_scope)} out-of-scope file(s)")
|
||||
log(f" out-of-scope (first 5): {out_of_scope[:5]}")
|
||||
try: gitea(f"issues/{n}/comments", "POST", {"body":
|
||||
"⚠️ **[Orchestrator]** AUDIT-ONLY mode violation: Stage 5 YES rejected.\n\n"
|
||||
"The HEAD commit includes files outside the audit-allowed scope.\n\n"
|
||||
f"Out-of-scope files in HEAD commit ({len(out_of_scope)} file(s)):\n" +
|
||||
"\n".join(f" - `{v}`" for v in out_of_scope[:20]) +
|
||||
("\n - ... (truncated)" if len(out_of_scope) > 20 else "") + "\n\n"
|
||||
"Allowed commit scope:\n"
|
||||
"- `docs/architecture/INTEGRATION-AUDIT-*.md`\n"
|
||||
"- `docs/architecture/INTEGRATION-AUDIT-*/*` (subdirectory variants)\n"
|
||||
"- `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`\n\n"
|
||||
"Remediation (use --force-with-lease, NOT plain --force):\n"
|
||||
"```\n"
|
||||
"git reset --soft HEAD~1\n"
|
||||
"git restore --staged <out-of-scope files>\n"
|
||||
"git commit -m '<audit commit message>'\n"
|
||||
"git push --force-with-lease origin <branch>\n"
|
||||
"```\n\n"
|
||||
"Do NOT use `git add -A` or `git add .` in audit-only mode. "
|
||||
"Stage only the audit report files explicitly."})
|
||||
except: pass
|
||||
continue
|
||||
|
||||
log(f"✅ {si['label']} — YES (evidence verified)")
|
||||
# stage 완료 = unit counter + remaining tracker 모두 reset
|
||||
update_issue_state(n, continue_same_count=0, last_remaining_units=None)
|
||||
@@ -1475,6 +1574,10 @@ def run_issue(n, until=None):
|
||||
if issue["state"] == "closed": log(f"#{n} closed, skip"); return
|
||||
title = issue["title"]; body = issue.get("body", "")
|
||||
header(f"Issue #{n}: {title}")
|
||||
# P4a (2026-05-19) — audit baseline 저장 (resumed run 시 기존 파일 보존).
|
||||
# audit mode 일 때만 호출 — 일반 이슈 경로 영향 0.
|
||||
if _audit_mode(title):
|
||||
_ensure_audit_baseline(n)
|
||||
st = get_issue_state(n); cur = st.get("stage", "problem-review")
|
||||
si = STAGE_IDS.index(cur) if cur in STAGE_IDS else 0
|
||||
ei = STAGE_IDS.index(until)+1 if until and until in STAGE_IDS else len(STAGES)
|
||||
|
||||
Reference in New Issue
Block a user