arkaos 4.14.3 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/config/agent-roster.json +16 -0
- package/core/agents/authority_brief.py +205 -0
- package/core/agents/roster_manifest.py +60 -0
- package/core/hooks/pre_tool_use.py +20 -19
- package/core/hooks/session_start.py +25 -0
- package/core/workflow/specialist_authorization.py +140 -0
- package/core/workflow/specialist_enforcer.py +240 -44
- package/core/workflow/transcript_scope.py +74 -0
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.15.0
|
package/config/agent-roster.json
CHANGED
|
@@ -3,33 +3,49 @@
|
|
|
3
3
|
"generator": "core/agents/roster_manifest.py",
|
|
4
4
|
"purpose": "every specialist-gate owner resolves to a dispatchable agent source; installer/skill-deploy.js deploys these"
|
|
5
5
|
},
|
|
6
|
+
"aliases": {
|
|
7
|
+
"andre": "backend-dev",
|
|
8
|
+
"bruno": "security-eng",
|
|
9
|
+
"carlos": "devops-eng",
|
|
10
|
+
"diana": "frontend-dev",
|
|
11
|
+
"gabriel": "architect",
|
|
12
|
+
"vasco": "dba"
|
|
13
|
+
},
|
|
14
|
+
"ambiguous_first_names": [],
|
|
6
15
|
"gate_owners": {
|
|
7
16
|
"architect": {
|
|
8
17
|
"compiled": false,
|
|
18
|
+
"human_name": "Gabriel",
|
|
9
19
|
"source": "departments/dev/agents/architect.md"
|
|
10
20
|
},
|
|
11
21
|
"backend-dev": {
|
|
12
22
|
"compiled": true,
|
|
23
|
+
"human_name": "Andre",
|
|
13
24
|
"source": "config/claude-agents/backend-dev.md"
|
|
14
25
|
},
|
|
15
26
|
"dba": {
|
|
16
27
|
"compiled": true,
|
|
28
|
+
"human_name": "Vasco",
|
|
17
29
|
"source": "config/claude-agents/dba.md"
|
|
18
30
|
},
|
|
19
31
|
"devops-eng": {
|
|
20
32
|
"compiled": true,
|
|
33
|
+
"human_name": "Carlos",
|
|
21
34
|
"source": "config/claude-agents/devops-eng.md"
|
|
22
35
|
},
|
|
23
36
|
"frontend-dev": {
|
|
24
37
|
"compiled": false,
|
|
38
|
+
"human_name": "Diana",
|
|
25
39
|
"source": "departments/dev/agents/frontend-dev.md"
|
|
26
40
|
},
|
|
27
41
|
"security-eng": {
|
|
28
42
|
"compiled": true,
|
|
43
|
+
"human_name": "Bruno",
|
|
29
44
|
"source": "config/claude-agents/security-eng.md"
|
|
30
45
|
},
|
|
31
46
|
"senior-dev": {
|
|
32
47
|
"compiled": false,
|
|
48
|
+
"human_name": "",
|
|
33
49
|
"source": "departments/dev/agents/senior-dev.md"
|
|
34
50
|
}
|
|
35
51
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Authority brief — the write rules a session must know BEFORE it writes.
|
|
2
|
+
|
|
3
|
+
The 2026-07-12 incident: a session was blocked writing a Laravel
|
|
4
|
+
controller and, never having been told the rule existed, invented a
|
|
5
|
+
"documented bug" to justify a bypass. Grep confirms the gap: ownership
|
|
6
|
+
and ``specialist-bypass`` appear NOWHERE in CLAUDE.md, arka/SKILL.md,
|
|
7
|
+
flow/SKILL.md or constitution.yaml. The deny message was the first and
|
|
8
|
+
only place a session ever learned any of it.
|
|
9
|
+
|
|
10
|
+
This module renders the ``[ARKA:AUTHORITY]`` block the SessionStart hook
|
|
11
|
+
injects. Everything is GENERATED from ``config/agent-ownership.yaml`` +
|
|
12
|
+
``config/agent-roster.json`` + the agents actually deployed on THIS
|
|
13
|
+
machine — never hand-typed.
|
|
14
|
+
|
|
15
|
+
Two rules this file learned the hard way (QG, redo 1):
|
|
16
|
+
|
|
17
|
+
1. **Relevance is decided by the FULL literal spine, never its first
|
|
18
|
+
segment.** The first cut matched ``**/app/Http/Controllers/**`` in
|
|
19
|
+
this repo because a directory called ``dashboard/app`` exists — so it
|
|
20
|
+
taught a session five Laravel rules that govern nothing here.
|
|
21
|
+
2. **Never truncate in silence.** The same cut capped the list at 8 and
|
|
22
|
+
dropped ``core/workflow/**/*.py`` and ``core/agents/**/*.py`` — the
|
|
23
|
+
rules that own the very files that PR was editing — under a heading
|
|
24
|
+
that claims "Owned paths in THIS project". A brief that hides the
|
|
25
|
+
rules that apply is the incident, re-delivered as its own fix.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import yaml
|
|
35
|
+
|
|
36
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
37
|
+
OWNERSHIP_YAML = REPO_ROOT / "config" / "agent-ownership.yaml"
|
|
38
|
+
ROSTER_JSON = REPO_ROOT / "config" / "agent-roster.json"
|
|
39
|
+
|
|
40
|
+
# Enough to state every rule that really applies to a project this size
|
|
41
|
+
# (arka-os itself renders 14). Anything beyond is COUNTED, never dropped.
|
|
42
|
+
_MAX_RULES = 16
|
|
43
|
+
_MAX_FIELD = 120
|
|
44
|
+
# `{` is a glob char too: the enforcer expands `{core,dashboard}` braces,
|
|
45
|
+
# so treating that segment as a literal spine would make the brief DROP a
|
|
46
|
+
# rule the gate enforces — the "brief hides an applicable rule" incident.
|
|
47
|
+
_GLOB_CHARS = ("*", "?", "[", "{")
|
|
48
|
+
# The brief lands in a system prompt. A pattern is operator-editable YAML
|
|
49
|
+
# (agent-ownership.yaml is `lead_allowed`), so a newline in it could forge
|
|
50
|
+
# an authority line — OWASP LLM01. Control bytes never reach the prompt.
|
|
51
|
+
_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load(path: Path) -> dict:
|
|
55
|
+
try:
|
|
56
|
+
text = path.read_text(encoding="utf-8")
|
|
57
|
+
except OSError:
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
data = (
|
|
61
|
+
json.loads(text) if path.suffix == ".json" else yaml.safe_load(text)
|
|
62
|
+
)
|
|
63
|
+
except (json.JSONDecodeError, yaml.YAMLError):
|
|
64
|
+
return {}
|
|
65
|
+
return data if isinstance(data, dict) else {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clean(value: str) -> str:
|
|
69
|
+
"""Strip control bytes and bound the length — prompt-injection guard."""
|
|
70
|
+
return _CONTROL_RE.sub("", str(value))[:_MAX_FIELD]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def deployed_agents(agents_dirs: list[Path] | None = None) -> set[str]:
|
|
74
|
+
"""Slugs the Task tool can actually dispatch on this machine."""
|
|
75
|
+
dirs = agents_dirs or [
|
|
76
|
+
Path.home() / ".claude" / "agents",
|
|
77
|
+
Path.cwd() / ".claude" / "agents",
|
|
78
|
+
]
|
|
79
|
+
found: set[str] = set()
|
|
80
|
+
for base in dirs:
|
|
81
|
+
try:
|
|
82
|
+
for md in base.glob("*.md"):
|
|
83
|
+
found.add(md.stem.removeprefix("arka-"))
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
return found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _spine(pattern: str) -> list[str]:
|
|
90
|
+
"""The literal path segments of a glob: `**/app/Http/Controllers/**`
|
|
91
|
+
-> ['app', 'Http', 'Controllers']; `**/*.vue` -> []."""
|
|
92
|
+
return [
|
|
93
|
+
part for part in pattern.split("/")
|
|
94
|
+
if part and not any(ch in part for ch in _GLOB_CHARS)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def rule_applies(pattern: str, root: Path) -> bool:
|
|
99
|
+
"""Does this rule govern anything in `root`?
|
|
100
|
+
|
|
101
|
+
The FULL spine must exist — checking only its first segment is what
|
|
102
|
+
made `**/app/Http/Controllers/**` look relevant here (a `dashboard/app`
|
|
103
|
+
directory exists) while the rules that actually govern the repo were
|
|
104
|
+
pushed out of the list. Extension-only rules always apply: they cost
|
|
105
|
+
one line and are honest ("write a .vue and frontend-dev owns it").
|
|
106
|
+
"""
|
|
107
|
+
spine = _spine(pattern)
|
|
108
|
+
if not spine:
|
|
109
|
+
return True
|
|
110
|
+
joined = Path(*spine)
|
|
111
|
+
try:
|
|
112
|
+
if (root / joined).exists():
|
|
113
|
+
return True
|
|
114
|
+
# One level down covers monorepo layouts (dashboard/app/…).
|
|
115
|
+
return any(
|
|
116
|
+
(child / joined).exists()
|
|
117
|
+
for child in root.iterdir()
|
|
118
|
+
if child.is_dir() and not child.name.startswith(".")
|
|
119
|
+
)
|
|
120
|
+
except OSError:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def applicable_rules(rules: list[dict], root: Path) -> list[dict]:
|
|
125
|
+
return [
|
|
126
|
+
rule for rule in rules
|
|
127
|
+
if isinstance(rule, dict)
|
|
128
|
+
and rule.get("pattern")
|
|
129
|
+
and rule.get("owners")
|
|
130
|
+
and "*" not in rule["owners"]
|
|
131
|
+
and rule_applies(str(rule["pattern"]), root)
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _dispatch_status(named: set[str], owners_meta: dict,
|
|
136
|
+
dispatchable: set[str]) -> list[str]:
|
|
137
|
+
status: list[str] = []
|
|
138
|
+
for slug in sorted(named):
|
|
139
|
+
# agent-roster.json is operator-editable and this line lands in a
|
|
140
|
+
# system prompt — human_name walks through _clean like every other
|
|
141
|
+
# field (QG redo 2: it was the one field that did not).
|
|
142
|
+
tokens = _clean(owners_meta.get(slug, {}).get("human_name", "")).split()
|
|
143
|
+
label = f"{slug} ({tokens[0]})" if tokens else slug
|
|
144
|
+
status.append(
|
|
145
|
+
f"{label} ok" if slug in dispatchable else f"{label} MISSING"
|
|
146
|
+
)
|
|
147
|
+
return status
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render(cwd: str | Path | None = None,
|
|
151
|
+
agents_dirs: list[Path] | None = None) -> str:
|
|
152
|
+
"""The [ARKA:AUTHORITY] block, or "" when there is nothing to say."""
|
|
153
|
+
ownership = _load(OWNERSHIP_YAML)
|
|
154
|
+
roster = _load(ROSTER_JSON)
|
|
155
|
+
rules = ownership.get("ownership") or []
|
|
156
|
+
owners_meta = roster.get("gate_owners") or {}
|
|
157
|
+
if not isinstance(rules, list) or not owners_meta:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
root = Path(cwd) if cwd else Path.cwd()
|
|
161
|
+
relevant = applicable_rules(rules, root)
|
|
162
|
+
if not relevant:
|
|
163
|
+
return ""
|
|
164
|
+
shown, overflow = relevant[:_MAX_RULES], len(relevant) - _MAX_RULES
|
|
165
|
+
|
|
166
|
+
lines = [
|
|
167
|
+
"[ARKA:AUTHORITY] Write permissions (enforced by a PreToolUse gate)",
|
|
168
|
+
"1. Your last [arka:routing] / [arka:dispatch] marker is an "
|
|
169
|
+
"AUTHORIZATION TOKEN a gate reads before every Write/Edit. It is "
|
|
170
|
+
"not narration.",
|
|
171
|
+
"2. Owned paths in THIS project (first match wins):",
|
|
172
|
+
]
|
|
173
|
+
named: set[str] = set()
|
|
174
|
+
for rule in shown:
|
|
175
|
+
owners = [_clean(o) for o in rule["owners"]]
|
|
176
|
+
named.update(owners)
|
|
177
|
+
lines.append(
|
|
178
|
+
f" {_clean(rule['pattern']):<26s} -> {', '.join(owners)}"
|
|
179
|
+
)
|
|
180
|
+
if overflow > 0:
|
|
181
|
+
lines.append(
|
|
182
|
+
f" (+{overflow} more — see config/agent-ownership.yaml)"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
status = _dispatch_status(named, owners_meta, deployed_agents(agents_dirs))
|
|
186
|
+
lines.append(
|
|
187
|
+
f"3. Dispatchable here (Task subagent_type): {', '.join(status)}"
|
|
188
|
+
)
|
|
189
|
+
if any(entry.endswith("MISSING") for entry in status):
|
|
190
|
+
lines.append(" MISSING -> run `npx arkaos update` (never bypass).")
|
|
191
|
+
lines.append(
|
|
192
|
+
"4. The dispatched specialist writes with NO block. Retrying a "
|
|
193
|
+
"blocked Write never works — dispatch instead."
|
|
194
|
+
)
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> int:
|
|
199
|
+
brief = render()
|
|
200
|
+
print(brief or "(no ownership rules apply to this directory)")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
raise SystemExit(main())
|
|
@@ -68,6 +68,55 @@ def _resolve_source(slug: str) -> Path | None:
|
|
|
68
68
|
return None
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _agent_yaml(slug: str) -> Path | None:
|
|
72
|
+
matches = sorted(DEPARTMENTS_DIR.glob(f"*/agents/**/{slug}.yaml"))
|
|
73
|
+
return matches[0] if matches else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _human_name(slug: str) -> str:
|
|
77
|
+
source = _agent_yaml(slug)
|
|
78
|
+
if source is None:
|
|
79
|
+
return ""
|
|
80
|
+
try:
|
|
81
|
+
data = yaml.safe_load(source.read_text(encoding="utf-8"))
|
|
82
|
+
except yaml.YAMLError:
|
|
83
|
+
return ""
|
|
84
|
+
return str(data.get("name") or "") if isinstance(data, dict) else ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _first_name(human: str) -> str:
|
|
88
|
+
"""'Andre — Backend Core Lead' -> 'andre'."""
|
|
89
|
+
return human.split()[0].split("—")[0].strip().lower() if human else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_aliases(entries: dict[str, dict]) -> dict[str, str]:
|
|
93
|
+
"""First-name -> owner slug, resolved WITHIN the gate-owner set.
|
|
94
|
+
|
|
95
|
+
Sessions route by human name (``[arka:dispatch] paulo -> diana``) but
|
|
96
|
+
ownership rules name slugs (``frontend-dev``). Unresolved, that
|
|
97
|
+
mismatch blocked the RIGHT specialist from her own files: 22 of 189
|
|
98
|
+
measured blocks. (The 22 `senior-dev` blocks are a separate, still
|
|
99
|
+
open class: there the slug IS the persona, so no alias helps.)
|
|
100
|
+
|
|
101
|
+
Scoping to gate owners is what makes this safe. Globally, ``diana``
|
|
102
|
+
is ambiguous (frontend-dev AND hr-specialist) and so is ``andre``
|
|
103
|
+
(backend-dev AND growth-engineer) — a naive first-name alias would
|
|
104
|
+
trade one bug for a worse one. Among the owners the gate can
|
|
105
|
+
actually demand, both are unique. Anything still ambiguous here is
|
|
106
|
+
REFUSED, never guessed.
|
|
107
|
+
"""
|
|
108
|
+
by_first: dict[str, set[str]] = {}
|
|
109
|
+
for slug in entries:
|
|
110
|
+
first = _first_name(_human_name(slug))
|
|
111
|
+
if first:
|
|
112
|
+
by_first.setdefault(first, set()).add(slug)
|
|
113
|
+
return {
|
|
114
|
+
first: next(iter(slugs))
|
|
115
|
+
for first, slugs in sorted(by_first.items())
|
|
116
|
+
if len(slugs) == 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
71
120
|
def build_roster() -> dict:
|
|
72
121
|
owners = gate_owners()
|
|
73
122
|
entries: dict[str, dict] = {}
|
|
@@ -80,6 +129,7 @@ def build_roster() -> dict:
|
|
|
80
129
|
entries[slug] = {
|
|
81
130
|
"source": str(source.relative_to(REPO_ROOT)),
|
|
82
131
|
"compiled": source.parent == COMPILED_DIR,
|
|
132
|
+
"human_name": _human_name(slug),
|
|
83
133
|
}
|
|
84
134
|
if ghosts:
|
|
85
135
|
raise ValueError(
|
|
@@ -88,6 +138,14 @@ def build_roster() -> dict:
|
|
|
88
138
|
f"config/agent-ownership.yaml; a gate must never demand an "
|
|
89
139
|
f"owner nobody can invoke"
|
|
90
140
|
)
|
|
141
|
+
aliases = build_aliases(entries)
|
|
142
|
+
ambiguous = sorted(
|
|
143
|
+
first
|
|
144
|
+
for first in {
|
|
145
|
+
_first_name(e["human_name"]) for e in entries.values()
|
|
146
|
+
}
|
|
147
|
+
if first and first not in aliases
|
|
148
|
+
)
|
|
91
149
|
return {
|
|
92
150
|
"_meta": {
|
|
93
151
|
"generator": "core/agents/roster_manifest.py",
|
|
@@ -97,6 +155,8 @@ def build_roster() -> dict:
|
|
|
97
155
|
),
|
|
98
156
|
},
|
|
99
157
|
"gate_owners": entries,
|
|
158
|
+
"aliases": aliases,
|
|
159
|
+
"ambiguous_first_names": ambiguous,
|
|
100
160
|
}
|
|
101
161
|
|
|
102
162
|
|
|
@@ -26,6 +26,7 @@ the specialist and flow gates via their ``messages=`` params.
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
import contextlib
|
|
29
30
|
import sys
|
|
30
31
|
from pathlib import Path
|
|
31
32
|
|
|
@@ -65,19 +66,26 @@ class _MessagesOnce:
|
|
|
65
66
|
def __init__(self, transcript_path: str):
|
|
66
67
|
self._path = transcript_path
|
|
67
68
|
self._messages: list[str] | None = None
|
|
69
|
+
self._sidechain: bool | None = None
|
|
68
70
|
|
|
69
71
|
def peek(self) -> list[str] | None:
|
|
70
72
|
return self._messages
|
|
71
73
|
|
|
74
|
+
def sidechain_active(self) -> bool | None:
|
|
75
|
+
"""Scope of the most recent assistant record; None before load()."""
|
|
76
|
+
return self._sidechain
|
|
77
|
+
|
|
72
78
|
def load(self) -> list[str] | None:
|
|
73
79
|
if self._messages is None:
|
|
74
80
|
try:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
+
# Scope-aware since P0.2: the window counts MAIN-scope
|
|
82
|
+
# messages only, so interleaved subagent records cannot
|
|
83
|
+
# evict the routing marker; sidechain-ness feeds the
|
|
84
|
+
# specialist gate's persistence guard.
|
|
85
|
+
from core.workflow.transcript_scope import split_from_path
|
|
86
|
+
split = split_from_path(self._path)
|
|
87
|
+
self._messages = split.main[-_ASSISTANT_WINDOW:]
|
|
88
|
+
self._sidechain = split.active_sidechain
|
|
81
89
|
except Exception:
|
|
82
90
|
return None
|
|
83
91
|
return self._messages
|
|
@@ -104,10 +112,8 @@ def _kb_gate(root: str, tool_name: str, session_id: str, query: str) -> int | No
|
|
|
104
112
|
decision = evaluate_research_gate(
|
|
105
113
|
tool_name=tool_name, session_id=session_id, query=query
|
|
106
114
|
)
|
|
107
|
-
|
|
115
|
+
with contextlib.suppress(Exception):
|
|
108
116
|
record_telemetry(session_id=session_id, tool=tool_name, decision=decision)
|
|
109
|
-
except Exception:
|
|
110
|
-
pass
|
|
111
117
|
if not decision.allow:
|
|
112
118
|
return _deny(decision.to_stderr_message())
|
|
113
119
|
if decision.nudge and decision.to_stderr_message():
|
|
@@ -150,8 +156,9 @@ def _specialist_gate(
|
|
|
150
156
|
cwd=cwd,
|
|
151
157
|
tool_input=tool_input,
|
|
152
158
|
messages=shared,
|
|
159
|
+
is_sidechain=messages.sidechain_active(),
|
|
153
160
|
)
|
|
154
|
-
|
|
161
|
+
with contextlib.suppress(Exception):
|
|
155
162
|
record_telemetry(
|
|
156
163
|
session_id=session_id,
|
|
157
164
|
tool=tool_name,
|
|
@@ -160,8 +167,6 @@ def _specialist_gate(
|
|
|
160
167
|
target_file=str(tool_input.get("file_path", "")),
|
|
161
168
|
model_requested=str(tool_input.get("model", "")),
|
|
162
169
|
)
|
|
163
|
-
except Exception:
|
|
164
|
-
pass
|
|
165
170
|
if not decision.allow:
|
|
166
171
|
return _deny(decision.to_stderr_message())
|
|
167
172
|
return None
|
|
@@ -206,10 +211,8 @@ def _frontend_gate(
|
|
|
206
211
|
tool_input=tool_input,
|
|
207
212
|
messages=messages.load(),
|
|
208
213
|
)
|
|
209
|
-
|
|
214
|
+
with contextlib.suppress(Exception):
|
|
210
215
|
record_telemetry(session_id=session_id, tool=tool_name, decision=decision)
|
|
211
|
-
except Exception:
|
|
212
|
-
pass
|
|
213
216
|
if not decision.allow:
|
|
214
217
|
return _deny(decision.to_stderr_message())
|
|
215
218
|
if decision.to_stderr_message():
|
|
@@ -259,12 +262,10 @@ def _flow_gate(
|
|
|
259
262
|
# env bypass, classifier flag, marker cache) before reading.
|
|
260
263
|
messages=messages.peek(),
|
|
261
264
|
)
|
|
262
|
-
|
|
265
|
+
with contextlib.suppress(Exception):
|
|
263
266
|
record_telemetry(
|
|
264
267
|
session_id=session_id, tool=tool_name, decision=decision, cwd=cwd
|
|
265
268
|
)
|
|
266
|
-
except Exception:
|
|
267
|
-
pass
|
|
268
269
|
if decision.allow:
|
|
269
270
|
# Grace path: allow, but surface the non-blocking warning so the
|
|
270
271
|
# operator sees the routing nudge instead of a silent pass.
|
|
@@ -326,4 +327,4 @@ if __name__ == "__main__":
|
|
|
326
327
|
except Exception:
|
|
327
328
|
# Fail open — the bash version piped every heredoc through
|
|
328
329
|
# `2>/dev/null` and exited 0 on internal errors.
|
|
329
|
-
raise SystemExit(0)
|
|
330
|
+
raise SystemExit(0) from None
|
|
@@ -242,6 +242,30 @@ def _ensure_dashboard(repo: str, config: dict) -> None:
|
|
|
242
242
|
)
|
|
243
243
|
|
|
244
244
|
|
|
245
|
+
def _authority_brief(cwd: str) -> str:
|
|
246
|
+
"""[ARKA:AUTHORITY] — who may write what, BEFORE the first Write.
|
|
247
|
+
|
|
248
|
+
The 2026-07-12 incident happened because this information existed
|
|
249
|
+
nowhere in the prompt surface: a session met the ownership rule for
|
|
250
|
+
the first time in a deny message, and rationalized a bypass instead
|
|
251
|
+
of dispatching. Generated per project, never hand-typed.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
from core.agents.authority_brief import render
|
|
255
|
+
brief = render(cwd)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
# But it must not fail SILENTLY either: a swallowed error here
|
|
258
|
+
# restores the exact pre-incident state (a session writing with no
|
|
259
|
+
# idea the rules exist). One honest line beats a silent void.
|
|
260
|
+
return (
|
|
261
|
+
f"\n\n[ARKA:AUTHORITY] unavailable ({type(exc).__name__}) — "
|
|
262
|
+
f"write rules could not be rendered; check "
|
|
263
|
+
f"config/agent-ownership.yaml. Dispatch the owning specialist "
|
|
264
|
+
f"rather than assuming you may write."
|
|
265
|
+
)
|
|
266
|
+
return f"\n\n{brief}" if brief else ""
|
|
267
|
+
|
|
268
|
+
|
|
245
269
|
def build_message(cwd: str) -> str:
|
|
246
270
|
repo = repo_path()
|
|
247
271
|
config = _config()
|
|
@@ -254,6 +278,7 @@ def build_message(cwd: str) -> str:
|
|
|
254
278
|
msg += _drift(version)
|
|
255
279
|
msg += _EVIDENCE_CONTRACT
|
|
256
280
|
msg += _META_TAG_CONTRACT
|
|
281
|
+
msg += _authority_brief(cwd)
|
|
257
282
|
msg += _model_fabric()
|
|
258
283
|
_trigger_reorganizer(repo, config)
|
|
259
284
|
_ensure_dashboard(repo, config)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Persistent per-session persona — closes the specialist gate fail-open.
|
|
2
|
+
|
|
3
|
+
The specialist gate reads the persona from the last ``[arka:routing]`` /
|
|
4
|
+
``[arka:dispatch]`` marker in a 20-message window. When the marker rolls
|
|
5
|
+
out of the window the gate returned ALLOW (``no-routing-tag``) — 4,093
|
|
6
|
+
of 5,683 telemetry records (72%). Routing once and hammering the gate
|
|
7
|
+
with 20 messages of noise was a winning strategy.
|
|
8
|
+
|
|
9
|
+
Fix mirrors ``design_authorization`` (#297, frontend gate): **persist on
|
|
10
|
+
observe, consult before allow**. Whenever ``evaluate`` sees a marker in
|
|
11
|
+
the window it confirms the resolved persona here; when the window shows
|
|
12
|
+
nothing, a valid persisted persona for the session decides exactly as if
|
|
13
|
+
the marker were still visible — including deciding BLOCK.
|
|
14
|
+
|
|
15
|
+
Two deliberate properties:
|
|
16
|
+
|
|
17
|
+
- **The record is keyed by session_id AND the transcript file's name.**
|
|
18
|
+
A dispatched subagent runs on its own transcript (ADR
|
|
19
|
+
2026-05-28-specialist-dispatch-subagent-blindspot), so it can never
|
|
20
|
+
inherit the parent's persisted persona and be blocked from the very
|
|
21
|
+
files it was dispatched to write — even if it shares the session_id.
|
|
22
|
+
- **A session that never routed stays un-restored** (``never-routed``
|
|
23
|
+
keeps today's ALLOW): this module only closes the eviction hole; the
|
|
24
|
+
hard flip for never-routed sessions is a separate, telemetry-gated
|
|
25
|
+
decision.
|
|
26
|
+
|
|
27
|
+
State dir: ``/tmp/arkaos-specialist-auth`` (override via
|
|
28
|
+
``ARKA_SPECIALIST_AUTH_DIR``).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import contextlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import time
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
40
|
+
from core.shared.temp_paths import arkaos_temp_dir
|
|
41
|
+
|
|
42
|
+
_safe_session_id = _safe_session_id_module.safe_session_id
|
|
43
|
+
|
|
44
|
+
# A working session; a stale /tmp record expires on its own.
|
|
45
|
+
DEFAULT_TTL_SECONDS = 12 * 60 * 60
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _base_dir() -> Path:
|
|
49
|
+
override = os.environ.get("ARKA_SPECIALIST_AUTH_DIR", "").strip()
|
|
50
|
+
return Path(override) if override else arkaos_temp_dir(
|
|
51
|
+
"arkaos-specialist-auth"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _auth_path(session_id: str) -> Path | None:
|
|
56
|
+
safe = _safe_session_id(session_id)
|
|
57
|
+
return (_base_dir() / f"{safe}.json") if safe else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read(path: Path | None) -> dict:
|
|
61
|
+
if path is None or not path.exists():
|
|
62
|
+
return {}
|
|
63
|
+
try:
|
|
64
|
+
# errors="replace", like transcript_scope: a non-UTF8 byte in an
|
|
65
|
+
# operator-writable /tmp file must degrade to "no record", never
|
|
66
|
+
# raise through evaluate() into the hook's exit-0 (QG redo 3 —
|
|
67
|
+
# an uncaught UnicodeDecodeError WAS a silent gate bypass).
|
|
68
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
69
|
+
data = json.loads(raw)
|
|
70
|
+
except (ValueError, OSError): # JSONDecodeError/UnicodeDecodeError ⊂ ValueError
|
|
71
|
+
return {}
|
|
72
|
+
return data if isinstance(data, dict) else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def confirm(
|
|
76
|
+
session_id: str,
|
|
77
|
+
transcript_path: str,
|
|
78
|
+
persona: str,
|
|
79
|
+
marker: str,
|
|
80
|
+
persona_raw: str | None = None,
|
|
81
|
+
alias_resolved: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Persist the persona observed in the window (marker seen)."""
|
|
84
|
+
if not persona or not transcript_path:
|
|
85
|
+
return
|
|
86
|
+
path = _auth_path(session_id)
|
|
87
|
+
if path is None:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
path.write_text(
|
|
92
|
+
json.dumps({
|
|
93
|
+
"persona": persona,
|
|
94
|
+
"marker": marker,
|
|
95
|
+
"persona_raw": persona_raw,
|
|
96
|
+
"alias_resolved": bool(alias_resolved),
|
|
97
|
+
"transcript": Path(transcript_path).name,
|
|
98
|
+
"confirmed_ts": time.time(),
|
|
99
|
+
}),
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass # authorization state must never block the hook
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def confirmed(
|
|
107
|
+
session_id: str,
|
|
108
|
+
transcript_path: str,
|
|
109
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
110
|
+
) -> dict | None:
|
|
111
|
+
"""Return the persisted persona record, or None.
|
|
112
|
+
|
|
113
|
+
None unless: record exists, TTL holds, the persona is non-empty AND
|
|
114
|
+
the record was written for THIS transcript file — the property that
|
|
115
|
+
keeps a parent's persona out of a subagent's evaluation.
|
|
116
|
+
"""
|
|
117
|
+
if not transcript_path:
|
|
118
|
+
return None
|
|
119
|
+
data = _read(_auth_path(session_id))
|
|
120
|
+
ts = data.get("confirmed_ts")
|
|
121
|
+
# isinstance, not float(): a string in a corrupt-but-valid JSON would
|
|
122
|
+
# raise straight through evaluate() — the same silent-bypass class as
|
|
123
|
+
# the non-UTF8 hole (QG redo 3).
|
|
124
|
+
if not isinstance(ts, (int, float)) or time.time() - ts > ttl_seconds:
|
|
125
|
+
return None
|
|
126
|
+
if data.get("transcript") != Path(transcript_path).name:
|
|
127
|
+
return None
|
|
128
|
+
persona = data.get("persona")
|
|
129
|
+
if not isinstance(persona, str) or not persona:
|
|
130
|
+
return None
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def clear(session_id: str) -> None:
|
|
135
|
+
"""Remove authorization state for a session (tests / session end)."""
|
|
136
|
+
path = _auth_path(session_id)
|
|
137
|
+
if path is None:
|
|
138
|
+
return
|
|
139
|
+
with contextlib.suppress(FileNotFoundError, OSError):
|
|
140
|
+
path.unlink()
|
|
@@ -13,29 +13,42 @@ Used bypasses are logged to telemetry for accountability.
|
|
|
13
13
|
Feature flag: `hooks.specialistEnforcement` in ~/.arkaos/config.json.
|
|
14
14
|
|
|
15
15
|
Architectural note (per ADR 2026-05-28-specialist-dispatch-subagent-
|
|
16
|
-
blindspot): the enforcer is a NEGATIVE gate on the
|
|
17
|
-
only. Subagent writes pass through
|
|
16
|
+
blindspot, amended by P0.2): the enforcer is a NEGATIVE gate on the
|
|
17
|
+
parent transcript only. Subagent writes pass through because Claude
|
|
18
18
|
Code isolates subagent transcripts from the parent. The positive
|
|
19
19
|
`owner-match` path is exercised when the parent emits `[arka:dispatch]`
|
|
20
20
|
inline (e.g., the orchestrator impersonating a specialist) and remains
|
|
21
21
|
for forward compatibility if parent-transcript visibility ever ships.
|
|
22
22
|
|
|
23
|
+
Fail-open closed (P0.2): a marker that rolls out of the 20-message
|
|
24
|
+
window no longer reopens the gate — the persona observed in the window
|
|
25
|
+
is persisted per session+transcript (specialist_authorization) and
|
|
26
|
+
restored when the window shows nothing, deciding exactly as if the
|
|
27
|
+
marker were visible. The old blanket `no-routing-tag` (72% of all
|
|
28
|
+
telemetry) splits into `never-routed` (session never emitted a marker;
|
|
29
|
+
still ALLOW — hardening THAT is a separate, telemetry-gated decision)
|
|
30
|
+
and `subagent-scope` (sidechain evaluation; ADR behavior, now
|
|
31
|
+
measurable). The window itself counts main-scope messages only
|
|
32
|
+
(transcript_scope), so interleaved subagent records cannot evict the
|
|
33
|
+
marker.
|
|
34
|
+
|
|
23
35
|
Read by: config/hooks/pre-tool-use.sh between the KB-gate and the
|
|
24
36
|
flow-gate. Same Decision JSON contract as core.workflow.flow_enforcer.
|
|
25
37
|
"""
|
|
26
38
|
|
|
39
|
+
import contextlib
|
|
27
40
|
import json
|
|
28
41
|
import re
|
|
29
42
|
from contextlib import contextmanager
|
|
30
43
|
from dataclasses import asdict, dataclass, field
|
|
31
|
-
from datetime import
|
|
44
|
+
from datetime import UTC, datetime
|
|
32
45
|
from functools import lru_cache
|
|
33
46
|
from pathlib import Path
|
|
34
47
|
|
|
35
48
|
import yaml
|
|
36
49
|
|
|
37
50
|
from core.shared import safe_session_id as _safe_session_id_module
|
|
38
|
-
from core.workflow
|
|
51
|
+
from core.workflow import specialist_authorization, transcript_scope
|
|
39
52
|
|
|
40
53
|
try:
|
|
41
54
|
import fcntl # POSIX only
|
|
@@ -55,10 +68,8 @@ def _locked_append(path: Path):
|
|
|
55
68
|
yield fh
|
|
56
69
|
finally:
|
|
57
70
|
if _HAS_FLOCK:
|
|
58
|
-
|
|
71
|
+
with contextlib.suppress(OSError):
|
|
59
72
|
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
60
|
-
except OSError:
|
|
61
|
-
pass
|
|
62
73
|
fh.close()
|
|
63
74
|
|
|
64
75
|
|
|
@@ -73,6 +84,11 @@ OWNERSHIP_YAML_PATH = (
|
|
|
73
84
|
/ "config"
|
|
74
85
|
/ "agent-ownership.yaml"
|
|
75
86
|
)
|
|
87
|
+
ROSTER_PATH = (
|
|
88
|
+
Path(__file__).resolve().parent.parent.parent
|
|
89
|
+
/ "config"
|
|
90
|
+
/ "agent-roster.json"
|
|
91
|
+
)
|
|
76
92
|
|
|
77
93
|
GATED_TOOLS: frozenset[str] = frozenset(
|
|
78
94
|
{"Write", "Edit", "MultiEdit", "NotebookEdit"}
|
|
@@ -85,8 +101,13 @@ ROUTING_RE = re.compile(
|
|
|
85
101
|
DISPATCH_RE = re.compile(
|
|
86
102
|
r"\[arka:dispatch\]\s*[\w-]+\s*->\s*([\w-]+)", re.IGNORECASE
|
|
87
103
|
)
|
|
104
|
+
# Single-line, length-bounded body: the lazy `([^\]]+?)\s*` form
|
|
105
|
+
# backtracked catastrophically on an unclosed marker (4000 trailing
|
|
106
|
+
# spaces = 22s) — inside a BLOCKING PreToolUse hook, on model-emitted
|
|
107
|
+
# text. 400 chars is far above any substantive reason; beyond it the
|
|
108
|
+
# marker simply does not parse and the gate stays closed.
|
|
88
109
|
BYPASS_RE = re.compile(
|
|
89
|
-
r"\[arka:specialist-bypass\s+([^\]]
|
|
110
|
+
r"\[arka:specialist-bypass\s+([^\]\n]{1,400})\]", re.IGNORECASE
|
|
90
111
|
)
|
|
91
112
|
|
|
92
113
|
ASSISTANT_WINDOW = 20
|
|
@@ -107,22 +128,50 @@ class Decision:
|
|
|
107
128
|
bypass_used: bool = False
|
|
108
129
|
bypass_reason: str | None = None
|
|
109
130
|
target_file: str | None = None
|
|
131
|
+
# Alias resolution (PR-1): the marker said "diana", the rules say
|
|
132
|
+
# "frontend-dev" — record both so telemetry can prove the fix.
|
|
133
|
+
persona_raw: str | None = None
|
|
134
|
+
alias_resolved: bool = False
|
|
135
|
+
# Fail-open taxonomy (P0.2): where the persona came from ("window" |
|
|
136
|
+
# "persisted" | ""), whether the ACTIVE scope is a sidechain, and how
|
|
137
|
+
# far the marker sits from the window's end (None = not in window).
|
|
138
|
+
persona_source: str = ""
|
|
139
|
+
is_sidechain: bool = False
|
|
140
|
+
msgs_since_marker: int | None = None
|
|
110
141
|
|
|
111
142
|
def to_stderr_message(self) -> str:
|
|
143
|
+
"""The message a blocked session reads.
|
|
144
|
+
|
|
145
|
+
Rewritten after the 2026-07-12 incident, where a session read the
|
|
146
|
+
old text and went looking for a bug to justify a bypass. Three
|
|
147
|
+
changes carry the weight: it refuses the false diagnosis up front,
|
|
148
|
+
it hands over the correct path already filled in (two copyable
|
|
149
|
+
lines), and it demotes the bypass to what it is — an audited
|
|
150
|
+
exception the operator sees. The old text also called EVERY
|
|
151
|
+
persona "(lead)", so a blocked specialist was told she was a lead:
|
|
152
|
+
that actively fed the "the gate is buggy" story.
|
|
153
|
+
"""
|
|
112
154
|
if self.allow:
|
|
113
155
|
return ""
|
|
114
|
-
persona = self.current_persona or "
|
|
115
|
-
|
|
156
|
+
persona = self.current_persona or "unrouted"
|
|
157
|
+
owner = (self.required_owners or ["the owning specialist"])[0]
|
|
158
|
+
owners = ", ".join(self.required_owners) or "the owning specialist"
|
|
116
159
|
target = self.target_file or "this file"
|
|
117
160
|
return (
|
|
118
|
-
f"[ARKA:SPECIALIST] {
|
|
119
|
-
f"
|
|
120
|
-
f"
|
|
121
|
-
f"
|
|
122
|
-
f"the
|
|
123
|
-
f"
|
|
124
|
-
f"
|
|
125
|
-
f"
|
|
161
|
+
f"[ARKA:SPECIALIST] BLOCKED. {target} is owned by: {owners} "
|
|
162
|
+
f"— dispatch any ONE of them. You are {persona}.\n"
|
|
163
|
+
f"This is NOT a bug. The gate is working as designed. Retrying "
|
|
164
|
+
f"this Write will fail again, every time.\n"
|
|
165
|
+
f"DO THIS — two lines, and the specialist writes with no block:\n"
|
|
166
|
+
f" [arka:dispatch] {persona} -> {owner}\n"
|
|
167
|
+
f" Task(subagent_type=\"{owner}\", prompt=\"<what you were "
|
|
168
|
+
f"about to write>\")\n"
|
|
169
|
+
f"Audited exception (visible to the operator in the "
|
|
170
|
+
f"specialist-dispatch telemetry: `arka-py -m "
|
|
171
|
+
f"core.governance.specialist_telemetry_cli today`) — ONLY if "
|
|
172
|
+
f"no specialist can do this:\n"
|
|
173
|
+
f" [arka:specialist-bypass owner={owner} reason=<24+ chars "
|
|
174
|
+
f"explaining why a specialist cannot>]"
|
|
126
175
|
)
|
|
127
176
|
|
|
128
177
|
|
|
@@ -139,8 +188,15 @@ class _Ctx:
|
|
|
139
188
|
messages: list[str] = field(default_factory=list)
|
|
140
189
|
persona: str | None = None
|
|
141
190
|
marker: str | None = None
|
|
191
|
+
persona_raw: str | None = None
|
|
192
|
+
alias_resolved: bool = False
|
|
142
193
|
config: dict = field(default_factory=dict)
|
|
143
194
|
preloaded_messages: list[str] | None = None
|
|
195
|
+
persona_source: str = ""
|
|
196
|
+
# None = self-detect from the transcript; the consolidated hook passes
|
|
197
|
+
# the value it computed while parsing (parse-once contract, PR-6).
|
|
198
|
+
is_sidechain: bool | None = None
|
|
199
|
+
msgs_since_marker: int | None = None
|
|
144
200
|
|
|
145
201
|
|
|
146
202
|
# ─── Config + Ownership loaders ────────────────────────────────────────
|
|
@@ -233,34 +289,110 @@ def _glob_match(pattern: str, path: str) -> bool:
|
|
|
233
289
|
# ─── Persona, bypass, ownership resolution ─────────────────────────────
|
|
234
290
|
|
|
235
291
|
|
|
236
|
-
def
|
|
237
|
-
"""
|
|
292
|
+
def _load_aliases() -> dict[str, str]:
|
|
293
|
+
"""First-name -> owner slug, from the generated roster.
|
|
294
|
+
|
|
295
|
+
Sessions dispatch by human name (``-> diana``); ownership rules name
|
|
296
|
+
slugs (``frontend-dev``). Without this, the RIGHT specialist was
|
|
297
|
+
blocked from her own files — 22 of 189 measured blocks. The roster
|
|
298
|
+
only emits aliases that are unambiguous among gate owners, and never
|
|
299
|
+
guesses (core/agents/roster_manifest.py::build_aliases).
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
data = json.loads(ROSTER_PATH.read_text(encoding="utf-8"))
|
|
303
|
+
except (OSError, json.JSONDecodeError):
|
|
304
|
+
return {}
|
|
305
|
+
aliases = data.get("aliases")
|
|
306
|
+
return aliases if isinstance(aliases, dict) else {}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _normalize_persona(raw: str) -> tuple[str, bool]:
|
|
310
|
+
"""Return (slug, alias_resolved). Unknown names pass through."""
|
|
311
|
+
slug = _load_aliases().get(raw)
|
|
312
|
+
return (slug, True) if slug else (raw, False)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _resolve_persona(
|
|
316
|
+
messages: list[str],
|
|
317
|
+
) -> tuple[str | None, str | None, str | None, bool]:
|
|
318
|
+
"""Find the current persona: (slug, marker, raw, alias_resolved).
|
|
238
319
|
|
|
239
320
|
Dispatch tag wins over routing because dispatching is more specific.
|
|
321
|
+
The name is normalized through the roster aliases, so a marker that
|
|
322
|
+
names the human (``-> diana``) matches an ownership rule that names
|
|
323
|
+
the slug (``frontend-dev``).
|
|
240
324
|
"""
|
|
241
325
|
for text in reversed(messages):
|
|
242
326
|
dispatch = DISPATCH_RE.search(text)
|
|
243
327
|
if dispatch:
|
|
244
|
-
|
|
328
|
+
raw = dispatch.group(1).lower()
|
|
329
|
+
slug, resolved = _normalize_persona(raw)
|
|
330
|
+
return slug, "dispatch", raw, resolved
|
|
245
331
|
routing = ROUTING_RE.search(text)
|
|
246
332
|
if routing:
|
|
247
|
-
|
|
248
|
-
|
|
333
|
+
raw = routing.group(1).lower()
|
|
334
|
+
slug, resolved = _normalize_persona(raw)
|
|
335
|
+
return slug, "routing", raw, resolved
|
|
336
|
+
return None, None, None, False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# A bypass must cost more than doing the right thing. Before the
|
|
340
|
+
# 2026-07-12 incident the only barrier was a non-empty string, and the
|
|
341
|
+
# deny message advertised the bypass beside the dispatch as an equal
|
|
342
|
+
# option — the single place in the whole prompt surface that taught it.
|
|
343
|
+
_MIN_BYPASS_REASON = 24
|
|
344
|
+
_EMPTY_REASONS = (
|
|
345
|
+
"typo", "quick fix", "quickfix", "trivial", "urgent", "just this once",
|
|
346
|
+
"small change", "minor", "one char", "fast", "quick typo fix",
|
|
347
|
+
"just a quick typo fix", "just a quick typo fix honestly",
|
|
348
|
+
"nothing important", "it is faster this way",
|
|
349
|
+
)
|
|
350
|
+
# Structured form the deny message teaches: `owner=<slug> reason=<text>`.
|
|
351
|
+
_BYPASS_STRUCTURED_RE = re.compile(
|
|
352
|
+
r"^owner=(?P<owner>[\w-]+)\s+reason=(?P<reason>.*)$",
|
|
353
|
+
re.IGNORECASE | re.DOTALL,
|
|
354
|
+
)
|
|
249
355
|
|
|
250
356
|
|
|
251
|
-
def
|
|
252
|
-
"""
|
|
357
|
+
def _reason_is_substantive(reason: str) -> bool:
|
|
358
|
+
"""A reason must say something. QG redo 1 caught the hole: the floor
|
|
359
|
+
was applied to the WHOLE marker body, so
|
|
360
|
+
``owner=senior-dev reason=`` (24 chars of boilerplate, empty reason)
|
|
361
|
+
opened the gate — with the deny message teaching that very template.
|
|
362
|
+
The floor now applies to the reason ALONE."""
|
|
363
|
+
reason = reason.strip()
|
|
364
|
+
if len(reason) < _MIN_BYPASS_REASON:
|
|
365
|
+
return False
|
|
366
|
+
normalized = reason.lower().strip(" .!,;")
|
|
367
|
+
return normalized not in _EMPTY_REASONS
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _find_bypass(messages: list[str], owners: list[str] | None = None) -> str | None:
|
|
371
|
+
"""Return the bypass reason from the LAST assistant message, or None.
|
|
253
372
|
|
|
254
373
|
Scope is strict: only the immediately preceding assistant message can
|
|
255
|
-
grant a bypass.
|
|
374
|
+
grant a bypass. The structured form is required in spirit — a bare
|
|
375
|
+
legacy reason is still accepted for one release, but it must clear the
|
|
376
|
+
same substance floor. When ``owners`` is given, a structured
|
|
377
|
+
``owner=`` that names someone other than an actual owner is rejected:
|
|
378
|
+
a bypass may not invent its own justification target.
|
|
256
379
|
"""
|
|
257
380
|
if not messages:
|
|
258
381
|
return None
|
|
259
382
|
match = BYPASS_RE.search(messages[-1])
|
|
260
383
|
if not match:
|
|
261
384
|
return None
|
|
262
|
-
|
|
263
|
-
|
|
385
|
+
body = match.group(1).strip()
|
|
386
|
+
structured = _BYPASS_STRUCTURED_RE.match(body)
|
|
387
|
+
if structured:
|
|
388
|
+
if owners:
|
|
389
|
+
claimed = structured.group("owner").lower()
|
|
390
|
+
if claimed not in {o.lower() for o in owners}:
|
|
391
|
+
return None
|
|
392
|
+
reason = structured.group("reason")
|
|
393
|
+
else:
|
|
394
|
+
reason = body
|
|
395
|
+
return reason.strip() if _reason_is_substantive(reason) else None
|
|
264
396
|
|
|
265
397
|
|
|
266
398
|
def _match_ownership(
|
|
@@ -285,6 +417,15 @@ def _is_lead_allowed(file_path: str, patterns: list[str]) -> bool:
|
|
|
285
417
|
# ─── Pipeline stages (B1 refactor) ─────────────────────────────────────
|
|
286
418
|
|
|
287
419
|
|
|
420
|
+
def _scope_fields(ctx: _Ctx) -> dict:
|
|
421
|
+
"""The P0.2 taxonomy fields every post-populate Decision carries."""
|
|
422
|
+
return {
|
|
423
|
+
"persona_source": ctx.persona_source,
|
|
424
|
+
"is_sidechain": bool(ctx.is_sidechain),
|
|
425
|
+
"msgs_since_marker": ctx.msgs_since_marker,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
288
429
|
def _check_tool_gated(ctx: _Ctx) -> Decision | None:
|
|
289
430
|
if ctx.tool_name not in GATED_TOOLS:
|
|
290
431
|
return Decision(allow=True, reason="tool-not-gated")
|
|
@@ -297,8 +438,15 @@ def _check_feature_flag(ctx: _Ctx) -> Decision | None:
|
|
|
297
438
|
return None
|
|
298
439
|
|
|
299
440
|
|
|
441
|
+
def _msgs_since_marker(messages: list[str]) -> int | None:
|
|
442
|
+
for i, text in enumerate(reversed(messages)):
|
|
443
|
+
if DISPATCH_RE.search(text) or ROUTING_RE.search(text):
|
|
444
|
+
return i
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
300
448
|
def _populate_context(ctx: _Ctx) -> None:
|
|
301
|
-
"""Extract file_path, load ownership
|
|
449
|
+
"""Extract file_path, load ownership, resolve persona (P0.2 fail-open)."""
|
|
302
450
|
if ctx.tool_input and isinstance(ctx.tool_input, dict):
|
|
303
451
|
ctx.file_path = str(
|
|
304
452
|
ctx.tool_input.get("file_path")
|
|
@@ -309,16 +457,55 @@ def _populate_context(ctx: _Ctx) -> None:
|
|
|
309
457
|
if ctx.preloaded_messages is not None:
|
|
310
458
|
ctx.messages = ctx.preloaded_messages[-ASSISTANT_WINDOW:]
|
|
311
459
|
else:
|
|
312
|
-
|
|
313
|
-
|
|
460
|
+
split = transcript_scope.split_from_path(ctx.transcript_path)
|
|
461
|
+
ctx.messages = split.main[-ASSISTANT_WINDOW:]
|
|
462
|
+
if ctx.is_sidechain is None:
|
|
463
|
+
ctx.is_sidechain = split.active_sidechain
|
|
464
|
+
ctx.is_sidechain = bool(ctx.is_sidechain)
|
|
465
|
+
_resolve_or_restore_persona(ctx)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _resolve_or_restore_persona(ctx: _Ctx) -> None:
|
|
469
|
+
"""Persist-on-observe + consult-before-allow (P0.2, mirrors #297).
|
|
470
|
+
|
|
471
|
+
Marker in the window -> persist the resolved persona. Window empty of
|
|
472
|
+
markers -> a valid persisted persona for THIS session+transcript
|
|
473
|
+
decides as if the marker were still visible — including deciding
|
|
474
|
+
BLOCK. Sidechain evaluations never consult the parent's persistence
|
|
475
|
+
(the dispatched specialist must keep writing with no block, per the
|
|
476
|
+
ADR).
|
|
477
|
+
"""
|
|
478
|
+
ctx.persona, ctx.marker, ctx.persona_raw, ctx.alias_resolved = (
|
|
479
|
+
_resolve_persona(ctx.messages)
|
|
480
|
+
)
|
|
481
|
+
if ctx.persona is not None:
|
|
482
|
+
ctx.persona_source = "window"
|
|
483
|
+
ctx.msgs_since_marker = _msgs_since_marker(ctx.messages)
|
|
484
|
+
specialist_authorization.confirm(
|
|
485
|
+
ctx.session_id, ctx.transcript_path,
|
|
486
|
+
persona=ctx.persona, marker=ctx.marker or "",
|
|
487
|
+
persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
|
|
488
|
+
)
|
|
489
|
+
elif not ctx.is_sidechain:
|
|
490
|
+
restored = specialist_authorization.confirmed(
|
|
491
|
+
ctx.session_id, ctx.transcript_path
|
|
314
492
|
)
|
|
315
|
-
|
|
493
|
+
if restored:
|
|
494
|
+
ctx.persona = restored["persona"]
|
|
495
|
+
ctx.marker = restored.get("marker") or None
|
|
496
|
+
ctx.persona_raw = restored.get("persona_raw")
|
|
497
|
+
ctx.alias_resolved = bool(restored.get("alias_resolved"))
|
|
498
|
+
ctx.persona_source = "persisted"
|
|
316
499
|
|
|
317
500
|
|
|
318
501
|
def _check_no_persona(ctx: _Ctx) -> Decision | None:
|
|
319
502
|
if ctx.persona is None:
|
|
503
|
+
# Taxonomy (P0.2): the old blanket "no-routing-tag" (72% of all
|
|
504
|
+
# telemetry) splits into what actually happened.
|
|
505
|
+
reason = "subagent-scope" if ctx.is_sidechain else "never-routed"
|
|
320
506
|
return Decision(
|
|
321
|
-
allow=True, reason=
|
|
507
|
+
allow=True, reason=reason, target_file=ctx.file_path,
|
|
508
|
+
**_scope_fields(ctx),
|
|
322
509
|
)
|
|
323
510
|
return None
|
|
324
511
|
|
|
@@ -329,7 +516,7 @@ def _check_c_suite(ctx: _Ctx) -> Decision | None:
|
|
|
329
516
|
return Decision(
|
|
330
517
|
allow=True, reason="c-suite-override",
|
|
331
518
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
332
|
-
target_file=ctx.file_path,
|
|
519
|
+
target_file=ctx.file_path, **_scope_fields(ctx),
|
|
333
520
|
)
|
|
334
521
|
return None
|
|
335
522
|
|
|
@@ -340,7 +527,7 @@ def _check_lead_allowed_file(ctx: _Ctx) -> Decision | None:
|
|
|
340
527
|
return Decision(
|
|
341
528
|
allow=True, reason="lead-allowed-file",
|
|
342
529
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
343
|
-
target_file=ctx.file_path,
|
|
530
|
+
target_file=ctx.file_path, **_scope_fields(ctx),
|
|
344
531
|
)
|
|
345
532
|
return None
|
|
346
533
|
|
|
@@ -352,7 +539,7 @@ def _decide_open_access(
|
|
|
352
539
|
return Decision(
|
|
353
540
|
allow=True, reason=reason, current_persona=ctx.persona,
|
|
354
541
|
marker_found=ctx.marker, target_file=ctx.file_path,
|
|
355
|
-
required_owners=owners,
|
|
542
|
+
required_owners=owners, **_scope_fields(ctx),
|
|
356
543
|
)
|
|
357
544
|
|
|
358
545
|
|
|
@@ -360,7 +547,9 @@ def _decide_owner_match(ctx: _Ctx, owners: list[str]) -> Decision:
|
|
|
360
547
|
return Decision(
|
|
361
548
|
allow=True, reason=f"owner-match:{ctx.persona}",
|
|
362
549
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
363
|
-
target_file=ctx.file_path,
|
|
550
|
+
target_file=ctx.file_path,
|
|
551
|
+
persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
|
|
552
|
+
required_owners=owners, **_scope_fields(ctx),
|
|
364
553
|
)
|
|
365
554
|
|
|
366
555
|
|
|
@@ -368,8 +557,10 @@ def _decide_bypass(ctx: _Ctx, owners: list[str], reason: str) -> Decision:
|
|
|
368
557
|
return Decision(
|
|
369
558
|
allow=True, reason="bypass-with-reason",
|
|
370
559
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
371
|
-
target_file=ctx.file_path,
|
|
372
|
-
|
|
560
|
+
target_file=ctx.file_path,
|
|
561
|
+
persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
|
|
562
|
+
required_owners=owners, bypass_used=True, bypass_reason=reason,
|
|
563
|
+
**_scope_fields(ctx),
|
|
373
564
|
)
|
|
374
565
|
|
|
375
566
|
|
|
@@ -379,7 +570,9 @@ def _decide_block(ctx: _Ctx, owners: list[str]) -> Decision:
|
|
|
379
570
|
allow=False,
|
|
380
571
|
reason=f"lead-blocked:{ctx.persona}-not-in-[{','.join(owners_lower)}]",
|
|
381
572
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
382
|
-
target_file=ctx.file_path,
|
|
573
|
+
target_file=ctx.file_path,
|
|
574
|
+
persona_raw=ctx.persona_raw, alias_resolved=ctx.alias_resolved,
|
|
575
|
+
required_owners=owners, **_scope_fields(ctx),
|
|
383
576
|
)
|
|
384
577
|
|
|
385
578
|
|
|
@@ -389,7 +582,7 @@ def _resolve_with_owners(ctx: _Ctx, owners: list[str], rule_reason: str | None)
|
|
|
389
582
|
return _decide_open_access(ctx, owners, rule_reason)
|
|
390
583
|
if ctx.persona in {o.lower() for o in owners}:
|
|
391
584
|
return _decide_owner_match(ctx, owners)
|
|
392
|
-
bypass = _find_bypass(ctx.messages)
|
|
585
|
+
bypass = _find_bypass(ctx.messages, owners)
|
|
393
586
|
if bypass:
|
|
394
587
|
return _decide_bypass(ctx, owners, bypass)
|
|
395
588
|
return _decide_block(ctx, owners)
|
|
@@ -404,7 +597,7 @@ def _resolve_ownership_outcome(ctx: _Ctx) -> Decision:
|
|
|
404
597
|
return Decision(
|
|
405
598
|
allow=True, reason="no-ownership-rule",
|
|
406
599
|
current_persona=ctx.persona, marker_found=ctx.marker,
|
|
407
|
-
target_file=ctx.file_path,
|
|
600
|
+
target_file=ctx.file_path, **_scope_fields(ctx),
|
|
408
601
|
)
|
|
409
602
|
return _resolve_with_owners(ctx, owners, rule_reason)
|
|
410
603
|
|
|
@@ -419,16 +612,19 @@ def evaluate(
|
|
|
419
612
|
cwd: str = "",
|
|
420
613
|
tool_input: dict | None = None,
|
|
421
614
|
messages: list[str] | None = None,
|
|
615
|
+
is_sidechain: bool | None = None,
|
|
422
616
|
) -> Decision:
|
|
423
617
|
"""Decide whether a Write/Edit/MultiEdit/NotebookEdit may proceed.
|
|
424
618
|
|
|
425
619
|
``messages`` (PR-6 hook consolidation): pre-parsed assistant messages;
|
|
426
620
|
when None the transcript is read from ``transcript_path``.
|
|
621
|
+
``is_sidechain`` (P0.2): the active scope, when the caller computed it
|
|
622
|
+
while parsing; None self-detects (only possible without ``messages``).
|
|
427
623
|
"""
|
|
428
624
|
ctx = _Ctx(
|
|
429
625
|
tool_name=tool_name, transcript_path=transcript_path,
|
|
430
626
|
session_id=session_id, cwd=cwd, tool_input=tool_input or {},
|
|
431
|
-
preloaded_messages=messages,
|
|
627
|
+
preloaded_messages=messages, is_sidechain=is_sidechain,
|
|
432
628
|
)
|
|
433
629
|
for early_check in (_check_tool_gated, _check_feature_flag):
|
|
434
630
|
decision = early_check(ctx)
|
|
@@ -464,7 +660,7 @@ def record_telemetry(
|
|
|
464
660
|
if safe is None:
|
|
465
661
|
return
|
|
466
662
|
entry = {
|
|
467
|
-
"ts": datetime.now(
|
|
663
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
468
664
|
"session_id": safe,
|
|
469
665
|
"tool": tool,
|
|
470
666
|
"cwd": cwd,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Scope-aware transcript reading — the sidechain guard (specialist P0.2).
|
|
2
|
+
|
|
3
|
+
The specialist gate's 20-message window was counted over EVERY assistant
|
|
4
|
+
record in the JSONL. Two scope leaks follow:
|
|
5
|
+
|
|
6
|
+
- where a harness interleaves subagent (sidechain) records into the same
|
|
7
|
+
transcript, a burst of subagent activity evicts the routing marker and
|
|
8
|
+
reopens the fail-open the persisted-persona fix exists to close;
|
|
9
|
+
- the persisted persona itself must never cross scopes — a dispatched
|
|
10
|
+
specialist evaluated under the PARENT's persisted persona would be
|
|
11
|
+
blocked from the very files it was dispatched to write.
|
|
12
|
+
|
|
13
|
+
This module splits a transcript into main-scope and sidechain-scope
|
|
14
|
+
assistant messages and reports which scope is ACTIVE (the scope of the
|
|
15
|
+
most recent assistant record — at PreToolUse time, the agent whose tool
|
|
16
|
+
call is being judged). Parsing mirrors
|
|
17
|
+
``flow_enforcer._load_last_assistant_messages`` exactly; records without
|
|
18
|
+
an ``isSidechain`` field are main-scope, so a transcript that predates
|
|
19
|
+
the field behaves as before.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from core.workflow.flow_enforcer import _extract_text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ScopeSplit:
|
|
33
|
+
"""Assistant messages of a transcript, separated by scope."""
|
|
34
|
+
|
|
35
|
+
main: list[str] = field(default_factory=list)
|
|
36
|
+
sidechain: list[str] = field(default_factory=list)
|
|
37
|
+
active_sidechain: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def split_by_scope(raw_text: str) -> ScopeSplit:
|
|
41
|
+
"""Split raw JSONL transcript text into main/sidechain scopes."""
|
|
42
|
+
split = ScopeSplit()
|
|
43
|
+
for line in raw_text.splitlines():
|
|
44
|
+
if not line.strip():
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
record = json.loads(line)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
continue
|
|
50
|
+
role = record.get("role") or record.get("message", {}).get("role")
|
|
51
|
+
if role != "assistant":
|
|
52
|
+
continue
|
|
53
|
+
content = record.get("content")
|
|
54
|
+
if content is None:
|
|
55
|
+
content = record.get("message", {}).get("content")
|
|
56
|
+
text = _extract_text(content)
|
|
57
|
+
if not text:
|
|
58
|
+
continue
|
|
59
|
+
side = bool(record.get("isSidechain", False))
|
|
60
|
+
(split.sidechain if side else split.main).append(text)
|
|
61
|
+
split.active_sidechain = side
|
|
62
|
+
return split
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def split_from_path(transcript_path: str) -> ScopeSplit:
|
|
66
|
+
"""Read and split a transcript file; any failure is an empty split."""
|
|
67
|
+
path = Path(transcript_path) if transcript_path else None
|
|
68
|
+
if path is None or not path.exists():
|
|
69
|
+
return ScopeSplit()
|
|
70
|
+
try:
|
|
71
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
72
|
+
except OSError:
|
|
73
|
+
return ScopeSplit()
|
|
74
|
+
return split_by_scope(raw)
|
package/package.json
CHANGED