feat(agentctl): externalize governance policy to JSON

Load configs/agent-governance.json from the main worktree with fail-closed
POLICY_* codes, and keep test fixtures writing the same contract file.
This commit is contained in:
WeiCHEN
2026-09-23 14:50:56 +08:00
parent 1cb8c6f4b3
commit 33c7b9a260
9 changed files with 287 additions and 14 deletions
+1
View File
@@ -18,6 +18,7 @@
| owner | task | branch | worktree | mutex | status |
|---|---|---|---|---|---|
| Cursor | agent-governance-config-2026-09-23 | feature/agent-governance-config-2026-09-23 | .worktrees/agent-governance-config-2026-09-23 | agent-governance-config | in_progress |
| Cursor | coordination-db-daemon-impl-plan-2026-09-23 | feature/coordination-db-daemon-impl-plan-2026-09-23 | .worktrees/coordination-db-daemon-impl-plan-2026-09-23 | coordination-db-daemon-impl-plan | done |
| Cursor | coordination-db-daemon-2026-09-23 | feature/coordination-db-daemon-2026-09-23 | .worktrees/coordination-db-daemon-2026-09-23 | coordination-db-daemon | done |
| Cursor | integrate-pi-runner-response-size-r2-2026-09-23 | feature/integrate-pi-runner-response-size-2026-09-23 | .worktrees/integrate-pi-runner-response-size-2026-09-23 | git:dev | done |
@@ -0,0 +1,45 @@
---
title: "W1-U2:治理策略外部化为 agent-governance.json"
task_id: agent-governance-config-2026-09-23
status: in_progress
created: 2026-09-23
updated: 2026-09-23
owner: Cursor
task_type: implementation
task_tier: single
policy_revision: "agent-governance-2026-09-21-r1 @ 1cb8c6f4b3379ef1136972ab36c5e120829d11f8"
branch: feature/agent-governance-config-2026-09-23
worktree: .worktrees/agent-governance-config-2026-09-23
base_dev_sha: "1cb8c6f4b3379ef1136972ab36c5e120829d11f8"
write_scope: ["scripts", "configs", ".doc"]
allowed_environments: []
mutex: agent-governance-config
review_state: pending
integration_state: pending
deployment: not_required
cleanup: pending
cleanup_owner: "独立维护者"
cleanup_trigger: "合入 local_dev 且无进程占用后复查"
remote_sync: not_requested
---
# W1-U2:治理策略外部化
## 用户目标与授权
- 用户目标:`configs/agent-governance.json`(stdlib JSON)外部化 SHARED_DOCS / JOB_KINDS / doc_rules 等;`load_policy` fail-closed。
- 授权:W1 波次 U2;不 push。
- 交付终点:合入 local_dev;负例三类拒绝码可测。
## 步骤
1. [x] worktree + register + CURRENT 认领
2. [ ] 新建 `configs/agent-governance.json`
3. [ ] `load_policy` + 写路径前置校验;只读不创建
4. [ ] 测试 fixture 写入策略文件
5. [ ] 负例测试 + unittest 全绿 + integrate
## 相关链接
- [实施承接计划](../../remediation/2026-09-23-coordination-db-daemon-implementation-undertaking-plan.md)
- 前序 U1r tip:`1cb8c6f4`
+34
View File
@@ -0,0 +1,34 @@
{
"protocol_version": 1,
"min_protocol_version": 1,
"policy_revision": "agent-governance-2026-09-21-r1",
"shared_docs": [".doc/ops/CURRENT.md", ".doc/status/FEATURE-MATRIX.md"],
"projectable_docs": [".doc/ops/CURRENT.md", ".doc/status/FEATURE-MATRIX.md"],
"projectable_prefixes": [".doc/ops/", ".doc/status/", ".doc/remediation/"],
"job_kinds": [
"validate",
"integrate",
"with",
"deploy_prepare",
"deploy_run",
"deploy_accept",
"docs_project",
"cleanup_inspect",
"noop"
],
"doc_rules": {
".doc/ops/CURRENT.md": {
"own_row_only": true,
"slot_protected": true,
"header": "| owner | task |"
},
".doc/status/FEATURE-MATRIX.md": {
"evidence_for_done": true,
"header": "| 模块 |"
}
},
"reserved_record_ids": ["holder", "idle", "Cursor", "Codex", "-"],
"protected_sections": {
".doc/ops/CURRENT.md": ["## 集成槽位"]
}
}
+148 -13
View File
@@ -24,7 +24,36 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import agentctl_w4 as w4 # noqa: E402
VERSION = 1
SHARED_DOCS = {'.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md'}
POLICY_REL = 'configs/agent-governance.json'
DEFAULT_POLICY = {
'protocol_version': 1,
'min_protocol_version': 1,
'policy_revision': 'agent-governance-2026-09-21-r1',
'shared_docs': ['.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md'],
'projectable_docs': ['.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md'],
'projectable_prefixes': ['.doc/ops/', '.doc/status/', '.doc/remediation/'],
'job_kinds': [
'validate', 'integrate', 'with', 'deploy_prepare', 'deploy_run',
'deploy_accept', 'docs_project', 'cleanup_inspect', 'noop',
],
'doc_rules': {
'.doc/ops/CURRENT.md': {
'own_row_only': True,
'slot_protected': True,
'header': '| owner | task |',
},
'.doc/status/FEATURE-MATRIX.md': {
'evidence_for_done': True,
'header': '| 模块 |',
},
},
'reserved_record_ids': ['holder', 'idle', 'Cursor', 'Codex', '-'],
'protected_sections': {'.doc/ops/CURRENT.md': ['## 集成槽位']},
}
SHARED_DOCS = set(DEFAULT_POLICY['shared_docs'])
RESERVED_RECORD_IDS = set(DEFAULT_POLICY['reserved_record_ids'])
DOC_RULES = dict(DEFAULT_POLICY['doc_rules'])
PROTECTED_SECTIONS = {k: list(v) for k, v in DEFAULT_POLICY['protected_sections'].items()}
ACTIVE = ('running', 'recovery_required')
TERMINAL = ('completed', 'failed', 'recovered')
QUEUE_TERMINAL = ('completed', 'failed', 'cancelled')
@@ -69,6 +98,100 @@ def digest(value):
return hashlib.sha256(value.encode() if isinstance(value, str) else value).hexdigest()
def _policy_path(main: Path) -> Path:
return Path(main) / POLICY_REL
def _validate_policy_shape(data) -> dict:
if not isinstance(data, dict):
deny('POLICY_INVALID', 'Governance policy must be a JSON object')
required = (
'protocol_version', 'min_protocol_version', 'policy_revision',
'shared_docs', 'projectable_docs', 'projectable_prefixes', 'job_kinds',
'doc_rules', 'reserved_record_ids', 'protected_sections',
)
missing = [key for key in required if key not in data]
if missing:
deny('POLICY_INVALID', 'Governance policy missing required keys', keys=missing)
if not isinstance(data['protocol_version'], int) or not isinstance(data['min_protocol_version'], int):
deny('POLICY_INVALID', 'protocol_version and min_protocol_version must be integers')
if data['protocol_version'] < data['min_protocol_version']:
deny('POLICY_MISMATCH', 'protocol_version below min_protocol_version',
protocol_version=data['protocol_version'],
min_protocol_version=data['min_protocol_version'])
for key in ('shared_docs', 'projectable_docs', 'projectable_prefixes',
'job_kinds', 'reserved_record_ids'):
if not isinstance(data[key], list) or not all(isinstance(item, str) for item in data[key]):
deny('POLICY_INVALID', f'{key} must be a list of strings')
if not isinstance(data['doc_rules'], dict) or not isinstance(data['protected_sections'], dict):
deny('POLICY_INVALID', 'doc_rules and protected_sections must be objects')
if not isinstance(data['policy_revision'], str) or not data['policy_revision']:
deny('POLICY_INVALID', 'policy_revision must be a nonempty string')
# Structure must stay compatible with the builtin DEFAULT_POLICY contract.
if set(data['shared_docs']) != set(DEFAULT_POLICY['shared_docs']):
deny('POLICY_MISMATCH', 'shared_docs diverges from DEFAULT_POLICY contract')
if set(data['job_kinds']) != set(DEFAULT_POLICY['job_kinds']):
deny('POLICY_MISMATCH', 'job_kinds diverges from DEFAULT_POLICY contract')
if set(data['projectable_docs']) != set(DEFAULT_POLICY['projectable_docs']):
deny('POLICY_MISMATCH', 'projectable_docs diverges from DEFAULT_POLICY contract')
if list(data['projectable_prefixes']) != list(DEFAULT_POLICY['projectable_prefixes']):
deny('POLICY_MISMATCH', 'projectable_prefixes diverges from DEFAULT_POLICY contract')
return data
def apply_policy(policy: dict) -> dict:
"""Install a validated policy into module globals used by write paths."""
global SHARED_DOCS, RESERVED_RECORD_IDS, DOC_RULES, PROTECTED_SECTIONS
SHARED_DOCS = set(policy['shared_docs'])
RESERVED_RECORD_IDS = set(policy['reserved_record_ids'])
DOC_RULES = {key: dict(value) for key, value in policy['doc_rules'].items()}
PROTECTED_SECTIONS = {key: list(value) for key, value in policy['protected_sections'].items()}
w4.apply_policy(policy)
return policy
def load_policy(main: Path) -> dict:
"""Load governance policy from the main worktree. Fail-closed; never creates the file."""
path = _policy_path(main)
if not path.is_file():
deny('POLICY_MISSING', f'Governance policy missing at {POLICY_REL}', path=str(path))
try:
raw = path.read_text(encoding='utf-8')
data = json.loads(raw)
except (OSError, UnicodeError) as exc:
deny('POLICY_INVALID', f'Cannot read governance policy: {exc}', path=str(path))
except json.JSONDecodeError as exc:
deny('POLICY_INVALID', f'Governance policy is not valid JSON: {exc}', path=str(path))
policy = _validate_policy_shape(data)
return apply_policy(policy)
def policy_status(main: Path) -> dict:
"""Read-only policy probe for status/doctor. Never creates files."""
path = _policy_path(main)
if not path.is_file():
return {'status': 'missing', 'code': 'POLICY_MISSING', 'path': str(path)}
try:
data = json.loads(path.read_text(encoding='utf-8'))
policy = _validate_policy_shape(data)
apply_policy(policy)
return {
'status': 'ok',
'path': str(path),
'policy_revision': policy['policy_revision'],
'protocol_version': policy['protocol_version'],
}
except Denied as exc:
return {'status': 'denied', 'code': exc.code, 'message': exc.message, 'path': str(path),
**exc.details}
except (OSError, UnicodeError) as exc:
return {'status': 'denied', 'code': 'POLICY_INVALID', 'message': str(exc), 'path': str(path)}
def require_policy(repo) -> dict:
return load_policy(repo.main)
def git(path, *args, index=False, check=True, input=None):
env = os.environ.copy()
for key in GIT_CONTEXT:
@@ -879,7 +1002,7 @@ def docs_apply(repo, args):
deny('CAPABILITY_REQUIRED', 'Document layout changes require docs:admin')
if filename.endswith('CURRENT.md') and record_id != args.task and 'docs:admin' not in json.loads(task['capabilities']):
deny('PATH_OUT_OF_SCOPE', 'A task can update only its own CURRENT record')
if record_id in ('holder', 'idle', 'Cursor', 'Codex', '-'):
if record_id in RESERVED_RECORD_IDS:
deny('PATH_OUT_OF_SCOPE', 'Use task ids; slot ownership is not a document edit')
path = safe_path(repo.current, filename)
if filename not in before:
@@ -900,14 +1023,14 @@ def docs_apply(repo, args):
deny('INVALID_REQUEST', 'Expected full document text')
if any(other.get('file') == filename and other is not change for other in request['changes']):
deny('INVALID_REQUEST', 'Whole-document operation cannot mix row edits for the same file')
if filename.endswith('CURRENT.md'):
def slot(text):
match = re.search(r'(?m)^## 集成槽位\s*\n(.*?)(?=^## |\Z)', text, re.S | re.M)
for section in PROTECTED_SECTIONS.get(filename, []):
def slot(text, marker=section):
match = re.search(r'(?m)^' + re.escape(marker) + r'\s*\n(.*?)(?=^## |\Z)', text, re.S | re.M)
return match.group(1) if match else None
if slot(replacement) != slot(before[filename]):
deny('PATH_OUT_OF_SCOPE', 'Document admin cannot overwrite integration slot ownership')
# A layout update cannot bypass the feature-completion evidence rule.
if filename.endswith('FEATURE-MATRIX.md'):
if DOC_RULES.get(filename, {}).get('evidence_for_done') or filename.endswith('FEATURE-MATRIX.md'):
old_states = matrix_states(before[filename])
new_states = matrix_states(replacement)
if any(value == 'done' and old_states.get(key) != 'done' for key,value in new_states.items()):
@@ -931,7 +1054,9 @@ def docs_apply(repo, args):
if live:
lines[live[0][0]] = replacement
else:
header = '| owner | task |' if filename.endswith('CURRENT.md') else '| 模块 |'
header = DOC_RULES.get(filename, {}).get('header')
if not header:
header = '| owner | task |' if filename.endswith('CURRENT.md') else '| 模块 |'
positions = [i for i, line in enumerate(lines) if line.startswith(header)]
if len(positions) != 1:
deny('INVALID_DOCUMENT', 'Target table header is absent or ambiguous')
@@ -1640,9 +1765,24 @@ def parser():
def main():
args=parser().parse_args();repo=Repo(args.repo)
write_actions = {
'init', 'task', 'with', 'docs', 'validate', 'integrate', 'queue',
'deploy', 'recover', 'freeze', 'unfreeze', 'hooks', 'guard', 'cleanup',
}
# status/doctor remain readable without a policy file and never create one.
if args.action in write_actions:
if args.action == 'docs' and getattr(args, 'doc_action', None) == 'snapshot':
pass # snapshot is read-only CAS helper
elif args.action == 'queue' and getattr(args, 'queue_action', None) == 'list':
pass
elif args.action == 'cleanup':
require_policy(repo) # status report still needs contract constants
else:
require_policy(repo)
if args.action=='init':initialize(repo)
elif args.action in ('status','doctor'):
identity=repo.identity()
policy=policy_status(repo.main)
try:
with repo.db(True) as db:
ensure_schema(db)
@@ -1658,7 +1798,7 @@ def main():
queue=[]
for row in runs:row['supervisor_alive']=alive_status(row['pid'],row['start'],row['boot'])
emit({'status':'ok','lock_root':str(repo.root),'main':str(repo.main),'version':VERSION,'epoch':identity['epoch'],
'tasks':tasks,'runs':runs,'queue':queue})
'tasks':tasks,'runs':runs,'queue':queue,'policy':policy})
elif args.action=='task':
if args.task_action=='register':
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}',args.id):deny('INVALID_TASK','Invalid task id')
@@ -1733,11 +1873,6 @@ def main():
elif args.action=='validate':return validate(repo,args)
elif args.action=='integrate':return integrate(repo,args)
elif args.action=='deploy':return deploy_cmd(repo,args)
elif args.action=='queue':
if args.queue_action=='enqueue':return queue_enqueue(repo,args)
if args.queue_action=='list':return queue_list(repo,args)
if args.queue_action=='cancel':return queue_cancel(repo,args)
if args.queue_action=='schedule':return queue_schedule(repo,args)
return 0
+8 -1
View File
@@ -75,11 +75,18 @@ DEPLOY_PHASES = (
PROJECTABLE_DOCS = frozenset({
'.doc/ops/CURRENT.md',
'.doc/status/FEATURE-MATRIX.md',
'.doc/ops/CURRENT.md', # duplicate ok in set
})
PROJECTABLE_PREFIXES = ('.doc/ops/', '.doc/status/', '.doc/remediation/')
def apply_policy(policy: dict) -> None:
"""Apply governance policy fields that W4 owns (job kinds / projectable docs)."""
global JOB_KINDS, PROJECTABLE_DOCS, PROJECTABLE_PREFIXES
JOB_KINDS = frozenset(policy['job_kinds'])
PROJECTABLE_DOCS = frozenset(policy['projectable_docs'])
PROJECTABLE_PREFIXES = tuple(policy['projectable_prefixes'])
def ensure_schema(db: sqlite3.Connection) -> None:
db.executescript(SCHEMA_SQL)
+3
View File
@@ -26,6 +26,9 @@ class WorktreeTest(unittest.TestCase):
(self.root / "scripts").mkdir()
for name in ("agent-worktree.sh", "agent_worktree.py", "agentctl.py", "agentctl", "agentctl_w4.py"):
shutil.copy2(SOURCE / name, self.root / "scripts" / name)
(self.root / "configs").mkdir(parents=True, exist_ok=True)
shutil.copy2(SOURCE.parent / "configs" / "agent-governance.json",
self.root / "configs" / "agent-governance.json")
(self.root / ".gitignore").write_text(".worktrees/\n.locks/\nignored-evidence/\n")
(self.root / "source.txt").write_text("base\n")
self.env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
+43
View File
@@ -16,6 +16,13 @@ import unittest
SOURCE = pathlib.Path(__file__).resolve().parents[1] / "agentctl.py"
POLICY_SOURCE = SOURCE.parents[1] / "configs" / "agent-governance.json"
def install_governance_policy(root: pathlib.Path) -> None:
dest = root / "configs"
dest.mkdir(parents=True, exist_ok=True)
shutil.copy2(POLICY_SOURCE, dest / "agent-governance.json")
class AgentctlTests(unittest.TestCase):
@@ -30,6 +37,7 @@ class AgentctlTests(unittest.TestCase):
(self.root / "scripts").mkdir()
shutil.copy2(SOURCE, self.root / "scripts" / "agentctl.py")
shutil.copy2(SOURCE.parent / "agentctl_w4.py", self.root / "scripts" / "agentctl_w4.py")
install_governance_policy(self.root)
(self.root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
for directory in ("src/a", "src/b", ".doc/ops", ".doc/status"):
(self.root / directory).mkdir(parents=True, exist_ok=True)
@@ -747,6 +755,40 @@ class AgentctlTests(unittest.TestCase):
item = next(i for i in listed["items"] if i["id"] == "q-sched-1")
self.assertEqual(item["state"], "completed")
def test_policy_missing_rejects_write_but_status_stays_readonly(self):
self.cli("init")
policy = self.root / "configs" / "agent-governance.json"
policy.unlink()
self.assertFalse(policy.exists())
denied = self.cli("hooks", "install", check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("POLICY_MISSING", denied.stderr)
self.assertFalse(policy.exists())
status = json.loads(self.cli("status").stdout)
self.assertEqual(status["status"], "ok")
self.assertEqual(status["policy"]["code"], "POLICY_MISSING")
self.assertFalse(policy.exists())
doctor = json.loads(self.cli("doctor").stdout)
self.assertEqual(doctor["status"], "ok")
self.assertEqual(doctor["policy"]["code"], "POLICY_MISSING")
self.assertFalse(policy.exists())
def test_policy_invalid_json_rejects_write(self):
policy = self.root / "configs" / "agent-governance.json"
policy.write_text("{not-json", encoding="utf-8")
denied = self.cli("init", check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("POLICY_INVALID", denied.stderr)
def test_policy_protocol_too_low_rejects_write(self):
policy = self.root / "configs" / "agent-governance.json"
data = json.loads(policy.read_text(encoding="utf-8"))
data["protocol_version"] = 0
policy.write_text(json.dumps(data), encoding="utf-8")
denied = self.cli("init", check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("POLICY_MISMATCH", denied.stderr)
class ProcessPlatformTests(unittest.TestCase):
@classmethod
@@ -818,6 +860,7 @@ class ProcessPlatformTests(unittest.TestCase):
(root / "scripts").mkdir()
shutil.copy2(SOURCE, root / "scripts" / "agentctl.py")
shutil.copy2(SOURCE.parent / "agentctl_w4.py", root / "scripts" / "agentctl_w4.py")
install_governance_policy(root)
(root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
(root / ".doc/ops").mkdir(parents=True)
(root / ".doc/ops/CURRENT.md").write_text("# c\n", encoding="utf-8")
+3
View File
@@ -32,6 +32,9 @@ class AgentctlW4Tests(unittest.TestCase):
self.root.mkdir()
(self.root / "scripts").mkdir()
shutil.copy2(W4_SOURCE, self.root / "scripts" / "agentctl_w4.py")
(self.root / "configs").mkdir(parents=True, exist_ok=True)
shutil.copy2(SOURCE.parent / "configs" / "agent-governance.json",
self.root / "configs" / "agent-governance.json")
self.w4 = load_w4(self.root / "scripts" / "agentctl_w4.py")
self.db_path = Path(self.tmp.name) / "w4.sqlite"
self.db = sqlite3.connect(self.db_path)
+2
View File
@@ -24,6 +24,8 @@ class OpsGuardsTest(unittest.TestCase):
(self.root / ".gitignore").write_text(".worktrees/\n.locks/\nconfigs/.env.*\n!configs/.env.*.example\ndata/\n")
(self.root / ".dockerignore").write_text(".doc\n.locks\n.worktrees\n")
(self.root / "configs").mkdir()
policy_src = SOURCE.parent / "configs" / "agent-governance.json"
shutil.copy2(policy_src, self.root / "configs" / "agent-governance.json")
for target in ("shared", "dev", "test", "main"):
(self.root / f"docker-compose.{target}.yml").write_text("services: {}\n")
(self.root / "configs" / f".env.{target}.example").write_text("EXAMPLE=1\n")