arkaos 4.47.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/egress/policy.py +84 -32
- 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/kb/__init__.py +9 -0
- package/core/kb/nlm_client.py +883 -0
- 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",
|
package/core/egress/policy.py
CHANGED
|
@@ -28,6 +28,7 @@ import re
|
|
|
28
28
|
from dataclasses import dataclass, field
|
|
29
29
|
from datetime import datetime
|
|
30
30
|
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
31
32
|
|
|
32
33
|
from core.egress import allowlist, audit, redact
|
|
33
34
|
from core.governance.harness_scanner import secret_labels
|
|
@@ -42,7 +43,7 @@ class Finding:
|
|
|
42
43
|
# # | audit-unavailable | payload-not-text | guard-failure
|
|
43
44
|
token: str
|
|
44
45
|
|
|
45
|
-
def to_audit(self, salt: bytes = b"") -> dict:
|
|
46
|
+
def to_audit(self, salt: bytes = b"") -> dict[str, Any]:
|
|
46
47
|
return {
|
|
47
48
|
"kind": self.kind,
|
|
48
49
|
"token_sha16": (
|
|
@@ -64,7 +65,7 @@ class EgressDecision:
|
|
|
64
65
|
redacted_sha256: str = ""
|
|
65
66
|
audited: bool = False
|
|
66
67
|
|
|
67
|
-
def to_audit(self, salt: bytes = b"") -> dict:
|
|
68
|
+
def to_audit(self, salt: bytes = b"") -> dict[str, Any]:
|
|
68
69
|
return {
|
|
69
70
|
"allowed": self.allowed,
|
|
70
71
|
"destination": self.destination,
|
|
@@ -99,26 +100,43 @@ def evaluate(
|
|
|
99
100
|
) -> EgressDecision:
|
|
100
101
|
"""Judge one payload against the policy. Never raises.
|
|
101
102
|
|
|
102
|
-
``home`` scopes the home-path check
|
|
103
|
-
audit locations; ``now`` pins allowlist expiry
|
|
103
|
+
``home`` scopes the home-path check, the redaction config and the
|
|
104
|
+
default allowlist / audit locations; ``now`` pins allowlist expiry
|
|
105
|
+
for tests.
|
|
104
106
|
"""
|
|
105
107
|
destination = _safe_str(destination)
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
108
|
+
# ONCE, before any failure path, so no handler re-runs what
|
|
109
|
+
# failed. Named `digest`: `payload_digest` shadowed the public
|
|
110
|
+
# function here (QG D2 r12, Francisca M2).
|
|
111
|
+
digest = payload_digest(text) if isinstance(text, str) else ""
|
|
109
112
|
try:
|
|
110
113
|
decision = _judge(
|
|
111
114
|
text, destination, config_path, home, allowlist_path, now
|
|
112
115
|
)
|
|
113
116
|
except Exception as exc: # never-raises boundary — deny, not crash
|
|
114
|
-
decision =
|
|
115
|
-
allowed=False, destination=destination,
|
|
116
|
-
payload_sha256=payload_digest,
|
|
117
|
-
findings=[Finding("guard-failure", type(exc).__name__)],
|
|
118
|
-
)
|
|
117
|
+
decision = _guard_failure(destination, digest, exc)
|
|
119
118
|
return _audited(decision, home, audit_path, now)
|
|
120
119
|
|
|
121
120
|
|
|
121
|
+
def _guard_failure(
|
|
122
|
+
destination: str, digest: str, exc: BaseException
|
|
123
|
+
) -> EgressDecision:
|
|
124
|
+
return EgressDecision(
|
|
125
|
+
allowed=False, destination=destination, payload_sha256=digest,
|
|
126
|
+
findings=[Finding("guard-failure", type(exc).__name__)],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def default_redaction_config_path(home: Path | None = None) -> Path:
|
|
131
|
+
"""The identifier list *home* implies.
|
|
132
|
+
|
|
133
|
+
Mirrors ``leak_scanner._DEFAULT_CONFIG_PATH`` for the real home, so
|
|
134
|
+
scoping by ``home`` never changes production behaviour — pinned by
|
|
135
|
+
``test_egress_policy.py`` rather than by this comment.
|
|
136
|
+
"""
|
|
137
|
+
return (home or Path.home()) / ".arkaos" / "redaction-clients.json"
|
|
138
|
+
|
|
139
|
+
|
|
122
140
|
def _safe_str(value: object) -> str:
|
|
123
141
|
try:
|
|
124
142
|
return str(value)
|
|
@@ -150,7 +168,7 @@ def _audited(
|
|
|
150
168
|
return decision
|
|
151
169
|
|
|
152
170
|
|
|
153
|
-
def enforce(text: object, destination: str, **kwargs) -> str:
|
|
171
|
+
def enforce(text: object, destination: str, **kwargs: Any) -> str:
|
|
154
172
|
"""The redacted text cleared to leave, or :class:`EgressDeniedError`."""
|
|
155
173
|
decision = evaluate(text, destination, **kwargs)
|
|
156
174
|
if not decision.allowed or decision.redacted_text is None:
|
|
@@ -167,41 +185,70 @@ def _judge(
|
|
|
167
185
|
now: datetime | None,
|
|
168
186
|
) -> EgressDecision:
|
|
169
187
|
if not isinstance(text, str):
|
|
170
|
-
return
|
|
171
|
-
allowed=False, destination=destination, payload_sha256="",
|
|
172
|
-
findings=[Finding("payload-not-text", type(text).__name__)],
|
|
173
|
-
)
|
|
188
|
+
return _not_text(destination, text)
|
|
174
189
|
decision = EgressDecision(
|
|
175
190
|
allowed=False, destination=destination,
|
|
176
|
-
payload_sha256=
|
|
191
|
+
payload_sha256=payload_digest(text),
|
|
177
192
|
)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
# One scoped path for both layers: scoping only the redaction call
|
|
194
|
+
# left residual_identifiers reading the real machine's list
|
|
195
|
+
# (QG D2 r3, Francisca B1).
|
|
196
|
+
scoped = _scoped_config(config_path, home)
|
|
197
|
+
clean = _redacted(text, scoped)
|
|
198
|
+
if isinstance(clean, Finding):
|
|
199
|
+
decision.findings.append(clean)
|
|
181
200
|
return decision
|
|
182
201
|
_collect_findings(
|
|
183
|
-
decision, clean, destination,
|
|
202
|
+
decision, clean, destination, scoped, home, allowlist_path, now
|
|
184
203
|
)
|
|
185
204
|
if not decision.findings:
|
|
186
205
|
decision.allowed = True
|
|
187
206
|
decision.redacted_text = clean
|
|
188
|
-
decision.redacted_sha256 =
|
|
207
|
+
decision.redacted_sha256 = payload_digest(clean)
|
|
189
208
|
return decision
|
|
190
209
|
|
|
191
210
|
|
|
192
|
-
def
|
|
211
|
+
def _not_text(destination: str, text: object) -> EgressDecision:
|
|
212
|
+
return EgressDecision(
|
|
213
|
+
allowed=False, destination=destination, payload_sha256="",
|
|
214
|
+
findings=[Finding("payload-not-text", type(text).__name__)],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def payload_digest(text: str) -> str:
|
|
193
219
|
"""Total over any str — surrogatepass, because a payload holding a
|
|
194
220
|
lone surrogate (routine from errors="surrogateescape" decoding or
|
|
195
221
|
json.loads of an escape) must be DIGESTIBLE to be denied with an
|
|
196
|
-
audit line (QG D1 r2 E-B1).
|
|
222
|
+
audit line (QG D1 r2 E-B1).
|
|
223
|
+
|
|
224
|
+
Public because callers outside this package need the same digest
|
|
225
|
+
the audit trail records; reaching for a private symbol made a D1
|
|
226
|
+
rename a silent runtime break downstream (QG D2 r1, Francisca M6).
|
|
227
|
+
"""
|
|
197
228
|
payload = text.encode("utf-8", errors="surrogatepass")
|
|
198
229
|
return hashlib.sha256(payload).hexdigest()
|
|
199
230
|
|
|
200
231
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
232
|
+
def _scoped_config(config_path: Path | None, home: Path | None) -> Path | None:
|
|
233
|
+
"""The redaction config *home* implies, unless one was named.
|
|
234
|
+
|
|
235
|
+
``home`` scoped ``allowlist_path`` and ``audit_path`` but NOT the
|
|
236
|
+
redaction config, so a caller passing ``home=`` and forgetting
|
|
237
|
+
``config_path`` judged paths against one home while redacting
|
|
238
|
+
against another (QG D2 r1, Francisca M7).
|
|
239
|
+
|
|
240
|
+
Called from inside ``_judge``, i.e. inside ``evaluate``'s try: the
|
|
241
|
+
same defaulting one frame up sat OUTSIDE it, and a non-Path home
|
|
242
|
+
turned the never-raises boundary into a TypeError — the very shape
|
|
243
|
+
QG D1 r2 F-M5 already closed once.
|
|
244
|
+
"""
|
|
245
|
+
if config_path is not None or home is None:
|
|
246
|
+
return config_path
|
|
247
|
+
return default_redaction_config_path(home)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _redacted(text: str, config_path: Path | None) -> str | Finding:
|
|
251
|
+
"""The clean text, or the finding that denies it.
|
|
205
252
|
|
|
206
253
|
A redaction that CRASHES proves nothing about the payload — same
|
|
207
254
|
posture as a missing config: denied, never allowlistable (QG D1
|
|
@@ -209,11 +256,16 @@ def _redacted(
|
|
|
209
256
|
"""
|
|
210
257
|
try:
|
|
211
258
|
clean, _counts = redact.redact(text, config_path)
|
|
212
|
-
|
|
259
|
+
# Annotated: redact() is untyped, so `clean` arrives as Any and
|
|
260
|
+
# the union return silently degrades to Any (mypy no-any-return,
|
|
261
|
+
# surfaced only with --follow-imports=skip —
|
|
262
|
+
# QG D2 r3, Francisca M3).
|
|
263
|
+
clean_text: str = clean
|
|
264
|
+
return clean_text
|
|
213
265
|
except redact.SanitizerConfigMissing:
|
|
214
|
-
return
|
|
266
|
+
return Finding("redaction-config-missing", "")
|
|
215
267
|
except Exception as exc:
|
|
216
|
-
return
|
|
268
|
+
return Finding("redaction-failed", type(exc).__name__)
|
|
217
269
|
|
|
218
270
|
|
|
219
271
|
def _collect_findings(
|
|
@@ -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,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Knowledge-base integrations that reach outside the machine.
|
|
2
|
+
|
|
3
|
+
Everything here goes through ``core.egress.policy`` before a byte
|
|
4
|
+
leaves. The package is deliberately absent from the critical path:
|
|
5
|
+
``tests/python/test_notebooklm_chokepoint.py`` asserts by import graph
|
|
6
|
+
that ``core/governance``, ``core/workflow`` and ``core/release`` never
|
|
7
|
+
import it, so an upstream tool breaking can stall research but never a
|
|
8
|
+
gate or a release.
|
|
9
|
+
"""
|