diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml index 10f3757..ad6be1f 100644 --- a/.github/workflows/policy.yml +++ b/.github/workflows/policy.yml @@ -44,8 +44,7 @@ jobs: TRAILER = re.compile( r"^(Co-authored-by|Made-with|Generated-by|Generated-with|Assisted-by|Signed-off-by):" r"|Generated with|cursor\.com|anthropic\.com|claude\.ai|openai\.com", re.I | re.M) - PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|\.(?:agent|workspace)/") - PLAN = re.compile(r"(?:\b[^\s/]+\.plan\.md\b|\bPLANS\.md\b)", re.I) + PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|[.]workspace/") AGENT = re.compile(r"\b(ChatGPT|Codex|Cursor|Claude)\b", re.I) @@ -83,8 +82,6 @@ jobs: reasons.append("attribution trailer or tool attribution") if PRIVATE_PATH.search(text): reasons.append("private execution path") - if PLAN.search(text): - reasons.append("private plan file") terms = sorted({match.group() for match in AGENT.finditer(text) if match.group().lower() not in allow_terms}) if terms: @@ -132,8 +129,12 @@ jobs: comparison = git("hash-object", "-t", "tree", "--stdin").strip() else: comparison = git("merge-base", base, head).strip() - paths = git("diff", "--name-only", "--no-renames", "-z", comparison, head).split("\0") + paths = git("diff", "--name-only", "--no-renames", "--diff-filter=d", "-z", comparison, head).split("\0") for path in filter(None, paths): + file = Path(path) + if (any(part in {".agent", ".workspace"} for part in file.parts) + or file.name == "PLANS.md" or file.name.endswith(".plan.md")): + findings.append(f"{path}: private execution file") # Git can emit byte fixtures as text when they contain no NUL. Keep # scanning their readable content without requiring valid UTF-8. patch = git("diff", "--no-ext-diff", "--no-color", "--no-renames", "--unified=0", diff --git a/Documentation/Reference/WorkflowIntegration.md b/Documentation/Reference/WorkflowIntegration.md index 4bf20d0..bccb4f5 100644 --- a/Documentation/Reference/WorkflowIntegration.md +++ b/Documentation/Reference/WorkflowIntegration.md @@ -104,17 +104,23 @@ default-branch site builds publish through a separate non-cancelling workflow. The required check is `policy / policy`. Pull requests check the title for Conventional Commit syntax, attribution and unapproved terms, then scan only -added lines. Contributor identities and signatures do not block pull requests. +added lines for attribution, unapproved product terms, absolute personal paths +and paths into the local-only `.workspace` directory. Mentions of `.agent/`, +`PLANS.md` and `*.plan.md` are allowed in text. Changed file paths are checked +separately: added or modified files are rejected when any path segment is +`.agent` or `.workspace`, or the basename is `PLANS.md` or ends with `.plan.md`. +Deleting those files is allowed. Contributor identities and signatures do not +block pull requests. Default-branch pushes require every new commit to be GitHub-verified with the owner or GitHub as committer; external and bot authors are accepted. An initial push checks the complete initial history. The embedded shared version validator runs when `.github/release.json` exists. The `allow-terms` input lists identifiers and product names required by the -repository, one per line. It does not exempt attribution, private paths or plan -files. Keep the list limited to the repository's actual public surface. Shared -policy sources and their tests name all supported tools, so this repository -declares those names in its own caller. +repository, one per line. It does not exempt attribution, absolute personal paths, +local-only directory paths or private execution files. Keep the list limited to +the repository's actual public surface. Shared policy sources and their tests +name all supported tools, so this repository declares those names in its own caller. `Scripts/check-policy.py` owns the policy implementation. Run `python3 Scripts/sync-policy-workflow.py` after editing it. The generated diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 00c2005..52c828f 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -24,7 +24,9 @@ file in the same pull request. setting. External pull requests and bot-authored changes are welcome. Pull-request -policy checks the title and added content. Default-branch push policy requires +policy checks the title, added content and added or modified file paths, as +defined in [Source policy](Documentation/Reference/WorkflowIntegration.md#source-policy). +Default-branch push policy requires GitHub-verified commits committed by GitHub or the owner; it does not restrict the original author. Dependabot, updater Apps and GitHub Actions may author changes. Product names are allowed only through the caller's `allow-terms`. diff --git a/Scripts/check-policy.py b/Scripts/check-policy.py index 1d53000..1bf4c1a 100755 --- a/Scripts/check-policy.py +++ b/Scripts/check-policy.py @@ -15,8 +15,7 @@ TRAILER = re.compile( r"^(Co-authored-by|Made-with|Generated-by|Generated-with|Assisted-by|Signed-off-by):" r"|Generated with|cursor\.com|anthropic\.com|claude\.ai|openai\.com", re.I | re.M) -PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|\.(?:agent|workspace)/") -PLAN = re.compile(r"(?:\b[^\s/]+\.plan\.md\b|\bPLANS\.md\b)", re.I) +PRIVATE_PATH = re.compile(r"/(?:Users|home)/[^/\s]+/|[.]workspace/") AGENT = re.compile(r"\b(ChatGPT|Codex|Cursor|Claude)\b", re.I) @@ -54,8 +53,6 @@ def content_findings(path, number, text, allow_terms): reasons.append("attribution trailer or tool attribution") if PRIVATE_PATH.search(text): reasons.append("private execution path") - if PLAN.search(text): - reasons.append("private plan file") terms = sorted({match.group() for match in AGENT.finditer(text) if match.group().lower() not in allow_terms}) if terms: @@ -103,8 +100,12 @@ def check_range(repository, base, head, allow_terms, *, verify_commits=False, fe comparison = git("hash-object", "-t", "tree", "--stdin").strip() else: comparison = git("merge-base", base, head).strip() - paths = git("diff", "--name-only", "--no-renames", "-z", comparison, head).split("\0") + paths = git("diff", "--name-only", "--no-renames", "--diff-filter=d", "-z", comparison, head).split("\0") for path in filter(None, paths): + file = Path(path) + if (any(part in {".agent", ".workspace"} for part in file.parts) + or file.name == "PLANS.md" or file.name.endswith(".plan.md")): + findings.append(f"{path}: private execution file") # Git can emit byte fixtures as text when they contain no NUL. Keep # scanning their readable content without requiring valid UTF-8. patch = git("diff", "--no-ext-diff", "--no-color", "--no-renames", "--unified=0", diff --git a/Tests/test_policy.py b/Tests/test_policy.py index c3ad991..5b285f5 100644 --- a/Tests/test_policy.py +++ b/Tests/test_policy.py @@ -41,8 +41,11 @@ def test_content_and_allowlist(self): self.assertTrue(policy.content_findings("a", 3, "Co-authored-by: helper", set())) workstation = "/" + "Users/fixture/project" self.assertTrue(policy.content_findings("a", 3, workstation, set())) + self.assertTrue(policy.content_findings("a", 3, "/" + "home/fixture/project", set())) self.assertTrue(policy.content_findings("a", 3, "." + "workspace/cache", set())) - self.assertTrue(policy.content_findings("a", 3, "example." + "plan.md", set())) + for text in (".agent/plans/x.md", ".agent/PLANS.md", "PLANS.md", "release.plan.md"): + with self.subTest(text=text): + self.assertEqual(policy.content_findings("a", 3, text, set()), []) self.assertTrue(policy.content_findings("a", 3, "Codex", set())) self.assertEqual(policy.content_findings("a", 3, "Codex API", {"codex"}), []) self.assertTrue(policy.content_findings("a", 3, workstation + " Codex", {"codex"})) @@ -86,6 +89,92 @@ def commit(text): finally: os.chdir(previous) + def test_real_git_private_file_paths(self): + output = ROOT / ".build/policy-tests" + output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=output) as directory: + previous = Path.cwd() + os.chdir(directory) + try: + subprocess.run(["git", "init", "-q"], check=True) + def commit(): + policy.git("add", "--all", "--force") + policy.git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "fix: fixture") + return policy.git("rev-parse", "HEAD").strip() + # Plan basenames and suffixes are matched case-sensitively. + public_paths = ("sample.txt", ".agent-notes/x.md", ".workspace-notes/x.md", + "docs/PLANS.md.example", "release.plan.md/guide.md", "plans.md", "docs/Release.PLAN.md") + for name in public_paths: + path = Path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("Document .agent/plans/x.md, .agent/PLANS.md and release.plan.md.\n") + base = commit() + self.assertEqual(policy.check_range("example/repo", policy.ZERO_SHA, base, set()), []) + private_paths = (".agent/plans/x.md", ".agent/plans/draft notes.md", "docs/PLANS.md", "x.plan.md", + "." + "workspace/state.json", "docs/.agent/x.md", "docs/." + "workspace/state.json") + expected = sorted(f"{name}: private execution file" for name in private_paths) + for name in private_paths: + path = Path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("initial\n") + added = commit() + self.assertEqual(sorted(policy.check_range("example/repo", base, added, set())), expected) + self.assertEqual(sorted(policy.check_range("example/repo", policy.ZERO_SHA, added, set())), expected) + self.assertEqual(sorted(policy.check_range("example/repo", base, added, set(), + verify_commits=True, fetch_commit=lambda *_: record())), expected) + self.assertEqual(sorted(policy.check_range("example/repo", base, added, set(), + verify_commits=True, fetch_commit=lambda *_: record(False))), + sorted([*expected, f"{added}: commit is not Verified by GitHub"])) + for name in private_paths: + Path(name).write_text("modified\n") + modified = commit() + self.assertEqual(sorted(policy.check_range("example/repo", added, modified, set())), expected) + self.assertEqual(sorted(policy.check_range("example/repo", added, modified, set(), + verify_commits=True, fetch_commit=lambda *_: record())), expected) + Path("sample.txt").write_text("updated documentation\n") + unchanged = commit() + self.assertEqual(policy.check_range("example/repo", modified, unchanged, set()), []) + for name in private_paths: + Path(name).unlink() + deleted = commit() + self.assertEqual(policy.check_range("example/repo", unchanged, deleted, set()), []) + self.assertEqual(policy.check_range("example/repo", unchanged, deleted, set(), + verify_commits=True, fetch_commit=lambda *_: record()), []) + finally: + os.chdir(previous) + + def test_real_git_renames_and_standalone_agent_file(self): + output = ROOT / ".build/policy-tests" + output.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=output) as directory: + previous = Path.cwd() + os.chdir(directory) + try: + subprocess.run(["git", "init", "-q"], check=True) + def commit(): + policy.git("add", "--all", "--force") + policy.git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "fix: fixture") + return policy.git("rev-parse", "HEAD").strip() + Path("sample.txt").write_text("clean\n") + base = commit() + Path(".agent").mkdir() + Path("sample.txt").rename(".agent/sample.txt") + moved_in = commit() + self.assertEqual(policy.check_range("example/repo", base, moved_in, set()), + [".agent/sample.txt: private execution file"]) + Path(".agent/sample.txt").rename("sample.txt") + moved_out = commit() + self.assertEqual(policy.check_range("example/repo", moved_in, moved_out, set()), []) + Path(".agent").rmdir() + Path("sample.txt").rename(".agent") + standalone = commit() + self.assertEqual(policy.check_range("example/repo", moved_out, standalone, set()), + [".agent: private execution file"]) + finally: + os.chdir(previous) + if __name__ == "__main__": unittest.main()