v1_policy.py
python
sha256:106ef9e12cca7f3be3ffb2a6bd3abfa76f032dce453c9e5010d732a8d86f2e14
Reconcile reviewed credential compatibility 32254cd939885d9…
Human
patch
8 days ago
| 1 | """Additive checkout-local exclusions; never untrack or rewrite user rules.""" |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import json |
| 5 | import re |
| 6 | import tomllib |
| 7 | |
| 8 | from cli.v1_io import Refusal, digest, read |
| 9 | |
| 10 | LOCAL_PATHS = (".overseer/config.yaml", ".overseer/bin/", ".overseer/local/", |
| 11 | "docs/NEXT.md", ".cursor/") |
| 12 | |
| 13 | |
| 14 | def local_path(path): |
| 15 | return any(path == p or (p.endswith("/") and path.startswith(p)) for p in LOCAL_PATHS) |
| 16 | |
| 17 | |
| 18 | def muse_ignore(raw): |
| 19 | text = (raw or b"").decode("utf-8") |
| 20 | try: |
| 21 | parsed = tomllib.loads(text) |
| 22 | forced = parsed.get("force_track", {}).get("paths", []) |
| 23 | if not isinstance(forced, list) or any(not isinstance(p, str) for p in forced): |
| 24 | raise ValueError() |
| 25 | if any(local_path(p.lstrip("/")) for p in forced): |
| 26 | raise Refusal("muse_force_tracks_local_binding") |
| 27 | sections = [("global", parsed.get("global", {}))] |
| 28 | domains = parsed.get("domain", {}) |
| 29 | if not isinstance(domains, dict): |
| 30 | raise ValueError() |
| 31 | sections.extend(("domain." + name, value) for name, value in domains.items()) |
| 32 | for name, value in sections: |
| 33 | if not isinstance(value, dict) or not isinstance(value.get("patterns", []), list): |
| 34 | raise ValueError() |
| 35 | patterns = value.get("patterns", []) |
| 36 | if any(not isinstance(p, str) for p in patterns): |
| 37 | raise ValueError() |
| 38 | additions = ["/" + p for p in LOCAL_PATHS] + ["/.git/"] |
| 39 | # A final suffix makes last-rule-wins negations harmless. Preserve |
| 40 | # all prior text, comments and rule order, even duplicate old rules. |
| 41 | if patterns[-len(additions):] == additions: |
| 42 | continue |
| 43 | table = re.search(r"(?m)^\[" + re.escape(name) + r"\][ \t]*(?:#[^\n]*)?$", text) |
| 44 | suffix = "\n# Overseer checkout-local bindings (never distribute).\npatterns = [\n" + "".join( |
| 45 | " " + json.dumps(p) + ",\n" for p in additions) + "]\n" |
| 46 | if not table: |
| 47 | if value: |
| 48 | raise Refusal("museignore_layout_unsupported") |
| 49 | text += ("\n" if text and not text.endswith("\n") else "") + "\n[" + name + "]" + suffix |
| 50 | continue |
| 51 | end_table = re.search(r"(?m)^\[", text[table.end():]) |
| 52 | end = table.end() + end_table.start() if end_table else len(text) |
| 53 | body = text[table.end():end] |
| 54 | array = re.search(r"(?m)^patterns\s*=\s*\[", body) |
| 55 | if not array: |
| 56 | if "patterns" in value: |
| 57 | raise Refusal("museignore_layout_unsupported") |
| 58 | text = text[:end] + suffix + text[end:] |
| 59 | continue |
| 60 | # Locate the TOML array end, ignoring comments and quoted strings. |
| 61 | start = table.end() + array.end() |
| 62 | quote = None |
| 63 | comment = False |
| 64 | escaped = False |
| 65 | for pos in range(start, end): |
| 66 | char = text[pos] |
| 67 | if comment: |
| 68 | comment = char != "\n" |
| 69 | elif quote: |
| 70 | if escaped: |
| 71 | escaped = False |
| 72 | elif char == "\\" and quote == '"': |
| 73 | escaped = True |
| 74 | elif char == quote: |
| 75 | quote = None |
| 76 | elif char == "#": |
| 77 | comment = True |
| 78 | elif char in "\"'": |
| 79 | quote = char |
| 80 | elif char == "]": |
| 81 | # Newline puts any missing comma beyond a trailing comment. |
| 82 | insert = ("\n," if patterns else "") + "\n # Overseer checkout-local bindings.\n" |
| 83 | # TOML permits a trailing comma, but not two commas. |
| 84 | cleaned = re.sub(r"#[^\n]*", "", text[start:pos]).rstrip() |
| 85 | if cleaned.endswith(","): |
| 86 | insert = insert.removeprefix("\n,") |
| 87 | insert += "".join(" " + json.dumps(p) + ",\n" for p in additions) |
| 88 | text = text[:pos] + insert + text[pos:] |
| 89 | break |
| 90 | else: |
| 91 | raise Refusal("museignore_layout_unsupported") |
| 92 | updated = tomllib.loads(text) |
| 93 | # The parser checks our insertion; no existing value may be discarded. |
| 94 | for name, old in sections: |
| 95 | new = updated["global"] if name == "global" else updated["domain"][name[7:]] |
| 96 | if new["patterns"][:len(old.get("patterns", []))] != old.get("patterns", []): |
| 97 | raise ValueError() |
| 98 | except (tomllib.TOMLDecodeError, ValueError, TypeError, AttributeError): |
| 99 | raise Refusal("museignore_invalid_or_unsupported") from None |
| 100 | return text.encode() |
| 101 | |
| 102 | |
| 103 | def ignore_assets(root, *, expected=None): |
| 104 | git_raw = read(root, ".gitignore", missing=True) |
| 105 | muse_raw = read(root, ".museignore", missing=True) |
| 106 | if expected is not None: |
| 107 | expected.update({p: "absent" if data is None else digest(data) |
| 108 | for p, data in ((".gitignore", git_raw), (".museignore", muse_raw))}) |
| 109 | old = git_raw or b"" |
| 110 | suffix = b"\n# Overseer checkout-local bindings (never distribute).\n" + "".join( |
| 111 | "/" + p + "\n" for p in (*LOCAL_PATHS, ".muse/")).encode() |
| 112 | gitignore = old if old.endswith(suffix) else old + (b"\n" if old and not old.endswith(b"\n") else b"") + suffix |
| 113 | return {".gitignore": gitignore, ".museignore": muse_ignore(muse_raw)} |
File History
2 commits
sha256:106ef9e12cca7f3be3ffb2a6bd3abfa76f032dce453c9e5010d732a8d86f2e14
Reconcile reviewed credential compatibility 32254cd939885d9…
Human
patch
8 days ago
sha256:c87cf2c2f03da507f66f93922cb339a2cbebb33ad6372a6039032fb09a20da12
Reconcile reviewed Overseer recovery d47291d5d9030de5ebef71…
Human
minor
⚠
8 days ago