arkaos 4.48.0 → 4.49.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 +2 -2
- package/THE-ARKAOS-GUIDE.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/config/hooks/_lib/fastpath/engine.cjs +29 -5
- package/config/hooks/gate-manifest.json +14 -1
- package/core/hooks/gate_manifest.py +12 -0
- package/core/hooks/post_tool_use.py +46 -4
- package/core/hooks/pre_tool_use.py +36 -3
- package/core/workflow/flow_enforcer.py +112 -20
- package/core/workflow/frontend_gate.py +64 -5
- package/core/workflow/specialist_enforcer.py +48 -15
- package/departments/kb/SKILL.md +1 -0
- package/departments/kb/skills/research-deep/SKILL.md +192 -0
- package/departments/kb/skills/research-deep/THIRD-PARTY-NOTICES.md +15 -0
- package/harness/codex/AGENTS.md +2 -2
- package/harness/copilot/copilot-instructions.md +2 -2
- package/harness/cursor/rules/arkaos.mdc +3 -3
- package/harness/gemini/GEMINI.md +2 -2
- package/harness/opencode/AGENTS.md +2 -2
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +2 -2
- package/knowledge/commands-registry.json +25 -3
- package/knowledge/skills-manifest.json +14 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**The Operating System for AI Agent Teams.**
|
|
4
4
|
|
|
5
|
-
89 agents. 17 departments.
|
|
5
|
+
89 agents. 17 departments. 333 skills. Enterprise frameworks. Multi-runtime. One install.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npx arkaos install
|
|
@@ -95,7 +95,7 @@ npx arkaos doctor # Health check
|
|
|
95
95
|
|
|
96
96
|
### Skill packs, à la carte
|
|
97
97
|
|
|
98
|
-
The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with
|
|
98
|
+
The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with 259 skills, generated straight from the same sources the core uses. Inside Claude Code:
|
|
99
99
|
|
|
100
100
|
```
|
|
101
101
|
/plugin marketplace add andreagroferreira/arka-os
|
package/THE-ARKAOS-GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The ArkaOS Guide
|
|
2
2
|
|
|
3
|
-
> v4.
|
|
3
|
+
> v4.49.0 — 89 agents, 17 departments, 333 skills, 298 commands, 19 ADRs.
|
|
4
4
|
> One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
|
|
5
5
|
|
|
6
6
|
## What it is
|
|
@@ -45,7 +45,7 @@ answers as a generic assistant.
|
|
|
45
45
|
| `/do` | 1 |
|
|
46
46
|
| `/ecom` | 18 |
|
|
47
47
|
| `/fin` | 10 |
|
|
48
|
-
| `/kb` |
|
|
48
|
+
| `/kb` | 23 |
|
|
49
49
|
| `/landing` | 17 |
|
|
50
50
|
| `/lead` | 10 |
|
|
51
51
|
| `/mkt` | 30 |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.49.0
|
package/arka/SKILL.md
CHANGED
|
@@ -47,7 +47,7 @@ complementary, not alternatives.
|
|
|
47
47
|
# ArkaOS — Main Orchestrator
|
|
48
48
|
|
|
49
49
|
> **The Operating System for AI Agent Teams**
|
|
50
|
-
> 89 agents. 17 departments.
|
|
50
|
+
> 89 agents. 17 departments. 333 skills. Multi-runtime. Dashboard. Knowledge RAG.
|
|
51
51
|
|
|
52
52
|
## ⛔ Evidence flow — 4 gates (NON-NEGOTIABLE)
|
|
53
53
|
|
|
@@ -152,6 +152,28 @@ function hardEnforcementOn(configResult, manifest) {
|
|
|
152
152
|
return pythonTruthy(hooks[key]);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** flow_enforcer.shadow_deny_on (PR-A5a): DEFAULT ON — missing config
|
|
156
|
+
* file, corrupt JSON, or missing key all mean ON; an explicit value is
|
|
157
|
+
* python-truthy. A missing MANIFEST entry (old manifest + new engine,
|
|
158
|
+
* mid-upgrade only) also means ON: delegation is the
|
|
159
|
+
* degrade-to-latency direction, never a skipped state write. */
|
|
160
|
+
function shadowDenyOn(configResult, manifest) {
|
|
161
|
+
const spec = manifest.flags && manifest.flags.shadowDeny;
|
|
162
|
+
if (!spec) return true;
|
|
163
|
+
if (configResult.state !== "ok") return true;
|
|
164
|
+
const [section, key] = spec.path;
|
|
165
|
+
const data = configResult.data;
|
|
166
|
+
const hooks =
|
|
167
|
+
data && typeof data === "object" && !Array.isArray(data)
|
|
168
|
+
? data[section]
|
|
169
|
+
: null;
|
|
170
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (!(key in hooks)) return true;
|
|
174
|
+
return pythonTruthy(hooks[key]);
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
function telemetryLine(prefix, template, now) {
|
|
156
178
|
return JSON.stringify({ ...prefix, ...template }) + "\n";
|
|
157
179
|
}
|
|
@@ -319,16 +341,17 @@ function decidePost(payload, manifest, ctx) {
|
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
// Q6/Q7 — the only remaining Python work on this path is the flow-auth
|
|
322
|
-
// confirm rescan.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// delegate.
|
|
344
|
+
// confirm rescan. Load-bearing when hard enforcement OR shadow-deny
|
|
345
|
+
// (PR-A5a) consumes the grace/confirm state — without the rescan the
|
|
346
|
+
// shadow grace ladder overstates would_block; redundant only when
|
|
347
|
+
// both are off or a confirmed auth is fresh → delegate otherwise.
|
|
326
348
|
const sid = String(payload.session_id || "");
|
|
327
349
|
const enforcementOff =
|
|
328
350
|
ctx.config.state !== "missing"
|
|
329
351
|
? !hardEnforcementOn(ctx.config, manifest)
|
|
330
352
|
: true; // missing config file → flag definitively false
|
|
331
|
-
|
|
353
|
+
const shadowOn = shadowDenyOn(ctx.config, manifest);
|
|
354
|
+
if ((!enforcementOff || shadowOn) && !flowAuthFresh(sid, manifest, ctx)) {
|
|
332
355
|
return { action: "delegate", reason: "flow-auth-not-fresh" };
|
|
333
356
|
}
|
|
334
357
|
|
|
@@ -369,6 +392,7 @@ module.exports = {
|
|
|
369
392
|
pythonTruthy,
|
|
370
393
|
readJsonFile,
|
|
371
394
|
safeSessionId,
|
|
395
|
+
shadowDenyOn,
|
|
372
396
|
toolText,
|
|
373
397
|
validateManifest,
|
|
374
398
|
};
|
|
@@ -605,6 +605,16 @@
|
|
|
605
605
|
"hooks",
|
|
606
606
|
"hardEnforcement"
|
|
607
607
|
]
|
|
608
|
+
},
|
|
609
|
+
"shadowDeny": {
|
|
610
|
+
"coercion": "python-truthy",
|
|
611
|
+
"on_corrupt": true,
|
|
612
|
+
"on_missing_file": true,
|
|
613
|
+
"on_missing_key": true,
|
|
614
|
+
"path": [
|
|
615
|
+
"hooks",
|
|
616
|
+
"shadowDeny"
|
|
617
|
+
]
|
|
608
618
|
}
|
|
609
619
|
},
|
|
610
620
|
"home_paths": {
|
|
@@ -675,7 +685,10 @@
|
|
|
675
685
|
"marker_found": null,
|
|
676
686
|
"phase_observed": null,
|
|
677
687
|
"reason": "tool-not-gated",
|
|
678
|
-
"
|
|
688
|
+
"shadow_ms": 0.0,
|
|
689
|
+
"shadow_reason": "",
|
|
690
|
+
"warning": "",
|
|
691
|
+
"would_block": false
|
|
679
692
|
},
|
|
680
693
|
"kb_first_prefix_keys": [
|
|
681
694
|
"ts",
|
|
@@ -249,6 +249,18 @@ def _flags_and_budget() -> dict:
|
|
|
249
249
|
"on_corrupt": False,
|
|
250
250
|
"coercion": "python-truthy",
|
|
251
251
|
},
|
|
252
|
+
"shadowDeny": {
|
|
253
|
+
"path": ["hooks", "shadowDeny"],
|
|
254
|
+
# flow_enforcer.shadow_deny_on: DEFAULT ON — missing file,
|
|
255
|
+
# corrupt JSON, or missing key all mean ON (PR-A5a telemetry
|
|
256
|
+
# collects by default); an explicit value is python-truthy.
|
|
257
|
+
# The engine additionally treats a MISSING MANIFEST ENTRY as
|
|
258
|
+
# ON: delegation is the degrade-to-latency direction.
|
|
259
|
+
"on_missing_file": True,
|
|
260
|
+
"on_corrupt": True,
|
|
261
|
+
"on_missing_key": True,
|
|
262
|
+
"coercion": "python-truthy",
|
|
263
|
+
},
|
|
252
264
|
"budget": {
|
|
253
265
|
"section": "budget",
|
|
254
266
|
"cap_keys": ["hardCapUsd", "dailyCapUsd"],
|
|
@@ -685,7 +685,36 @@ def _enqueue_cognition_capture(session_id: str, tool_text: str) -> None:
|
|
|
685
685
|
# ─── Section 10: metrics ─────────────────────────────────────────────────
|
|
686
686
|
|
|
687
687
|
|
|
688
|
-
def
|
|
688
|
+
def _shadow_attribution() -> dict:
|
|
689
|
+
"""PR-A5a: label this (delegated) run with the flag states. The
|
|
690
|
+
labels partition by FLAG STATE only — stateful-tool and error-turn
|
|
691
|
+
delegations carry the same flags — so the shadow-forced population
|
|
692
|
+
is isolated by combining them with the ``delegation`` field
|
|
693
|
+
(``_delegation_kind``): enforcement=false, shadow=true,
|
|
694
|
+
delegation="benign" is the flow-auth rescan population that only
|
|
695
|
+
reaches Python because of shadow-deny (engine.cjs Q6), plus the
|
|
696
|
+
negligible direct-invocation/fastpath-off runs. Empty on stripped
|
|
697
|
+
installs."""
|
|
698
|
+
try:
|
|
699
|
+
from core.workflow.flow_enforcer import _feature_flag_on, shadow_deny_on
|
|
700
|
+
return {"enforcement": _feature_flag_on(), "shadow": shadow_deny_on()}
|
|
701
|
+
except Exception:
|
|
702
|
+
return {}
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _delegation_kind(stdin_json: dict, tool_name: str, benign: bool) -> str:
|
|
706
|
+
"""Why this run reached Python, derived from the payload in the same
|
|
707
|
+
precedence the shim decides delegation (engine.cjs decidePost:
|
|
708
|
+
failure event, then the stateful set, then the error trigger —
|
|
709
|
+
everything else on this path is the Q6 flow-auth rescan)."""
|
|
710
|
+
if get_str(stdin_json, "hook_event_name") == "PostToolUseFailure":
|
|
711
|
+
return "error"
|
|
712
|
+
if tool_name in ("ExitPlanMode", "Task", "Agent"):
|
|
713
|
+
return "stateful"
|
|
714
|
+
return "benign" if benign else "error"
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _log_metrics(duration_ms: int, attribution: dict | None = None) -> None:
|
|
689
718
|
metrics_file = Path.home() / ".arkaos" / "hook-metrics.json"
|
|
690
719
|
lock_file = Path.home() / ".arkaos" / "hook-metrics.lock"
|
|
691
720
|
metrics_file.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -706,6 +735,7 @@ def _log_metrics(duration_ms: int) -> None:
|
|
|
706
735
|
"hook": "post-tool-use",
|
|
707
736
|
"duration_ms": duration_ms,
|
|
708
737
|
"timestamp": now,
|
|
738
|
+
**(attribution or {}),
|
|
709
739
|
})
|
|
710
740
|
tmp = metrics_file.with_suffix(".json.tmp")
|
|
711
741
|
tmp.write_text(json.dumps(entries[-500:]), encoding="utf-8")
|
|
@@ -776,9 +806,17 @@ def main(stdin_json: dict | None = None) -> int:
|
|
|
776
806
|
_record_pattern_stub(tool_text, prompt)
|
|
777
807
|
_record_activation(subagent_type, session_id)
|
|
778
808
|
|
|
779
|
-
# Only process further
|
|
780
|
-
# as the bash version
|
|
809
|
+
# Only process violations/gotchas further on error turns (same early
|
|
810
|
+
# exit as the bash version). Metrics ARE logged here first: benign
|
|
811
|
+
# turns are exactly where the shadow-forced flow-auth delegations
|
|
812
|
+
# live — skipping them left the kill-switch population unrecorded
|
|
813
|
+
# (QG r2, Eduardo B1 + Francisca B3).
|
|
781
814
|
if exit_code in ("0", "") and not _ERROR_TRIGGER_RE.search(tool_text):
|
|
815
|
+
_log_metrics(
|
|
816
|
+
int((time.monotonic() - start) * 1000),
|
|
817
|
+
{**_shadow_attribution(),
|
|
818
|
+
"delegation": _delegation_kind(stdin_json, tool_name, True)},
|
|
819
|
+
)
|
|
782
820
|
print("{}")
|
|
783
821
|
return 0
|
|
784
822
|
|
|
@@ -802,7 +840,11 @@ def main(stdin_json: dict | None = None) -> int:
|
|
|
802
840
|
)
|
|
803
841
|
|
|
804
842
|
_enqueue_cognition_capture(session_id, tool_text)
|
|
805
|
-
_log_metrics(
|
|
843
|
+
_log_metrics(
|
|
844
|
+
int((time.monotonic() - start) * 1000),
|
|
845
|
+
{**_shadow_attribution(),
|
|
846
|
+
"delegation": _delegation_kind(stdin_json, tool_name, False)},
|
|
847
|
+
)
|
|
806
848
|
|
|
807
849
|
if violation_msg:
|
|
808
850
|
# The runtime drops context whose hookEventName differs from the
|
|
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import contextlib
|
|
30
30
|
import sys
|
|
31
|
+
import time
|
|
31
32
|
from pathlib import Path
|
|
32
33
|
|
|
33
34
|
from core.hooks._shared import (
|
|
@@ -55,7 +56,8 @@ def _query_hint(tool_input: dict) -> str:
|
|
|
55
56
|
|
|
56
57
|
|
|
57
58
|
class _MessagesOnce:
|
|
58
|
-
"""Parse the transcript at most once, lazily, across
|
|
59
|
+
"""Parse the transcript at most once, lazily, across the gates
|
|
60
|
+
(specialist, frontend and flow all consume the same instance).
|
|
59
61
|
|
|
60
62
|
``peek()`` never triggers a read — gates that have their own cheaper
|
|
61
63
|
early-outs (feature flag off, marker-cache hit) pass ``peek()`` so the
|
|
@@ -67,6 +69,7 @@ class _MessagesOnce:
|
|
|
67
69
|
self._path = transcript_path
|
|
68
70
|
self._messages: list[str] | None = None
|
|
69
71
|
self._sidechain: bool | None = None
|
|
72
|
+
self._load_ms: float = 0.0
|
|
70
73
|
|
|
71
74
|
def peek(self) -> list[str] | None:
|
|
72
75
|
return self._messages
|
|
@@ -75,8 +78,19 @@ class _MessagesOnce:
|
|
|
75
78
|
"""Scope of the most recent assistant record; None before load()."""
|
|
76
79
|
return self._sidechain
|
|
77
80
|
|
|
81
|
+
def consume_load_ms(self) -> float:
|
|
82
|
+
"""The parse cost in ms, claimable ONCE (PR-A5a shadow timing).
|
|
83
|
+
|
|
84
|
+
The parse a shadow evaluation forces happens here, outside every
|
|
85
|
+
gate's own timer — the first shadow caller claims it into its
|
|
86
|
+
Decision.shadow_ms; subsequent callers get 0.0 so the cost is
|
|
87
|
+
never double-attributed."""
|
|
88
|
+
elapsed, self._load_ms = self._load_ms, 0.0
|
|
89
|
+
return elapsed
|
|
90
|
+
|
|
78
91
|
def load(self) -> list[str] | None:
|
|
79
92
|
if self._messages is None:
|
|
93
|
+
start = time.perf_counter()
|
|
80
94
|
try:
|
|
81
95
|
# Scope-aware since P0.2: the window counts MAIN-scope
|
|
82
96
|
# messages only, so interleaved subagent records cannot
|
|
@@ -88,6 +102,10 @@ class _MessagesOnce:
|
|
|
88
102
|
self._sidechain = split.active_sidechain
|
|
89
103
|
except Exception:
|
|
90
104
|
return None
|
|
105
|
+
finally:
|
|
106
|
+
self._load_ms = round(
|
|
107
|
+
(time.perf_counter() - start) * 1000, 3
|
|
108
|
+
)
|
|
91
109
|
return self._messages
|
|
92
110
|
|
|
93
111
|
|
|
@@ -121,6 +139,17 @@ def _kb_gate(root: str, tool_name: str, session_id: str, query: str) -> int | No
|
|
|
121
139
|
return None
|
|
122
140
|
|
|
123
141
|
|
|
142
|
+
def _claim_parse_cost(decision, messages: _MessagesOnce) -> None:
|
|
143
|
+
"""Attribute the parse a shadow evaluation forced (QG r1, Francisca
|
|
144
|
+
B2): the load ran outside the gate's timer, so the first shadow
|
|
145
|
+
decision claims it — consume_load_ms() returns 0.0 when another
|
|
146
|
+
gate already did, or when enforcement (not shadow) paid for it."""
|
|
147
|
+
if decision.shadow_reason:
|
|
148
|
+
decision.shadow_ms = round(
|
|
149
|
+
decision.shadow_ms + messages.consume_load_ms(), 3
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
124
153
|
def _specialist_gate(
|
|
125
154
|
root: str,
|
|
126
155
|
tool_name: str,
|
|
@@ -137,6 +166,7 @@ def _specialist_gate(
|
|
|
137
166
|
if not module_path.is_file():
|
|
138
167
|
return None
|
|
139
168
|
try:
|
|
169
|
+
from core.workflow.flow_enforcer import shadow_deny_on
|
|
140
170
|
from core.workflow.specialist_enforcer import (
|
|
141
171
|
_feature_flag_on,
|
|
142
172
|
evaluate,
|
|
@@ -145,9 +175,11 @@ def _specialist_gate(
|
|
|
145
175
|
except Exception:
|
|
146
176
|
return None # specialist-import-failed → allow
|
|
147
177
|
# Load (and share) the transcript only when the gate will actually
|
|
148
|
-
# scan it — flag-off sessions
|
|
178
|
+
# scan it — flag-off sessions with the shadow kill-switch thrown
|
|
179
|
+
# keep the zero-read fast path; shadow sessions share ONE parse
|
|
180
|
+
# across the specialist, frontend and flow gates (PR-A5a).
|
|
149
181
|
shared = messages.peek()
|
|
150
|
-
if shared is None and _feature_flag_on():
|
|
182
|
+
if shared is None and (_feature_flag_on() or shadow_deny_on()):
|
|
151
183
|
shared = messages.load()
|
|
152
184
|
decision = evaluate(
|
|
153
185
|
tool_name=tool_name,
|
|
@@ -158,6 +190,7 @@ def _specialist_gate(
|
|
|
158
190
|
messages=shared,
|
|
159
191
|
is_sidechain=messages.sidechain_active(),
|
|
160
192
|
)
|
|
193
|
+
_claim_parse_cost(decision, messages)
|
|
161
194
|
with contextlib.suppress(Exception):
|
|
162
195
|
record_telemetry(
|
|
163
196
|
session_id=session_id,
|
|
@@ -16,12 +16,15 @@ Design contract:
|
|
|
16
16
|
- Gated tool list is closed: anything outside it is always allowed.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
+
import contextlib
|
|
19
20
|
import json
|
|
20
21
|
import os
|
|
21
22
|
import re
|
|
23
|
+
import time
|
|
22
24
|
from contextlib import contextmanager
|
|
23
25
|
from dataclasses import asdict, dataclass
|
|
24
|
-
from datetime import
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from functools import lru_cache
|
|
25
28
|
from pathlib import Path
|
|
26
29
|
|
|
27
30
|
from core.shared import safe_session_id as _safe_session_id_module
|
|
@@ -51,10 +54,8 @@ def _locked_append(path: Path):
|
|
|
51
54
|
yield fh
|
|
52
55
|
finally:
|
|
53
56
|
if _HAS_FLOCK:
|
|
54
|
-
|
|
57
|
+
with contextlib.suppress(OSError):
|
|
55
58
|
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
56
|
-
except OSError:
|
|
57
|
-
pass
|
|
58
59
|
fh.close()
|
|
59
60
|
|
|
60
61
|
# PR11 v2.33.0 — Discovery vs Effect tool taxonomy (Conclave Phase 5).
|
|
@@ -107,7 +108,7 @@ _BASH_DISCOVERY_FIRST: frozenset[str] = frozenset({
|
|
|
107
108
|
"shopt", "trap", "wait", "eval", "exec",
|
|
108
109
|
# Test runners (read state but don't mutate canonical files)
|
|
109
110
|
"pytest", "jest", "vitest", "phpunit", "pest", "rspec", "mocha",
|
|
110
|
-
"
|
|
111
|
+
"cargo",
|
|
111
112
|
})
|
|
112
113
|
|
|
113
114
|
# Bash classifier — patterns that indicate mutation (anywhere in command).
|
|
@@ -182,10 +183,8 @@ def bash_is_effect(command: str) -> bool:
|
|
|
182
183
|
first = first_tokens[0] if first_tokens else ""
|
|
183
184
|
if not first:
|
|
184
185
|
return False
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# Unknown command — default to requiring routing.
|
|
188
|
-
return True
|
|
186
|
+
# Unknown commands default to requiring routing.
|
|
187
|
+
return first not in _BASH_DISCOVERY_FIRST
|
|
189
188
|
|
|
190
189
|
ROUTING_RE = re.compile(r"\[arka:routing\]\s*[\w-]+\s*->\s*\w+", re.IGNORECASE)
|
|
191
190
|
TRIVIAL_RE = re.compile(r"\[arka:trivial\]\s*\S+", re.IGNORECASE)
|
|
@@ -227,6 +226,17 @@ class Decision:
|
|
|
227
226
|
# Interaction Reform PR3 — plan-approval telemetry (WARN phase):
|
|
228
227
|
# "approved" | "missing" | "no-plan" | "unknown" | "" (not gated).
|
|
229
228
|
approval_state: str = ""
|
|
229
|
+
# PR-A5a shadow-deny: with hardEnforcement off, the gate still runs
|
|
230
|
+
# and records what it WOULD have decided. `would_block` is True when
|
|
231
|
+
# enforcement would have denied; `shadow_reason` carries the inner
|
|
232
|
+
# decision's reason (shadow ran iff it is non-empty); `shadow_ms`
|
|
233
|
+
# (float, sub-ms preserved) measures the added evaluation cost for
|
|
234
|
+
# the pre-side p90 kill-switch criterion — the post-side cost is
|
|
235
|
+
# attributed separately in hook-metrics.json (see wiki/16). All
|
|
236
|
+
# three stay at their defaults on enforced paths.
|
|
237
|
+
would_block: bool = False
|
|
238
|
+
shadow_reason: str = ""
|
|
239
|
+
shadow_ms: float = 0.0
|
|
230
240
|
|
|
231
241
|
def to_stderr_message(self) -> str:
|
|
232
242
|
if self.allow:
|
|
@@ -257,13 +267,37 @@ def _feature_flag_on() -> bool:
|
|
|
257
267
|
return bool(data.get("hooks", {}).get("hardEnforcement", False))
|
|
258
268
|
|
|
259
269
|
|
|
270
|
+
@lru_cache(maxsize=1)
|
|
271
|
+
def shadow_deny_on() -> bool:
|
|
272
|
+
"""``hooks.shadowDeny`` — the shadow-telemetry kill-switch (PR-A5a).
|
|
273
|
+
|
|
274
|
+
DEFAULT ON: a missing config file, corrupt or undecodable JSON, or a
|
|
275
|
+
missing key all mean ON (the telemetry that gates the A5b flip
|
|
276
|
+
collects by default); only an explicit falsy value turns it off.
|
|
277
|
+
Mirrored by engine.cjs ``shadowDenyOn`` via the gate manifest — keep
|
|
278
|
+
the two in lockstep. Cached per process (each hook invocation is a
|
|
279
|
+
fresh interpreter, so the cache scope is one tool call; tests call
|
|
280
|
+
``shadow_deny_on.cache_clear()`` after rewriting the config).
|
|
281
|
+
"""
|
|
282
|
+
if not CONFIG_PATH.exists():
|
|
283
|
+
return True
|
|
284
|
+
try:
|
|
285
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
286
|
+
except (ValueError, OSError):
|
|
287
|
+
return True
|
|
288
|
+
hooks = data.get("hooks") if isinstance(data, dict) else None
|
|
289
|
+
if not isinstance(hooks, dict) or "shadowDeny" not in hooks:
|
|
290
|
+
return True
|
|
291
|
+
return bool(hooks["shadowDeny"])
|
|
292
|
+
|
|
293
|
+
|
|
260
294
|
def _bypass_env_active() -> bool:
|
|
261
295
|
return os.environ.get("ARKA_BYPASS_FLOW", "").strip() == "1"
|
|
262
296
|
|
|
263
297
|
|
|
264
298
|
def _audit_bypass(session_id: str, tool: str, cwd: str) -> None:
|
|
265
299
|
entry = {
|
|
266
|
-
"ts": datetime.now(
|
|
300
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
267
301
|
"session_id": session_id,
|
|
268
302
|
"tool": tool,
|
|
269
303
|
"cwd": cwd,
|
|
@@ -278,7 +312,7 @@ def record_telemetry(
|
|
|
278
312
|
) -> None:
|
|
279
313
|
"""Append a structured record to the enforcement telemetry log."""
|
|
280
314
|
entry = {
|
|
281
|
-
"ts": datetime.now(
|
|
315
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
282
316
|
"session_id": session_id,
|
|
283
317
|
"tool": tool,
|
|
284
318
|
"cwd": cwd,
|
|
@@ -407,7 +441,7 @@ def _annotate_plan_approval(decision: Decision, session_id: str) -> Decision:
|
|
|
407
441
|
)
|
|
408
442
|
else:
|
|
409
443
|
decision.approval_state = "no-plan"
|
|
410
|
-
except Exception:
|
|
444
|
+
except Exception:
|
|
411
445
|
decision.approval_state = "unknown"
|
|
412
446
|
return decision
|
|
413
447
|
|
|
@@ -462,11 +496,61 @@ def _evaluate_flow(
|
|
|
462
496
|
if not is_gated:
|
|
463
497
|
return Decision(allow=True, reason="tool-not-gated")
|
|
464
498
|
|
|
465
|
-
if
|
|
499
|
+
if _feature_flag_on():
|
|
500
|
+
return _decide_gated(
|
|
501
|
+
tool_name, transcript_path, session_id, cwd, messages
|
|
502
|
+
)
|
|
503
|
+
if not shadow_deny_on():
|
|
466
504
|
return Decision(allow=True, reason="feature-flag-off")
|
|
505
|
+
return _shadow_decision(
|
|
506
|
+
tool_name, transcript_path, session_id, cwd, messages
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _shadow_decision(
|
|
511
|
+
tool_name: str,
|
|
512
|
+
transcript_path: str,
|
|
513
|
+
session_id: str,
|
|
514
|
+
cwd: str,
|
|
515
|
+
messages: list[str] | None,
|
|
516
|
+
) -> Decision:
|
|
517
|
+
"""Run the gated chain in shadow (PR-A5a): allow regardless, record
|
|
518
|
+
what enforcement would have decided. Silent by construction — the
|
|
519
|
+
inner decision's warning/stderr never propagates."""
|
|
520
|
+
start = time.perf_counter()
|
|
521
|
+
inner = _decide_gated(
|
|
522
|
+
tool_name, transcript_path, session_id, cwd, messages, shadow=True
|
|
523
|
+
)
|
|
524
|
+
return Decision(
|
|
525
|
+
allow=True,
|
|
526
|
+
reason="feature-flag-off",
|
|
527
|
+
marker_found=inner.marker_found,
|
|
528
|
+
phase_observed=inner.phase_observed,
|
|
529
|
+
would_block=not inner.allow,
|
|
530
|
+
shadow_reason=inner.reason,
|
|
531
|
+
shadow_ms=round((time.perf_counter() - start) * 1000, 3),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _decide_gated(
|
|
536
|
+
tool_name: str,
|
|
537
|
+
transcript_path: str,
|
|
538
|
+
session_id: str,
|
|
539
|
+
cwd: str,
|
|
540
|
+
messages: list[str] | None,
|
|
541
|
+
shadow: bool = False,
|
|
542
|
+
) -> Decision:
|
|
543
|
+
"""The enforcement chain behind the feature flag.
|
|
467
544
|
|
|
545
|
+
``shadow=True`` runs the identical chain for the telemetry-only
|
|
546
|
+
caller; the sole suppressed side effect is the bypass audit — an env
|
|
547
|
+
bypass of an inactive gate is not a bypass and must not pollute the
|
|
548
|
+
accountability log. State writes (confirm/grace) run in both modes
|
|
549
|
+
so the shadow would_block rate is the rate a flip would produce.
|
|
550
|
+
"""
|
|
468
551
|
if _bypass_env_active():
|
|
469
|
-
|
|
552
|
+
if not shadow:
|
|
553
|
+
_audit_bypass(session_id, tool_name, cwd)
|
|
470
554
|
return Decision(allow=True, reason="env-bypass", bypass_used=True)
|
|
471
555
|
|
|
472
556
|
if not _flow_required_for_session(session_id):
|
|
@@ -485,6 +569,11 @@ def _evaluate_flow(
|
|
|
485
569
|
messages = _load_last_assistant_messages(
|
|
486
570
|
transcript_path, ASSISTANT_WINDOW
|
|
487
571
|
)
|
|
572
|
+
return _decide_from_markers(session_id, messages)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _decide_from_markers(session_id: str, messages: list[str]) -> Decision:
|
|
576
|
+
"""Marker scan + persistent-auth fallback + grace ladder."""
|
|
488
577
|
marker_found, phase_observed = _scan_markers(messages)
|
|
489
578
|
|
|
490
579
|
if marker_found is not None:
|
|
@@ -511,12 +600,15 @@ def _evaluate_flow(
|
|
|
511
600
|
return Decision(
|
|
512
601
|
allow=True, reason="turn-grace", phase_observed=phase_observed
|
|
513
602
|
)
|
|
603
|
+
return _decide_grace(session_id, phase_observed)
|
|
604
|
+
|
|
514
605
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
606
|
+
def _decide_grace(session_id: str, phase_observed: str | None) -> Decision:
|
|
607
|
+
"""First effect-tool of a turn with no confirmed auth. A hard deny
|
|
608
|
+
here is a false positive (the assistant may have routed in this very
|
|
609
|
+
message — invisible to us). Grace it with a warning; escalate to a
|
|
610
|
+
real block only after GRACE_CAP consecutive graced turns without any
|
|
611
|
+
confirmation (a normally-routing session confirms by turn 2)."""
|
|
520
612
|
grace = flow_authorization.register_grace(session_id)
|
|
521
613
|
if grace.escalate:
|
|
522
614
|
return Decision(
|
|
@@ -545,7 +637,7 @@ def mark_flow_required(session_id: str) -> None:
|
|
|
545
637
|
return
|
|
546
638
|
FLOW_REQUIRED_DIR.mkdir(parents=True, exist_ok=True)
|
|
547
639
|
marker = FLOW_REQUIRED_DIR / safe
|
|
548
|
-
marker.write_text(datetime.now(
|
|
640
|
+
marker.write_text(datetime.now(UTC).isoformat(), encoding="utf-8")
|
|
549
641
|
|
|
550
642
|
|
|
551
643
|
def clear_flow_required(session_id: str) -> None:
|