arkaos 2.31.0 → 2.40.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/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +76 -0
- package/arka/skills/checkpoint/SKILL.md +79 -0
- package/config/constitution.yaml +193 -0
- package/config/hooks/pre-tool-use.sh +18 -2
- package/config/hooks/session-start.sh +7 -0
- package/config/hooks/stop.sh +72 -0
- package/config/hooks/user-prompt-submit.sh +25 -0
- package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
- package/core/cognition/__pycache__/dreams_reader.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/governance/__init__.py +7 -1
- package/core/governance/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/dod_gate.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/kb_cite_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/learning_detector.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/sycophancy_detector.cpython-313.pyc +0 -0
- package/core/governance/dod_gate.py +192 -0
- package/core/governance/kb_cite_check.py +129 -0
- package/core/governance/learning_detector.py +221 -0
- package/core/governance/sycophancy_detector.py +169 -0
- package/core/orchestration/__pycache__/checkpoint.cpython-313.pyc +0 -0
- package/core/orchestration/checkpoint.py +157 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_provider.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/flow_enforcer.py +144 -3
- package/knowledge/commands-registry.json +1 -1
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/knowledge/commands-registry.json.bak +0 -2791
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Sycophancy detector for ArkaOS responses (Conclave Phase 5, PR13 v2.35.0).
|
|
2
|
+
|
|
3
|
+
Implements the *arkaos-not-yes-man* NON-NEGOTIABLE rule from PR10 constitution.
|
|
4
|
+
|
|
5
|
+
The detector is a heuristic — it scans an assistant response for known
|
|
6
|
+
sycophancy signals (agreement-without-critique, pure-agreement-standalone,
|
|
7
|
+
missing reference-companies citation when a recommendation is made,
|
|
8
|
+
missing critic step). Returns a structured verdict the Stop hook records
|
|
9
|
+
to telemetry (warn-only mode in v2.35.0) before promotion to hard
|
|
10
|
+
enforcement in a later PR.
|
|
11
|
+
|
|
12
|
+
This is NOT an LLM-based judgement. It is regex / heuristic. False
|
|
13
|
+
positives are acceptable when they nudge the assistant toward more
|
|
14
|
+
pushback; false negatives are the failure mode we measure and improve.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
|
|
22
|
+
# ─── Patterns ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# Sycophantic openers — agreement WITHOUT immediate critique follow-up.
|
|
25
|
+
# These trigger inspection of the rest of the response.
|
|
26
|
+
_AGREEMENT_OPENERS: tuple[re.Pattern, ...] = tuple(
|
|
27
|
+
re.compile(p, re.IGNORECASE | re.MULTILINE) for p in [
|
|
28
|
+
r"^\s*tens\s+raz[aã]o\b",
|
|
29
|
+
r"^\s*est[aá]s?\s+certo\b",
|
|
30
|
+
r"^\s*you[' ]re\s+right\b",
|
|
31
|
+
r"^\s*you\s+are\s+correct\b",
|
|
32
|
+
r"^\s*absolutely(?:[.,!]|\s)",
|
|
33
|
+
r"^\s*boa\s+ideia\b",
|
|
34
|
+
r"^\s*great\s+idea\b",
|
|
35
|
+
r"^\s*excelente\s+ideia\b",
|
|
36
|
+
r"^\s*perfeito[.,!]",
|
|
37
|
+
r"^\s*claro\s*[.,!]",
|
|
38
|
+
r"^\s*ok,?\s+vou\s+fazer\b",
|
|
39
|
+
r"^\s*ok,?\s+fazendo\b",
|
|
40
|
+
r"^\s*ok,?\s+a\s+fazer\b",
|
|
41
|
+
r"^\s*ok,?\s+seguindo\b",
|
|
42
|
+
r"^\s*sure[,.\s]",
|
|
43
|
+
r"^\s*of\s+course[,.\s]",
|
|
44
|
+
r"^\s*understood[,.\s]",
|
|
45
|
+
r"^\s*entendido[,.\s]",
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Critique connectors — if present after an agreement opener, the response
|
|
50
|
+
# isn't sycophantic (it's "yes, AND but here's the issue").
|
|
51
|
+
_CRITIQUE_CONNECTORS: tuple[re.Pattern, ...] = tuple(
|
|
52
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
53
|
+
r"\bmas\b", r"\bpor[eé]m\b", r"\bcontudo\b", r"\btodavia\b",
|
|
54
|
+
r"\bantes\s+de\s+(avan[cç]ar|fazer|continuar)\b",
|
|
55
|
+
r"\bbut\b", r"\bhowever\b", r"\balthough\b",
|
|
56
|
+
r"\bone\s+concern\b", r"\bone\s+issue\b", r"\bone\s+problem\b",
|
|
57
|
+
r"\bn[aã]o\s+vai\s+funcionar\b", r"\bnot\s+gonna\s+work\b",
|
|
58
|
+
r"\bbefore\s+(we|you|proceed|continue)\b",
|
|
59
|
+
r"\bantes\s+disso\b", r"\bo\s+problema\s+(é|aqui)\b",
|
|
60
|
+
r"\bthere[' ]s\s+a\s+problem\b",
|
|
61
|
+
r"\bissue\s+with\s+(this|your|the)\b",
|
|
62
|
+
r"\bestructur[ae]lly\s+(flawed|wrong)\b",
|
|
63
|
+
r"\bestruturalmente\s+(errad[oa]|com\s+falha)\b",
|
|
64
|
+
# Numerical / evidence cues
|
|
65
|
+
r"\b(?:Google|Stripe|SpaceX|Tesla|Anthropic|OpenAI|Linear|Notion|Vercel|Figma|Supabase|Apple|a16z|Stratechery)\b",
|
|
66
|
+
# Pushback verbs / phrases
|
|
67
|
+
r"\bdiscordo\b", r"\bdiscord[oa]\s+com\b",
|
|
68
|
+
r"\bI\s+disagree\b", r"\bdon[' ]t\s+agree\b",
|
|
69
|
+
# ArkaOS-internal tagged dissent or critic
|
|
70
|
+
r"\[arka:dissent\]", r"\[arka:critic\]",
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Pure-agreement standalone — short response that's just agreement.
|
|
75
|
+
_PURE_AGREEMENT_SHORT_RE = re.compile(
|
|
76
|
+
r"^\s*(?:sim|yes|claro|perfeito|certo|ok|tudo\s+certo)[.!]?\s*$",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Recommendation markers — verbs/phrases that signal "here's what to do".
|
|
81
|
+
_RECOMMENDATION_RE = re.compile(
|
|
82
|
+
r"\b(?:propon?h?o|recomendo|sugiro|deves|devias|implementa|faz|usa|escolhe|"
|
|
83
|
+
r"recommend|suggest|propose|should|build|implement|pick|choose|use)\b",
|
|
84
|
+
re.IGNORECASE,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Reference companies — required citation when recommendation is made.
|
|
88
|
+
_REFERENCE_COMPANIES = (
|
|
89
|
+
"Google", "Stripe", "SpaceX", "Tesla", "Anthropic", "OpenAI",
|
|
90
|
+
"Linear", "Vercel", "Supabase", "Notion", "Figma", "Apple",
|
|
91
|
+
"Raycast", "Arc", "Basecamp", "37signals", "a16z", "Stratechery",
|
|
92
|
+
"Damodaran", "ProfitWell", "Patrick Campbell",
|
|
93
|
+
)
|
|
94
|
+
_REFERENCE_RE = re.compile(
|
|
95
|
+
r"\b(?:" + "|".join(re.escape(c) for c in _REFERENCE_COMPANIES) + r")\b",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Critic verdict marker — emitted by self-critic step.
|
|
99
|
+
_CRITIC_RE = re.compile(r"critic\s*=\s*(passed|failed|skipped)", re.IGNORECASE)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class SycophancyVerdict:
|
|
104
|
+
"""Structured detector output."""
|
|
105
|
+
|
|
106
|
+
is_sycophantic: bool
|
|
107
|
+
signals: list[str] = field(default_factory=list)
|
|
108
|
+
confidence: float = 0.0
|
|
109
|
+
response_length: int = 0
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict:
|
|
112
|
+
return asdict(self)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def detect_sycophancy(text: str) -> SycophancyVerdict:
|
|
116
|
+
"""Inspect *text* and return a SycophancyVerdict.
|
|
117
|
+
|
|
118
|
+
Confidence scale:
|
|
119
|
+
0.0 — no signal at all (clean response)
|
|
120
|
+
0.3 — weak signal (agreement opener but with later critique)
|
|
121
|
+
0.6 — medium (recommendation without reference-company citation)
|
|
122
|
+
0.9 — strong (agreement opener + no critique connectors anywhere)
|
|
123
|
+
1.0 — pure-agreement standalone short reply to substantive input
|
|
124
|
+
"""
|
|
125
|
+
text_clean = (text or "").strip()
|
|
126
|
+
if not text_clean:
|
|
127
|
+
return SycophancyVerdict(is_sycophantic=False)
|
|
128
|
+
|
|
129
|
+
signals: list[str] = []
|
|
130
|
+
confidence = 0.0
|
|
131
|
+
|
|
132
|
+
# Signal 1 — pure-agreement-standalone short reply.
|
|
133
|
+
if len(text_clean) <= 40 and _PURE_AGREEMENT_SHORT_RE.match(text_clean):
|
|
134
|
+
return SycophancyVerdict(
|
|
135
|
+
is_sycophantic=True,
|
|
136
|
+
signals=["pure-agreement-standalone"],
|
|
137
|
+
confidence=1.0,
|
|
138
|
+
response_length=len(text_clean),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Signal 2 — agreement opener.
|
|
142
|
+
has_agreement_opener = any(p.search(text_clean) for p in _AGREEMENT_OPENERS)
|
|
143
|
+
if has_agreement_opener:
|
|
144
|
+
signals.append("agreement-opener")
|
|
145
|
+
# Look for critique connector elsewhere in the text.
|
|
146
|
+
has_critique = any(p.search(text_clean) for p in _CRITIQUE_CONNECTORS)
|
|
147
|
+
if not has_critique:
|
|
148
|
+
signals.append("missing-critique-connector")
|
|
149
|
+
confidence = max(confidence, 0.9)
|
|
150
|
+
else:
|
|
151
|
+
confidence = max(confidence, 0.3)
|
|
152
|
+
|
|
153
|
+
# Signal 3 — recommendation without reference-company citation.
|
|
154
|
+
if _RECOMMENDATION_RE.search(text_clean):
|
|
155
|
+
if not _REFERENCE_RE.search(text_clean):
|
|
156
|
+
signals.append("recommendation-without-reference-company")
|
|
157
|
+
confidence = max(confidence, 0.6)
|
|
158
|
+
|
|
159
|
+
# Signal 4 — missing critic verdict in substantive response (> 200 chars).
|
|
160
|
+
if len(text_clean) > 200 and not _CRITIC_RE.search(text_clean):
|
|
161
|
+
signals.append("missing-critic-verdict")
|
|
162
|
+
confidence = max(confidence, 0.4)
|
|
163
|
+
|
|
164
|
+
return SycophancyVerdict(
|
|
165
|
+
is_sycophantic=confidence >= 0.6,
|
|
166
|
+
signals=signals,
|
|
167
|
+
confidence=confidence,
|
|
168
|
+
response_length=len(text_clean),
|
|
169
|
+
)
|
|
Binary file
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Inter-agent checkpoint primitives (PR15 v2.37.0).
|
|
2
|
+
|
|
3
|
+
Implements the `inter-agent-checkpoints` NON-NEGOTIABLE rule from PR10:
|
|
4
|
+
long-running multi-agent dispatches are fragmented into sub-dispatches
|
|
5
|
+
of 2-3min each, and the orchestrator emits a proactive checkpoint
|
|
6
|
+
prompt between each sub-dispatch announcing the next step and inviting
|
|
7
|
+
user context injection.
|
|
8
|
+
|
|
9
|
+
This module ships PRIMITIVES — message builders, an injection parser,
|
|
10
|
+
and a planner that splits a task list into checkpoint-sized chunks.
|
|
11
|
+
The actual orchestration (calling Agent tools in sequence, pausing
|
|
12
|
+
between them) is a pattern the orchestrator follows, not code this
|
|
13
|
+
module runs.
|
|
14
|
+
|
|
15
|
+
Conclave Phase 5 brainstorm (2026-05-13) decided:
|
|
16
|
+
* Live interruption capability — user can add context mid-task
|
|
17
|
+
* Proactive checkpoint prompts between sub-dispatches
|
|
18
|
+
* Sub-dispatch budget: 2-3 minutes each
|
|
19
|
+
* Reviewer conflicts: strategic -> escalate, technical -> Marta decides
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
# Recommended sub-dispatch duration window (seconds).
|
|
29
|
+
SUB_DISPATCH_MIN_S = 60
|
|
30
|
+
SUB_DISPATCH_TARGET_S = 180 # 3 min sweet spot
|
|
31
|
+
SUB_DISPATCH_MAX_S = 300 # 5 min hard ceiling
|
|
32
|
+
|
|
33
|
+
# Threshold above which a task MUST be checkpointed.
|
|
34
|
+
CHECKPOINT_TRIGGER_S = 30
|
|
35
|
+
|
|
36
|
+
# Recognised user-injection cues (added context, not new turn / abort).
|
|
37
|
+
_INJECTION_CUES: tuple[re.Pattern, ...] = tuple(
|
|
38
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
39
|
+
r"\b(adiciona|considera tamb[eé]m|esquecia(-me)?|antes de|t[eê]m que|ainda|t[aâ]mbem)\b",
|
|
40
|
+
r"\b(also|btw|by the way|forgot to mention|one more thing|consider also)\b",
|
|
41
|
+
r"^[+]\s", # leading + prefix common shorthand for "add"
|
|
42
|
+
r"^\s*FYI[:\s]",
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Recognised abort / hard-stop cues (user wants to stop or redirect).
|
|
47
|
+
_ABORT_CUES: tuple[re.Pattern, ...] = tuple(
|
|
48
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
49
|
+
r"\b(stop|para|parar|abort|cancela|cancel|n[aã]o continues|wait)\b",
|
|
50
|
+
r"\b(redirect|muda|altera|change direction)\b",
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
InjectionKind = Literal["new-turn", "context-injection", "abort"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CheckpointPlan:
|
|
59
|
+
"""A fragmented multi-step work plan."""
|
|
60
|
+
|
|
61
|
+
task_name: str
|
|
62
|
+
sub_dispatches: list[str] = field(default_factory=list)
|
|
63
|
+
estimated_total_seconds: int = 0
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
return asdict(self)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class UserInjection:
|
|
71
|
+
"""Parsed verdict on a mid-checkpoint user message."""
|
|
72
|
+
|
|
73
|
+
kind: InjectionKind
|
|
74
|
+
matched_cues: list[str] = field(default_factory=list)
|
|
75
|
+
raw_text: str = ""
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_checkpoint_message(
|
|
82
|
+
next_dispatch_name: str,
|
|
83
|
+
estimated_seconds: int,
|
|
84
|
+
step: int,
|
|
85
|
+
total_steps: int,
|
|
86
|
+
context_carried_forward: str = "",
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Return the canonical [arka:checkpoint] message string.
|
|
89
|
+
|
|
90
|
+
Format::
|
|
91
|
+
|
|
92
|
+
[arka:checkpoint] Step S/T: next dispatch "<name>" — ~Xs.
|
|
93
|
+
Tens contexto a acrescentar antes de eu arrancar? (Silêncio = procedo.)
|
|
94
|
+
"""
|
|
95
|
+
s = max(1, int(step))
|
|
96
|
+
t = max(s, int(total_steps))
|
|
97
|
+
name = next_dispatch_name.strip() or "next sub-task"
|
|
98
|
+
seconds = max(0, int(estimated_seconds))
|
|
99
|
+
body = (
|
|
100
|
+
f"[arka:checkpoint] Step {s}/{t}: next dispatch \"{name}\" — ~{seconds}s.\n"
|
|
101
|
+
"Tens contexto a acrescentar antes de eu arrancar? (Silêncio = procedo.)"
|
|
102
|
+
)
|
|
103
|
+
if context_carried_forward:
|
|
104
|
+
body += f"\n (Carry-forward: {context_carried_forward.strip()})"
|
|
105
|
+
return body
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_user_injection(text: str) -> UserInjection:
|
|
109
|
+
"""Classify a user message arriving between checkpoints.
|
|
110
|
+
|
|
111
|
+
Returns an :class:`UserInjection` with kind in:
|
|
112
|
+
* ``"abort"`` — user wants to stop or redirect
|
|
113
|
+
* ``"context-injection"`` — user adds context to the current plan
|
|
114
|
+
* ``"new-turn"`` — anything else (treat as fresh request)
|
|
115
|
+
|
|
116
|
+
Abort detection wins over injection (cue order: abort first).
|
|
117
|
+
"""
|
|
118
|
+
stripped = (text or "").strip()
|
|
119
|
+
if not stripped:
|
|
120
|
+
return UserInjection(kind="new-turn", raw_text=stripped)
|
|
121
|
+
|
|
122
|
+
abort_hits = [p.pattern for p in _ABORT_CUES if p.search(stripped)]
|
|
123
|
+
if abort_hits:
|
|
124
|
+
return UserInjection(
|
|
125
|
+
kind="abort", matched_cues=abort_hits, raw_text=stripped
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
injection_hits = [p.pattern for p in _INJECTION_CUES if p.search(stripped)]
|
|
129
|
+
if injection_hits:
|
|
130
|
+
return UserInjection(
|
|
131
|
+
kind="context-injection",
|
|
132
|
+
matched_cues=injection_hits,
|
|
133
|
+
raw_text=stripped,
|
|
134
|
+
)
|
|
135
|
+
return UserInjection(kind="new-turn", raw_text=stripped)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def plan_fragmented_dispatches(
|
|
139
|
+
task_name: str, sub_tasks: list[str], per_task_seconds: int = SUB_DISPATCH_TARGET_S
|
|
140
|
+
) -> CheckpointPlan:
|
|
141
|
+
"""Build a CheckpointPlan from a list of sub-task names.
|
|
142
|
+
|
|
143
|
+
Each sub-task is one dispatch separated by a checkpoint prompt.
|
|
144
|
+
Caller is responsible for executing the sub-dispatches in order.
|
|
145
|
+
"""
|
|
146
|
+
cleaned: list[str] = [s.strip() for s in sub_tasks if s and s.strip()]
|
|
147
|
+
per = max(SUB_DISPATCH_MIN_S, min(int(per_task_seconds), SUB_DISPATCH_MAX_S))
|
|
148
|
+
return CheckpointPlan(
|
|
149
|
+
task_name=task_name.strip() or "unnamed-task",
|
|
150
|
+
sub_dispatches=cleaned,
|
|
151
|
+
estimated_total_seconds=len(cleaned) * per,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def should_checkpoint(estimated_seconds: int) -> bool:
|
|
156
|
+
"""Return True if the work is long enough to require a checkpoint."""
|
|
157
|
+
return int(estimated_seconds) > CHECKPOINT_TRIGGER_S
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -54,7 +54,135 @@ def _locked_append(path: Path):
|
|
|
54
54
|
pass
|
|
55
55
|
fh.close()
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
# PR11 v2.33.0 — Discovery vs Effect tool taxonomy (Conclave Phase 5).
|
|
58
|
+
#
|
|
59
|
+
# DISCOVERY tools (no routing required): Read, Grep, Glob, ToolSearch,
|
|
60
|
+
# the various read-only MCP tools (Obsidian search, claude-mem search,
|
|
61
|
+
# Context7 query), AskUserQuestion. These never mutate user state.
|
|
62
|
+
#
|
|
63
|
+
# EFFECT tools (routing required): tools below produce visible state
|
|
64
|
+
# changes — write to filesystem, dispatch agents, invoke skills,
|
|
65
|
+
# mutate the notebook. Each requires a flow marker
|
|
66
|
+
# ([arka:routing] or [arka:trivial]) in the recent assistant messages.
|
|
67
|
+
#
|
|
68
|
+
# Bash is special: command-by-command classification via bash_is_effect().
|
|
69
|
+
# Pure read commands (cat, ls, grep, git status, etc.) are DISCOVERY.
|
|
70
|
+
# Mutating commands (rm, mv, git commit/push, npm install, etc.) are
|
|
71
|
+
# EFFECT. Unknown commands default to EFFECT (safer).
|
|
72
|
+
|
|
73
|
+
EFFECT_TOOLS_ALWAYS: frozenset[str] = frozenset({
|
|
74
|
+
"Write", "Edit", "MultiEdit", "NotebookEdit",
|
|
75
|
+
"Task", "Skill", # Agent dispatch + skill invocation cascade to effect
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
# Backwards-compatible alias for callers that import GATED_TOOLS.
|
|
79
|
+
GATED_TOOLS: frozenset[str] = EFFECT_TOOLS_ALWAYS
|
|
80
|
+
|
|
81
|
+
# Bash classifier — whitelist of safe DISCOVERY first-tokens.
|
|
82
|
+
_BASH_DISCOVERY_FIRST: frozenset[str] = frozenset({
|
|
83
|
+
# File reading
|
|
84
|
+
"cat", "head", "tail", "less", "more", "tee",
|
|
85
|
+
# Directory + search
|
|
86
|
+
"ls", "find", "locate", "tree", "stat", "file",
|
|
87
|
+
# Text search / processing (read-only)
|
|
88
|
+
"grep", "ag", "rg", "wc", "sort", "uniq", "tr", "cut", "awk", "fmt",
|
|
89
|
+
# System info
|
|
90
|
+
"pwd", "whoami", "id", "hostname", "uname", "date", "df", "du",
|
|
91
|
+
"free", "uptime", "echo", "printf", "true", "false",
|
|
92
|
+
# Process info
|
|
93
|
+
"ps", "pgrep", "jobs",
|
|
94
|
+
# Tool version queries (no side-effect)
|
|
95
|
+
"which", "type", "command", "where",
|
|
96
|
+
# Common toolchain read-only entry points (subcommand checked below)
|
|
97
|
+
"git", "npm", "yarn", "pnpm", "pip", "pip3", "uv", "poetry",
|
|
98
|
+
"brew", "apt", "snap", "winget", "choco", "ollama", "docker",
|
|
99
|
+
"python", "python3", "node", "ruby", "php", "go", "rustc",
|
|
100
|
+
"curl", "wget", # default to read; mutation via flags below
|
|
101
|
+
# Shell builtins / control
|
|
102
|
+
"test", "[", "if", "while", "for", "case", "function", "return",
|
|
103
|
+
"exit", "source", ".", "set", "export", "alias", "unalias",
|
|
104
|
+
"shopt", "trap", "wait", "eval", "exec",
|
|
105
|
+
# Test runners (read state but don't mutate canonical files)
|
|
106
|
+
"pytest", "jest", "vitest", "phpunit", "pest", "rspec", "mocha",
|
|
107
|
+
"go", "cargo",
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
# Bash classifier — patterns that indicate mutation (anywhere in command).
|
|
111
|
+
_BASH_EFFECT_PATTERNS: tuple[re.Pattern, ...] = tuple(
|
|
112
|
+
re.compile(p) for p in [
|
|
113
|
+
# File-system mutation
|
|
114
|
+
r"(^|[\s|;&])rm\s", r"(^|[\s|;&])mv\s", r"(^|[\s|;&])cp\s+-[rRf]",
|
|
115
|
+
r"(^|[\s|;&])dd\s", r"(^|[\s|;&])truncate\s",
|
|
116
|
+
r"(^|[\s|;&])touch\s", r"(^|[\s|;&])mkdir\s", r"(^|[\s|;&])rmdir\s",
|
|
117
|
+
r"(^|[\s|;&])ln\s+-s", r"(^|[\s|;&])chmod\s", r"(^|[\s|;&])chown\s",
|
|
118
|
+
# In-place edit
|
|
119
|
+
r"sed\s+-i", r"perl\s+-i", r"awk\s+-i\s",
|
|
120
|
+
# Process control
|
|
121
|
+
r"(^|[\s|;&])kill\s", r"(^|[\s|;&])killall\s", r"(^|[\s|;&])pkill\s",
|
|
122
|
+
# Elevation
|
|
123
|
+
r"(^|[\s|;&])sudo\s", r"(^|[\s|;&])su\s+-",
|
|
124
|
+
# Git mutation
|
|
125
|
+
r"git\s+(commit|push|merge|rebase|reset\s+--hard|checkout\s+-[Bb]|tag\s|stash|cherry-pick|revert|branch\s+-[dD])",
|
|
126
|
+
# Package mutation
|
|
127
|
+
r"npm\s+(install|i|publish|uninstall|update|run\s+publish)",
|
|
128
|
+
r"yarn\s+(add|remove|install|publish|upgrade)",
|
|
129
|
+
r"pnpm\s+(add|remove|install|publish|update)",
|
|
130
|
+
r"pip3?\s+install", r"pip3?\s+uninstall",
|
|
131
|
+
r"uv\s+pip\s+install", r"poetry\s+(add|remove|install|publish)",
|
|
132
|
+
r"brew\s+(install|uninstall|upgrade|cleanup)",
|
|
133
|
+
r"apt(-get)?\s+(install|remove|purge|upgrade)",
|
|
134
|
+
r"snap\s+(install|remove|refresh)",
|
|
135
|
+
r"winget\s+(install|uninstall|upgrade)",
|
|
136
|
+
r"choco\s+(install|uninstall|upgrade)",
|
|
137
|
+
# GitHub mutation
|
|
138
|
+
r"gh\s+(pr\s+create|release\s+create|issue\s+create|repo\s+create|secret\s+set)",
|
|
139
|
+
r"gh\s+pr\s+merge", r"gh\s+pr\s+close", r"gh\s+repo\s+delete",
|
|
140
|
+
# Docker mutation
|
|
141
|
+
r"docker\s+(build|push|run|create|rm|kill|stop|start|restart|exec)",
|
|
142
|
+
# Network transfer (mutates remote)
|
|
143
|
+
r"(^|[\s|;&])scp\s",
|
|
144
|
+
# rsync is intentionally not in the blacklist nor in the discovery
|
|
145
|
+
# whitelist — default-deny path classifies it as EFFECT. Users
|
|
146
|
+
# who genuinely need rsync (including --dry-run) emit a routing
|
|
147
|
+
# marker; safer than guessing intent from flags.
|
|
148
|
+
# Redirects to file (overwrite or append)
|
|
149
|
+
r">\s*[^&\s]", r">>\s*[^&\s]",
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def bash_is_effect(command: str) -> bool:
|
|
155
|
+
"""Classify a Bash command as EFFECT (requires routing) or DISCOVERY (free).
|
|
156
|
+
|
|
157
|
+
Algorithm (default-deny for unknowns):
|
|
158
|
+
1. Empty command → False (no effect).
|
|
159
|
+
2. Any blacklist pattern matches anywhere in the command → True.
|
|
160
|
+
3. First non-pipe token is in the discovery whitelist → False.
|
|
161
|
+
4. Otherwise → True (unknown commands default to requiring routing).
|
|
162
|
+
|
|
163
|
+
Pipes and command chaining are scanned as a whole — if any segment
|
|
164
|
+
has an effect verb, the entire chain is classified EFFECT.
|
|
165
|
+
"""
|
|
166
|
+
if not command or not command.strip():
|
|
167
|
+
return False
|
|
168
|
+
stripped = command.strip()
|
|
169
|
+
for pattern in _BASH_EFFECT_PATTERNS:
|
|
170
|
+
if pattern.search(stripped):
|
|
171
|
+
return True
|
|
172
|
+
first_tokens = stripped.split(None, 1)
|
|
173
|
+
if not first_tokens:
|
|
174
|
+
return False
|
|
175
|
+
first = first_tokens[0]
|
|
176
|
+
# Strip leading env-var assignments like FOO=bar baz qux
|
|
177
|
+
while "=" in first and first_tokens:
|
|
178
|
+
first_tokens = first_tokens[1].split(None, 1) if len(first_tokens) > 1 else []
|
|
179
|
+
first = first_tokens[0] if first_tokens else ""
|
|
180
|
+
if not first:
|
|
181
|
+
return False
|
|
182
|
+
if first in _BASH_DISCOVERY_FIRST:
|
|
183
|
+
return False
|
|
184
|
+
# Unknown command — default to requiring routing.
|
|
185
|
+
return True
|
|
58
186
|
|
|
59
187
|
ROUTING_RE = re.compile(r"\[arka:routing\]\s*[\w-]+\s*->\s*\w+", re.IGNORECASE)
|
|
60
188
|
TRIVIAL_RE = re.compile(r"\[arka:trivial\]\s*\S+", re.IGNORECASE)
|
|
@@ -88,7 +216,9 @@ class Decision:
|
|
|
88
216
|
return (
|
|
89
217
|
f"[ARKA:ENFORCEMENT] Flow marker missing. "
|
|
90
218
|
f"Emit `[arka:routing] <dept> -> <lead>` or `[arka:trivial] <reason>` "
|
|
91
|
-
f"before any
|
|
219
|
+
f"before any tool that mutates state "
|
|
220
|
+
f"(Write/Edit/MultiEdit/NotebookEdit/Task/Skill, or Bash with effect commands like rm/mv/git commit/npm install). "
|
|
221
|
+
f"Reason: {self.reason}"
|
|
92
222
|
)
|
|
93
223
|
|
|
94
224
|
|
|
@@ -209,13 +339,24 @@ def evaluate(
|
|
|
209
339
|
transcript_path: str,
|
|
210
340
|
session_id: str = "",
|
|
211
341
|
cwd: str = "",
|
|
342
|
+
tool_input: dict | None = None,
|
|
212
343
|
) -> Decision:
|
|
213
344
|
"""Decide whether a tool call may proceed.
|
|
214
345
|
|
|
215
346
|
Returns a Decision. Caller is responsible for translating `allow=False`
|
|
216
347
|
into the appropriate hook exit code or permissionDecision output.
|
|
348
|
+
|
|
349
|
+
PR11 v2.33.0 expanded the gated set beyond Write/Edit/MultiEdit to
|
|
350
|
+
cover all EFFECT tools (NotebookEdit, Task, Skill) and to classify
|
|
351
|
+
Bash commands per-command via ``bash_is_effect``.
|
|
217
352
|
"""
|
|
218
|
-
|
|
353
|
+
is_gated = tool_name in EFFECT_TOOLS_ALWAYS
|
|
354
|
+
if not is_gated and tool_name == "Bash":
|
|
355
|
+
bash_cmd = ""
|
|
356
|
+
if tool_input and isinstance(tool_input, dict):
|
|
357
|
+
bash_cmd = str(tool_input.get("command", ""))
|
|
358
|
+
is_gated = bash_is_effect(bash_cmd)
|
|
359
|
+
if not is_gated:
|
|
219
360
|
return Decision(allow=True, reason="tool-not-gated")
|
|
220
361
|
|
|
221
362
|
if not _feature_flag_on():
|
|
@@ -916,7 +916,7 @@
|
|
|
916
916
|
"id": "dev-scaffold-laravel",
|
|
917
917
|
"command": "/dev scaffold laravel <name>",
|
|
918
918
|
"department": "dev",
|
|
919
|
-
"description": "
|
|
919
|
+
"description": "Scaffold a Laravel project from the configured starter-kit template.",
|
|
920
920
|
"lead_agent": "dev-scaffold",
|
|
921
921
|
"tier": 2,
|
|
922
922
|
"requires_branch": false,
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arkaos",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.40.0",
|
|
4
4
|
"description": "The Operating System for AI Agent Teams",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"arkaos": "
|
|
7
|
+
"arkaos": "installer/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test \"tests/installer/**/*.test.js\"",
|