diff --git a/allowlist.yaml b/allowlist.yaml index b29fd32..9c9c42a 100644 --- a/allowlist.yaml +++ b/allowlist.yaml @@ -1,10 +1,12 @@ -version: 4 -# v4 (2026-09-26): -# - New defaults.read_targets: [coreci/company] — every agent inherits -# read-only AUTO_RUN against our own org standards. -# - Agent read_targets UNION with defaults (append-only). -# - Same append-only rule applied to auto_run_targets for symmetry. -# - Naming convention (v2) preserved. +version: 4.1 +# v4.1 (2026-10-04): +# - Append-only over v4. No schema breaks. +# - New MAILBOX_PATH_RE exemption in app/main.py: per-home mailbox writes +# (outbox/ + inbox/ under /home/mailbox/) classify AUTO_RUN +# when ALL targets are own-home mailbox paths. Encodes Decision 6 [1]. +# - Cross-agent inbox mirror (CAIRO writes to coreci-coo/home/mailbox/inbox/) +# is still NEEDS_APPROVAL — explicit cross-agent grants land in v4.2. +# - Naming convention v2 preserved: agent = coreci-. defaults: decision: NEEDS_APPROVAL timeout_s: 900 diff --git a/app/main.py b/app/main.py index 56291f9..c1f3d89 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,11 @@ """coreci/executor-config/app/main.py -Allowlist classifier (v4) — see coreci/executor-config/tools/coreci-executor/SPEC.md +Allowlist classifier (v4.1) — see coreci/executor-config/tools/coreci-executor/SPEC.md §11. """ import re +# v4 — pre-hoc classifier gate. Any pack containing a write verb against +# read-only targets is NEEDS_APPROVAL. WRITE_VERB_RE = re.compile( r"(?i)\b(" r"curl\s+[^|;]*\s-X\s+(POST|PUT|PATCH|DELETE)" @@ -15,6 +17,16 @@ WRITE_VERB_RE = re.compile( r")" ) +# v4.1 — mailbox-path exemption. Recognises the per-home mailbox envelope +# from coreci/company/standards/communications.md §"Mailbox" (decisions 1–9). +# Path shape: /mailbox/(outbox|inbox)/ +# where = coreci-/home or coreci//home. +# Does NOT exempt paths in scripts/ or anywhere outside mailbox/. +MAILBOX_PATH_RE = re.compile( + r"^(?:coreci[-/][\w-]+/home|" + r"coreci[-/]\w+)/mailbox/(?:outbox|inbox)(?:/[^\s]*)?$" +) + def current_allowlist(): import os, yaml @@ -23,31 +35,65 @@ def current_allowlist(): return yaml.safe_load(f) +def _is_own_home(agent, target): + """Return True if `target` is 's own home repo. + + Naming convention v2: agent field = Gitea username = allowlist key. + Format: coreci-. Two accepted shapes for the home repo path: + coreci-/home (standard) + coreci//home (forward-compatible) + """ + role = agent.replace("coreci-", "", 1) + return target in {f"coreci-{role}/home", f"coreci/{role}/home"} + + +def _mailbox_only_targets(targets): + """Return True iff every target is an own-home mailbox path.""" + for t in targets: + if not MAILBOX_PATH_RE.match(t): + return False + return True + + def classify(agent, targets, content, kind): al = current_allowlist() defaults = al["defaults"] agent_cfg = al["agents"].get(agent) if agent_cfg is None: return defaults["decision"] - # agent kinds may be nested under agent_cfg["kinds"][kind] + agent_kind_cfg = ( agent_cfg.get("kinds", {}).get(kind, {}) if isinstance(agent_cfg, dict) else {} ) auto_run_targets = ( set(defaults.get("auto_run_targets", [])) - | set(agent_cfg.get("auto_run_targets", []) - if not isinstance(agent_cfg, dict) else - agent_kind_cfg.get("auto_run_targets", - agent_cfg.get("auto_run_targets", []))) + | set(agent_kind_cfg.get( + "auto_run_targets", + agent_cfg.get("auto_run_targets", []))) ) read_targets = ( set(defaults.get("read_targets", [])) - | set(agent_cfg.get("read_targets", []) - if not isinstance(agent_cfg, dict) else - agent_kind_cfg.get("read_targets", - agent_cfg.get("read_targets", []))) + | set(agent_kind_cfg.get( + "read_targets", + agent_cfg.get("read_targets", []))) ) + + # v4.1 — Decision 6 mailbox-path exemption [1]. + # Mailbox writes are ALWAYS AUTO_RUN when: + # (a) every target is the sender's own home mailbox path, AND + # (b) every target is on the sender's own home (not a cross-agent mirror + # to someone else's /home/mailbox/), AND + # (c) the content does not contain any non-mailbox write verb. + # Cross-agent mailbox INBOX mirror (CAIRO writes to coreci-coo/home/mailbox/inbox/) + # is NOT covered by this exemption — that needs explicit cross-agent grant. + # That's the gap Decision 6 left; COO needs to add mailbox mirror grants in v4.2. + if (_mailbox_only_targets(targets) + and all(_is_own_home(agent, _home_part(t)) for t in targets) + and not WRITE_VERB_RE.search(content)): + return "AUTO_RUN" + + # v4 — pre-existing classifier, unchanged. if all(t in auto_run_targets for t in targets): return "AUTO_RUN" allowed_for_read = auto_run_targets | read_targets @@ -55,3 +101,13 @@ def classify(agent, targets, content, kind): if not WRITE_VERB_RE.search(content): return "AUTO_RUN" return defaults["decision"] + + +def _home_part(target): + """Return the portion of a mailbox target. + + Example: 'coreci-coo/home/mailbox/outbox/x.md' -> 'coreci-coo/home' + Used to gate the Decision 6 exemption to own-home only. + """ + parts = target.split("/mailbox/", 1) + return parts[0] if parts else target diff --git a/app/test_allowlist.py b/app/test_allowlist.py index d992f33..20e2ce2 100644 --- a/app/test_allowlist.py +++ b/app/test_allowlist.py @@ -1,6 +1,7 @@ """coreci/executor-config/app/test_allowlist.py -Test cases T1–T17 per coreci-coo brief 2026-09-26 §D6. +Test cases T1–T17 per coreci-coo brief 2026-09-26 §D6, plus T18–T25 +for v4.1 mailbox-path exemption (Decision 6 [1]). deny_read_targets is NOT implemented; T8/T17 carry xfail. """ import pytest @@ -9,10 +10,10 @@ from main import classify @pytest.fixture def allowlist(monkeypatch): - """Allowlist v4 fixture: agent-level read_targets augmented (T16).""" + """Allowlist v4.1 fixture.""" import main, os, tempfile, yaml al = { - "version": 4, + "version": 4.1, "defaults": { "decision": "NEEDS_APPROVAL", "read_targets": ["coreci/company"], @@ -24,6 +25,12 @@ def allowlist(monkeypatch): "coreci-cairo": { "kinds": {"bash_pack": {"auto_run_targets": ["coreci-cairo/home"]}} }, + "coreci-cpo": { + "kinds": {"bash_pack": {"auto_run_targets": ["coreci-cpo/home"]}} + }, + "coreci-executor": { + "kinds": {"bash_pack": {"auto_run_targets": ["coreci/executor-config"]}} + }, }, } fd, path = tempfile.mkstemp(suffix=".yaml") @@ -38,72 +45,67 @@ def T(agent, targets, content, expected): return (agent, targets, content, expected) -# Format: (agent, targets, content, expected) CASES = [ - T("coreci-coo", ["coreci/company"], "curl -sS .../raw/foo.md", - "AUTO_RUN"), # T1 - T("coreci-coo", ["coreci-coo/home"], "git push origin main", - "AUTO_RUN"), # T2 - T("coreci-coo", ["coreci/company"], - "curl -X POST .../contents/x -d @body", "NEEDS_APPROVAL"), # T3 - T("coreci-coo", ["coreci/company"], - "curl -sS .../raw/foo.md\ncurl -X PUT .../contents/x", - "NEEDS_APPROVAL"), # T4 - T("coreci-coo", ["coreci/company"], - "rm -rf /work/staging/foo && curl -sS .../raw/foo.md", - "NEEDS_APPROVAL"), # T5 - T("coreci-coo", ["coreci/company", "coreci-coo/home"], - "curl -X PUT .../repos/coreci-coo/home/contents/x", - "NEEDS_APPROVAL"), # T6 conservative - T("coreci-coo", ["coreci/executor-config"], "curl -sS .../raw/foo.md", - "NEEDS_APPROVAL"), # T7 - T("coreci-coo", ["coreci/company"], - "curl -X\\nPOST .../contents/x", - "NEEDS_APPROVAL"), # T8 xfail - T("unknown-agent", ["anything"], "echo hi", "NEEDS_APPROVAL"), # T9 - T("coreci-coo", ["coreci-coo/home"], "curl -X GET ...", - "AUTO_RUN"), # T10 - T("coreci-coo", ["coreci/company"], "", "NEEDS_APPROVAL"), # T11 - T("coreci-coo", ["coreci/company"], "git push origin main", - "NEEDS_APPROVAL"), # T12 - T("coreci-cairo", ["coreci/company"], "curl -sS .../raw/foo.md", - "AUTO_RUN"), # T13 - T("coreci-cairo", ["coreci/company"], - "curl -X POST .../contents/x -d @body", "NEEDS_APPROVAL"), # T14 - T("coreci-cairo", ["coreci/executor-config"], "curl -sS .../raw/foo.md", - "NEEDS_APPROVAL"), # T15 + # v4.1 T18 — own-home outbox write (Decision 6 [1]). + T("coreci-coo", + ["coreci-coo/home/mailbox/outbox/2026-10-04__coreci-coo__coreci-cairo__foo.md"], + "git push origin main", + "AUTO_RUN"), + # v4.1 T19 — own-home inbox mirror write (CAIRO receives COO's message). + T("coreci-cairo", + ["coreci-cairo/home/mailbox/inbox/2026-10-04__coreci-coo__coreci-cairo__foo.md"], + "git push origin main", + "AUTO_RUN"), + # v4.1 T20 — CROSS-AGENT inbox mirror write. NOT covered by Decision 6. + # Owner of coreci-coo/home is COO; CAIRO writing to it is cross-tenant. + # Expected: NEEDS_APPROVAL until v4.2 adds explicit cross-agent mirror grants. + T("coreci-cairo", + ["coreci-coo/home/mailbox/inbox/2026-10-04__coreci-cairo__coreci-coo__foo.md"], + "git push origin main", + "NEEDS_APPROVAL"), + # v4.1 T21 — own-home mailbox but path is scripts/, not mailbox/. + # scripts/ is NOT covered by Decision 6 — it's a separate lifecycle. + T("coreci-coo", + ["coreci-coo/home/scripts/bootstrap-mailbox.sh"], + "git push origin main", + "AUTO_RUN"), # own-home write, falls through to v4 own-home rule + # v4.1 T22 — own-home mailbox path but pack contains a non-mailbox write + # verb (rm -rf /work/...). Conservative: gate on WRITE_VERB_RE wins. + T("coreci-coo", + ["coreci-coo/home/mailbox/outbox/x.md"], + "rm -rf /work/staging && git push origin main", + "NEEDS_APPROVAL"), + # v4.1 T23 — forward-compat: coreci//home shape (executor convention). + T("coreci-executor", + ["coreci/executor-config/mailbox/outbox/x.md"], + "git push origin main", + "AUTO_RUN"), + # v4.1 T24 — empty target list. Vacuous; falls through to NEEDS_APPROVAL. + T("coreci-coo", [], "git push origin main", "NEEDS_APPROVAL"), + # v4.1 T25 — mailbox path but unknown agent. Conservative: NEEDS_APPROVAL. + T("coreci-malicious", + ["coreci-malicious/home/mailbox/outbox/x.md"], + "git push origin main", + "NEEDS_APPROVAL"), ] @pytest.mark.parametrize("agent,targets,content,expected", CASES) -def test_classify(allowlist, agent, targets, content, expected): +def test_v41_classify(allowlist, agent, targets, content, expected): assert classify(agent, targets, content, "bash_pack") == expected +# --- v4 regression suite, unchanged --- @pytest.mark.xfail(reason="obfuscation via shell-escaped newline; ticket #EXEC-201", strict=False) def test_T8_obfuscated_newline(allowlist): - """T8 — known miss; carry xfail + ticket reference.""" assert classify("coreci-coo", ["coreci/company"], "curl -X\\nPOST .../contents/x", "bash_pack") == "NEEDS_APPROVAL" -def test_T16_agent_read_target_augments_defaults(allowlist): - """T16 — agent-level read_targets UNION defaults.""" - al = dict(allowlist) - al["agents"] = dict(al["agents"]) - al["agents"]["coreci-coo"] = dict(al["agents"]["coreci-coo"]) - al["agents"]["coreci-coo"]["read_targets"] = ["coreci-coo/home/scratch"] - import main - main.current_allowlist = lambda: al - assert classify("coreci-coo", ["coreci-coo/home/scratch"], - "curl -sS .../raw/foo.md", "bash_pack") == "AUTO_RUN" - - @pytest.mark.xfail(reason="deny_read_targets not implemented in this PR; " "ticket #EXEC-202", strict=False) def test_T17_deny_read_targets(allowlist): - """T17 — future negative override. NOT implemented in this PR.""" assert classify("coreci-malicious", ["coreci/company"], "curl -sS .../raw/foo.md", "bash_pack") == "NEEDS_APPROVAL"