arkaos 2.20.0 → 2.20.1
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/hooks/_lib/workflow-classifier.sh +95 -0
- package/config/hooks/pre-tool-use.ps1 +117 -0
- package/config/hooks/pre-tool-use.sh +135 -0
- package/config/hooks/stop.ps1 +112 -0
- package/config/hooks/stop.sh +127 -0
- package/config/hooks/user-prompt-submit.sh +20 -15
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/flow_enforcer.py +272 -0
- package/installer/adapters/claude-code.js +13 -0
- package/installer/doctor.js +13 -1
- package/installer/index.js +15 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.20.
|
|
1
|
+
2.20.1
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# ArkaOS v2 — Shared Workflow Classifier
|
|
4
|
+
#
|
|
5
|
+
# Decides whether a user prompt triggers the mandatory 13-phase flow.
|
|
6
|
+
# Used by: user-prompt-submit.sh, pre-tool-use.sh, stop.sh.
|
|
7
|
+
#
|
|
8
|
+
# Contract:
|
|
9
|
+
# arka_wf_classify "<prompt text>" → echoes "true" or "false", exits 0.
|
|
10
|
+
# arka_wf_mark_required "<session_id>" → writes marker file.
|
|
11
|
+
# arka_wf_is_required "<session_id>" → exits 0 if required, 1 otherwise.
|
|
12
|
+
# arka_wf_clear_required "<session_id>" → removes marker file.
|
|
13
|
+
#
|
|
14
|
+
# Markers live under /tmp/arkaos-wf-required/<session_id>.
|
|
15
|
+
# Python path for mark/clear: delegates to flow_enforcer.py when available,
|
|
16
|
+
# otherwise falls back to touching the marker file directly.
|
|
17
|
+
# ============================================================================
|
|
18
|
+
|
|
19
|
+
ARKA_WF_REQUIRED_DIR="${ARKA_WF_REQUIRED_DIR:-/tmp/arkaos-wf-required}"
|
|
20
|
+
|
|
21
|
+
# Reject any session_id outside [A-Za-z0-9._-]{1,128}. Protects the marker
|
|
22
|
+
# directory from path-traversal writes (CWE-22). Must stay in sync with
|
|
23
|
+
# core/workflow/flow_enforcer.py::_safe_session_id.
|
|
24
|
+
arka_wf_safe_session_id() {
|
|
25
|
+
local session_id="${1:-}"
|
|
26
|
+
[ -z "$session_id" ] && return 1
|
|
27
|
+
[ ${#session_id} -gt 128 ] && return 1
|
|
28
|
+
case "$session_id" in
|
|
29
|
+
*[!A-Za-z0-9._-]*) return 1 ;;
|
|
30
|
+
esac
|
|
31
|
+
return 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Verb + noun patterns shared with the original inline classifier in
|
|
35
|
+
# user-prompt-submit.sh. Keep in sync when adding new intent verbs.
|
|
36
|
+
ARKA_WF_VERB_PATTERN='(criar?|crie[ms]?|cria[mr]?|adicionar?|adiciona[mr]?|implementar?|implementa[mr]?|desenvolver?|desenvolve[mr]?|construir?|constru[ií]a?[mr]?|fazer?|faz[ae][mr]?|refactor(izar?)?|corrigir?|corrige[mr]?|consertar?|conserta[mr]?|create[sd]?|creating|build(s|ing)?|add(s|ed|ing)?|implement(s|ed|ing)?|develop(s|ed|ing)?|fix(es|ed|ing)?|refactor(s|ed|ing)?|make[sd]?|making)'
|
|
37
|
+
|
|
38
|
+
# Classify: returns "true" if the prompt looks like a creation/
|
|
39
|
+
# implementation/modification request, "false" otherwise.
|
|
40
|
+
# Skips: explicit slash commands (already routed) and bang shells.
|
|
41
|
+
arka_wf_classify() {
|
|
42
|
+
local text="${1:-}"
|
|
43
|
+
[ -z "$text" ] && { echo "false"; return 0; }
|
|
44
|
+
|
|
45
|
+
local first_char
|
|
46
|
+
first_char=$(printf '%s' "$text" | head -c 1)
|
|
47
|
+
if [ "$first_char" = "/" ] || [ "$first_char" = "!" ]; then
|
|
48
|
+
echo "false"
|
|
49
|
+
return 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
if echo "$text" | grep -qiE "\b${ARKA_WF_VERB_PATTERN}\b"; then
|
|
53
|
+
echo "true"
|
|
54
|
+
else
|
|
55
|
+
echo "false"
|
|
56
|
+
fi
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Mark that the flow is required for this session. Safe no-op if session_id
|
|
60
|
+
# is empty or fails the allowlist check.
|
|
61
|
+
arka_wf_mark_required() {
|
|
62
|
+
local session_id="${1:-}"
|
|
63
|
+
arka_wf_safe_session_id "$session_id" || return 0
|
|
64
|
+
mkdir -p "$ARKA_WF_REQUIRED_DIR" 2>/dev/null
|
|
65
|
+
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$ARKA_WF_REQUIRED_DIR/$session_id" 2>/dev/null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Test whether flow is required. Exit code 0 = required, 1 = not required.
|
|
69
|
+
arka_wf_is_required() {
|
|
70
|
+
local session_id="${1:-}"
|
|
71
|
+
arka_wf_safe_session_id "$session_id" || return 1
|
|
72
|
+
[ -f "$ARKA_WF_REQUIRED_DIR/$session_id" ]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Clear the requirement marker. Safe no-op if absent or unsafe.
|
|
76
|
+
arka_wf_clear_required() {
|
|
77
|
+
local session_id="${1:-}"
|
|
78
|
+
arka_wf_safe_session_id "$session_id" || return 0
|
|
79
|
+
rm -f "$ARKA_WF_REQUIRED_DIR/$session_id" 2>/dev/null
|
|
80
|
+
return 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# When invoked directly (not sourced), expose a simple CLI for ad-hoc use.
|
|
84
|
+
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
85
|
+
case "${1:-}" in
|
|
86
|
+
classify) arka_wf_classify "${2:-}" ;;
|
|
87
|
+
mark) arka_wf_mark_required "${2:-}" ;;
|
|
88
|
+
is-required) arka_wf_is_required "${2:-}" && echo "true" || echo "false" ;;
|
|
89
|
+
clear) arka_wf_clear_required "${2:-}" ;;
|
|
90
|
+
*)
|
|
91
|
+
echo "Usage: $0 {classify <text>|mark <session_id>|is-required <session_id>|clear <session_id>}" >&2
|
|
92
|
+
exit 64
|
|
93
|
+
;;
|
|
94
|
+
esac
|
|
95
|
+
fi
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# ArkaOS v2 — PreToolUse Hook (Windows PowerShell)
|
|
3
|
+
#
|
|
4
|
+
# Parity with config/hooks/pre-tool-use.sh. Blocks Write/Edit/MultiEdit when
|
|
5
|
+
# the mandatory 13-phase flow is required for the session AND the assistant
|
|
6
|
+
# has not emitted a flow marker in its last 3 messages of the transcript.
|
|
7
|
+
#
|
|
8
|
+
# Delegates the decision to core/workflow/flow_enforcer.py.
|
|
9
|
+
#
|
|
10
|
+
# Exit 0 = allow (silent). Exit 2 = deny + structured hookSpecificOutput JSON.
|
|
11
|
+
# ============================================================================
|
|
12
|
+
|
|
13
|
+
$ErrorActionPreference = "SilentlyContinue"
|
|
14
|
+
|
|
15
|
+
# --- Read stdin JSON ---
|
|
16
|
+
$inputJson = [Console]::In.ReadToEnd()
|
|
17
|
+
if ([string]::IsNullOrWhiteSpace($inputJson)) { exit 0 }
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
$inp = $inputJson | ConvertFrom-Json
|
|
21
|
+
} catch {
|
|
22
|
+
exit 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
$toolName = [string]$inp.tool_name
|
|
26
|
+
$transcriptPath = [string]$inp.transcript_path
|
|
27
|
+
$sessionId = [string]$inp.session_id
|
|
28
|
+
$cwd = [string]$inp.cwd
|
|
29
|
+
|
|
30
|
+
# --- Fast allow: not a gated tool ---
|
|
31
|
+
if ($toolName -ne "Write" -and $toolName -ne "Edit" -and $toolName -ne "MultiEdit") {
|
|
32
|
+
exit 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# --- Resolve ARKAOS_ROOT ---
|
|
36
|
+
if ([string]::IsNullOrWhiteSpace($env:ARKAOS_ROOT)) {
|
|
37
|
+
$repoPathFile = Join-Path $HOME ".arkaos/.repo-path"
|
|
38
|
+
if (Test-Path $repoPathFile) {
|
|
39
|
+
$env:ARKAOS_ROOT = (Get-Content $repoPathFile -Raw).Trim()
|
|
40
|
+
} elseif (Test-Path (Join-Path $HOME ".arkaos")) {
|
|
41
|
+
$env:ARKAOS_ROOT = (Join-Path $HOME ".arkaos")
|
|
42
|
+
} else {
|
|
43
|
+
$env:ARKAOS_ROOT = if ($env:ARKA_OS) { $env:ARKA_OS } else { Join-Path $HOME ".claude/skills/arkaos" }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
$enforcerPy = Join-Path $env:ARKAOS_ROOT "core/workflow/flow_enforcer.py"
|
|
48
|
+
if (-not (Test-Path $enforcerPy)) { exit 0 }
|
|
49
|
+
|
|
50
|
+
$python = Get-Command python3 -ErrorAction SilentlyContinue
|
|
51
|
+
if (-not $python) { $python = Get-Command python -ErrorAction SilentlyContinue }
|
|
52
|
+
if (-not $python) { exit 0 }
|
|
53
|
+
|
|
54
|
+
# --- Delegate to Python enforcer ---
|
|
55
|
+
$env:TOOL_NAME = $toolName
|
|
56
|
+
$env:TRANSCRIPT_PATH = $transcriptPath
|
|
57
|
+
$env:SESSION_ID = $sessionId
|
|
58
|
+
$env:CWD = $cwd
|
|
59
|
+
|
|
60
|
+
$pyScript = @'
|
|
61
|
+
import json
|
|
62
|
+
import os
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
sys.path.insert(0, os.environ["ARKAOS_ROOT"])
|
|
66
|
+
try:
|
|
67
|
+
from core.workflow.flow_enforcer import evaluate, record_telemetry
|
|
68
|
+
except Exception:
|
|
69
|
+
print(json.dumps({"allow": True, "reason": "enforcer-import-failed"}))
|
|
70
|
+
sys.exit(0)
|
|
71
|
+
|
|
72
|
+
decision = evaluate(
|
|
73
|
+
tool_name=os.environ.get("TOOL_NAME", ""),
|
|
74
|
+
transcript_path=os.environ.get("TRANSCRIPT_PATH", ""),
|
|
75
|
+
session_id=os.environ.get("SESSION_ID", ""),
|
|
76
|
+
cwd=os.environ.get("CWD", ""),
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
record_telemetry(
|
|
80
|
+
session_id=os.environ.get("SESSION_ID", ""),
|
|
81
|
+
tool=os.environ.get("TOOL_NAME", ""),
|
|
82
|
+
decision=decision,
|
|
83
|
+
cwd=os.environ.get("CWD", ""),
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
print(json.dumps({
|
|
88
|
+
"allow": decision.allow,
|
|
89
|
+
"reason": decision.reason,
|
|
90
|
+
"stderr_msg": decision.to_stderr_message(),
|
|
91
|
+
}))
|
|
92
|
+
'@
|
|
93
|
+
|
|
94
|
+
$decisionJson = $pyScript | & $python.Source -
|
|
95
|
+
if ([string]::IsNullOrWhiteSpace($decisionJson)) { exit 0 }
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
$decision = $decisionJson | ConvertFrom-Json
|
|
99
|
+
} catch {
|
|
100
|
+
exit 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if ($decision.allow) { exit 0 }
|
|
104
|
+
|
|
105
|
+
# --- Deny path ---
|
|
106
|
+
[Console]::Error.WriteLine($decision.stderr_msg)
|
|
107
|
+
|
|
108
|
+
$denyOut = @{
|
|
109
|
+
hookSpecificOutput = @{
|
|
110
|
+
hookEventName = "PreToolUse"
|
|
111
|
+
permissionDecision = "deny"
|
|
112
|
+
permissionDecisionReason = $decision.stderr_msg
|
|
113
|
+
}
|
|
114
|
+
} | ConvertTo-Json -Compress -Depth 5
|
|
115
|
+
|
|
116
|
+
Write-Output $denyOut
|
|
117
|
+
exit 2
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# ArkaOS v2 — PreToolUse Hook (Flow Enforcement Gate)
|
|
4
|
+
#
|
|
5
|
+
# Blocks Write/Edit/MultiEdit when the mandatory 13-phase flow is required
|
|
6
|
+
# for the session AND the assistant has not emitted a flow marker
|
|
7
|
+
# (`[arka:routing]`, `[arka:trivial]`, or `[arka:phase:`) in its last
|
|
8
|
+
# 3 messages of the transcript.
|
|
9
|
+
#
|
|
10
|
+
# Delegates the decision to core/workflow/flow_enforcer.py (single source
|
|
11
|
+
# of truth, pytest-covered). This shell script is a thin wrapper — anti
|
|
12
|
+
# pattern `duplicated-security-logic` compliance.
|
|
13
|
+
#
|
|
14
|
+
# Timeout: 10s.
|
|
15
|
+
# Allow semantics: no stdout, exit 0.
|
|
16
|
+
# Deny semantics: hookSpecificOutput.permissionDecision=deny JSON on stdout,
|
|
17
|
+
# `[ARKA:ENFORCEMENT] ...` on stderr, exit 2.
|
|
18
|
+
# ============================================================================
|
|
19
|
+
|
|
20
|
+
input=$(cat)
|
|
21
|
+
|
|
22
|
+
# ─── Extract fields (docs: session_id, transcript_path, cwd, tool_name)
|
|
23
|
+
TOOL_NAME=""
|
|
24
|
+
TRANSCRIPT_PATH=""
|
|
25
|
+
SESSION_ID=""
|
|
26
|
+
CWD=""
|
|
27
|
+
if command -v jq &>/dev/null; then
|
|
28
|
+
TOOL_NAME=$(echo "$input" | jq -r '.tool_name // ""' 2>/dev/null)
|
|
29
|
+
TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // ""' 2>/dev/null)
|
|
30
|
+
SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null)
|
|
31
|
+
CWD=$(echo "$input" | jq -r '.cwd // ""' 2>/dev/null)
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# ─── Fast allow: not a gated tool
|
|
35
|
+
case "$TOOL_NAME" in
|
|
36
|
+
Write|Edit|MultiEdit) ;;
|
|
37
|
+
*) exit 0 ;;
|
|
38
|
+
esac
|
|
39
|
+
|
|
40
|
+
# ─── Resolve ARKAOS_ROOT (same rules as user-prompt-submit.sh) ──────────
|
|
41
|
+
if [ -z "${ARKAOS_ROOT:-}" ]; then
|
|
42
|
+
if [ -f "$HOME/.arkaos/.repo-path" ]; then
|
|
43
|
+
ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path")
|
|
44
|
+
elif [ -d "$HOME/.arkaos" ]; then
|
|
45
|
+
ARKAOS_ROOT="$HOME/.arkaos"
|
|
46
|
+
else
|
|
47
|
+
ARKAOS_ROOT="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# ─── Degrade gracefully if Python or module is unavailable ──────────────
|
|
52
|
+
if ! command -v python3 &>/dev/null; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
if [ ! -f "$ARKAOS_ROOT/core/workflow/flow_enforcer.py" ]; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# ─── Delegate to Python enforcer ────────────────────────────────────────
|
|
60
|
+
DECISION_JSON=$(TOOL_NAME="$TOOL_NAME" \
|
|
61
|
+
TRANSCRIPT_PATH="$TRANSCRIPT_PATH" \
|
|
62
|
+
SESSION_ID="$SESSION_ID" \
|
|
63
|
+
CWD="$CWD" \
|
|
64
|
+
ARKAOS_ROOT="$ARKAOS_ROOT" \
|
|
65
|
+
python3 - <<'PY' 2>/dev/null
|
|
66
|
+
import json
|
|
67
|
+
import os
|
|
68
|
+
import sys
|
|
69
|
+
|
|
70
|
+
sys.path.insert(0, os.environ["ARKAOS_ROOT"])
|
|
71
|
+
try:
|
|
72
|
+
from core.workflow.flow_enforcer import evaluate, record_telemetry
|
|
73
|
+
except Exception:
|
|
74
|
+
print(json.dumps({"allow": True, "reason": "enforcer-import-failed"}))
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
decision = evaluate(
|
|
78
|
+
tool_name=os.environ.get("TOOL_NAME", ""),
|
|
79
|
+
transcript_path=os.environ.get("TRANSCRIPT_PATH", ""),
|
|
80
|
+
session_id=os.environ.get("SESSION_ID", ""),
|
|
81
|
+
cwd=os.environ.get("CWD", ""),
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
record_telemetry(
|
|
85
|
+
session_id=os.environ.get("SESSION_ID", ""),
|
|
86
|
+
tool=os.environ.get("TOOL_NAME", ""),
|
|
87
|
+
decision=decision,
|
|
88
|
+
cwd=os.environ.get("CWD", ""),
|
|
89
|
+
)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
print(json.dumps({
|
|
93
|
+
"allow": decision.allow,
|
|
94
|
+
"reason": decision.reason,
|
|
95
|
+
"stderr_msg": decision.to_stderr_message(),
|
|
96
|
+
}))
|
|
97
|
+
PY
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if [ -z "$DECISION_JSON" ]; then
|
|
101
|
+
exit 0
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
ALLOW=$(echo "$DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('allow', True))" 2>/dev/null)
|
|
105
|
+
|
|
106
|
+
if [ "$ALLOW" = "True" ] || [ "$ALLOW" = "true" ]; then
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# ─── Deny path: structured hookSpecificOutput + stderr fallback ─────────
|
|
111
|
+
REASON=$(echo "$DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('reason',''))" 2>/dev/null)
|
|
112
|
+
STDERR_MSG=$(echo "$DECISION_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('stderr_msg',''))" 2>/dev/null)
|
|
113
|
+
|
|
114
|
+
# Emit stderr (visible to the model per Claude Code hook spec) ─────────
|
|
115
|
+
echo "$STDERR_MSG" >&2
|
|
116
|
+
|
|
117
|
+
# Emit structured deny JSON (preferred path when runtime understands it).
|
|
118
|
+
# STDERR_MSG is passed via env var and read inside a single-quoted heredoc
|
|
119
|
+
# so no shell interpolation occurs inside the Python source — this closes
|
|
120
|
+
# the command-injection surface flagged by Francisca's tech review.
|
|
121
|
+
STDERR_MSG="$STDERR_MSG" python3 - <<'PY'
|
|
122
|
+
import json
|
|
123
|
+
import os
|
|
124
|
+
|
|
125
|
+
out = {
|
|
126
|
+
"hookSpecificOutput": {
|
|
127
|
+
"hookEventName": "PreToolUse",
|
|
128
|
+
"permissionDecision": "deny",
|
|
129
|
+
"permissionDecisionReason": os.environ.get("STDERR_MSG", ""),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
print(json.dumps(out))
|
|
133
|
+
PY
|
|
134
|
+
|
|
135
|
+
exit 2
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# ArkaOS v2 — Stop Hook (Windows PowerShell, WARN mode v1)
|
|
3
|
+
#
|
|
4
|
+
# Parity with config/hooks/stop.sh. Observes whether a flow-required session
|
|
5
|
+
# closed with [arka:phase:13] or [arka:trivial]. Logs to telemetry; never
|
|
6
|
+
# blocks in v1.
|
|
7
|
+
# ============================================================================
|
|
8
|
+
|
|
9
|
+
$ErrorActionPreference = "SilentlyContinue"
|
|
10
|
+
|
|
11
|
+
$inputJson = [Console]::In.ReadToEnd()
|
|
12
|
+
if ([string]::IsNullOrWhiteSpace($inputJson)) { exit 0 }
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
$inp = $inputJson | ConvertFrom-Json
|
|
16
|
+
} catch {
|
|
17
|
+
exit 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
$sessionId = [string]$inp.session_id
|
|
21
|
+
$transcriptPath = [string]$inp.transcript_path
|
|
22
|
+
$stopHookActive = [string]$inp.stop_hook_active
|
|
23
|
+
$cwd = [string]$inp.cwd
|
|
24
|
+
|
|
25
|
+
if ($stopHookActive -eq "true") { exit 0 }
|
|
26
|
+
|
|
27
|
+
$wfMarker = Join-Path "/tmp/arkaos-wf-required" $sessionId
|
|
28
|
+
if ([string]::IsNullOrWhiteSpace($sessionId) -or -not (Test-Path $wfMarker)) {
|
|
29
|
+
exit 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if ([string]::IsNullOrWhiteSpace($env:ARKAOS_ROOT)) {
|
|
33
|
+
$repoPathFile = Join-Path $HOME ".arkaos/.repo-path"
|
|
34
|
+
if (Test-Path $repoPathFile) {
|
|
35
|
+
$env:ARKAOS_ROOT = (Get-Content $repoPathFile -Raw).Trim()
|
|
36
|
+
} elseif (Test-Path (Join-Path $HOME ".arkaos")) {
|
|
37
|
+
$env:ARKAOS_ROOT = (Join-Path $HOME ".arkaos")
|
|
38
|
+
} else {
|
|
39
|
+
$env:ARKAOS_ROOT = if ($env:ARKA_OS) { $env:ARKA_OS } else { Join-Path $HOME ".claude/skills/arkaos" }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
$enforcerPy = Join-Path $env:ARKAOS_ROOT "core/workflow/flow_enforcer.py"
|
|
44
|
+
if (-not (Test-Path $enforcerPy)) { exit 0 }
|
|
45
|
+
|
|
46
|
+
$python = Get-Command python3 -ErrorAction SilentlyContinue
|
|
47
|
+
if (-not $python) { $python = Get-Command python -ErrorAction SilentlyContinue }
|
|
48
|
+
if (-not $python) { exit 0 }
|
|
49
|
+
|
|
50
|
+
$env:SESSION_ID_VAL = $sessionId
|
|
51
|
+
$env:TRANSCRIPT_PATH_VAL = $transcriptPath
|
|
52
|
+
$env:CWD_VAL = $cwd
|
|
53
|
+
|
|
54
|
+
$pyScript = @'
|
|
55
|
+
import json
|
|
56
|
+
import os
|
|
57
|
+
import re
|
|
58
|
+
import sys
|
|
59
|
+
from datetime import datetime, timezone
|
|
60
|
+
|
|
61
|
+
sys.path.insert(0, os.environ["ARKAOS_ROOT"])
|
|
62
|
+
try:
|
|
63
|
+
from core.workflow.flow_enforcer import (
|
|
64
|
+
_load_last_assistant_messages,
|
|
65
|
+
TELEMETRY_PATH,
|
|
66
|
+
clear_flow_required,
|
|
67
|
+
)
|
|
68
|
+
except Exception:
|
|
69
|
+
sys.exit(0)
|
|
70
|
+
|
|
71
|
+
session_id = os.environ.get("SESSION_ID_VAL", "")
|
|
72
|
+
transcript_path = os.environ.get("TRANSCRIPT_PATH_VAL", "")
|
|
73
|
+
cwd = os.environ.get("CWD_VAL", "")
|
|
74
|
+
|
|
75
|
+
messages = _load_last_assistant_messages(transcript_path, n=1)
|
|
76
|
+
last = messages[-1] if messages else ""
|
|
77
|
+
|
|
78
|
+
phase13 = bool(re.search(r"\[arka:phase:13\]", last, re.IGNORECASE))
|
|
79
|
+
trivial = bool(re.search(r"\[arka:trivial\]", last, re.IGNORECASE))
|
|
80
|
+
|
|
81
|
+
entry = {
|
|
82
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
83
|
+
"session_id": session_id,
|
|
84
|
+
"cwd": cwd,
|
|
85
|
+
"event": "stop-hook-flow-check",
|
|
86
|
+
"closing_marker_found": phase13 or trivial,
|
|
87
|
+
"phase13": phase13,
|
|
88
|
+
"trivial": trivial,
|
|
89
|
+
"mode": "warn",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
with TELEMETRY_PATH.open("a", encoding="utf-8") as fh:
|
|
95
|
+
fh.write(json.dumps(entry) + "\n")
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
clear_flow_required(session_id)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
'@
|
|
104
|
+
|
|
105
|
+
$pyScript | & $python.Source - | Out-Null
|
|
106
|
+
|
|
107
|
+
# Belt-and-braces marker cleanup (safe even if the Python block crashed).
|
|
108
|
+
if ($sessionId -match '^[A-Za-z0-9._-]{1,128}$') {
|
|
109
|
+
Remove-Item -LiteralPath $wfMarker -ErrorAction SilentlyContinue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
exit 0
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# ArkaOS v2 — Stop Hook (Flow Completion Validator, WARN mode v1)
|
|
4
|
+
#
|
|
5
|
+
# When the classifier marked the session as flow-required, this hook checks
|
|
6
|
+
# whether the final assistant message contains [arka:phase:13] or
|
|
7
|
+
# [arka:trivial]. If absent, a structured warning is appended to
|
|
8
|
+
# ~/.arkaos/telemetry/enforcement.jsonl. The hook NEVER blocks in v1.
|
|
9
|
+
#
|
|
10
|
+
# Promotion to strict mode is planned for v2.21.0 after ≥ 2 weeks of clean
|
|
11
|
+
# telemetry. Until then, this hook is observation only.
|
|
12
|
+
#
|
|
13
|
+
# Timeout: 5s | Always exit 0.
|
|
14
|
+
# ============================================================================
|
|
15
|
+
|
|
16
|
+
input=$(cat)
|
|
17
|
+
|
|
18
|
+
SESSION_ID=""
|
|
19
|
+
TRANSCRIPT_PATH=""
|
|
20
|
+
STOP_HOOK_ACTIVE=""
|
|
21
|
+
CWD=""
|
|
22
|
+
if command -v jq &>/dev/null; then
|
|
23
|
+
SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null)
|
|
24
|
+
TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // ""' 2>/dev/null)
|
|
25
|
+
STOP_HOOK_ACTIVE=$(echo "$input" | jq -r '.stop_hook_active // ""' 2>/dev/null)
|
|
26
|
+
CWD=$(echo "$input" | jq -r '.cwd // ""' 2>/dev/null)
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# Prevent infinite loops when Stop hook was triggered by its own decision.
|
|
30
|
+
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Only evaluate sessions where the classifier flagged a creation intent.
|
|
35
|
+
WF_MARKER="/tmp/arkaos-wf-required/$SESSION_ID"
|
|
36
|
+
if [ -z "$SESSION_ID" ] || [ ! -f "$WF_MARKER" ]; then
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Resolve ARKAOS_ROOT
|
|
41
|
+
if [ -z "${ARKAOS_ROOT:-}" ]; then
|
|
42
|
+
if [ -f "$HOME/.arkaos/.repo-path" ]; then
|
|
43
|
+
ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path")
|
|
44
|
+
elif [ -d "$HOME/.arkaos" ]; then
|
|
45
|
+
ARKAOS_ROOT="$HOME/.arkaos"
|
|
46
|
+
else
|
|
47
|
+
ARKAOS_ROOT="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
if ! command -v python3 &>/dev/null; then
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
if [ ! -f "$ARKAOS_ROOT/core/workflow/flow_enforcer.py" ]; then
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Reuse the last-messages reader to check for a closing phase marker.
|
|
59
|
+
SESSION_ID_VAL="$SESSION_ID" \
|
|
60
|
+
TRANSCRIPT_PATH_VAL="$TRANSCRIPT_PATH" \
|
|
61
|
+
CWD_VAL="$CWD" \
|
|
62
|
+
ARKAOS_ROOT_VAL="$ARKAOS_ROOT" \
|
|
63
|
+
python3 - <<'PY' 2>/dev/null
|
|
64
|
+
import json
|
|
65
|
+
import os
|
|
66
|
+
import re
|
|
67
|
+
import sys
|
|
68
|
+
from datetime import datetime, timezone
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
|
|
71
|
+
sys.path.insert(0, os.environ["ARKAOS_ROOT_VAL"])
|
|
72
|
+
try:
|
|
73
|
+
from core.workflow.flow_enforcer import (
|
|
74
|
+
_load_last_assistant_messages,
|
|
75
|
+
TELEMETRY_PATH,
|
|
76
|
+
clear_flow_required,
|
|
77
|
+
)
|
|
78
|
+
except Exception:
|
|
79
|
+
sys.exit(0)
|
|
80
|
+
|
|
81
|
+
session_id = os.environ.get("SESSION_ID_VAL", "")
|
|
82
|
+
transcript_path = os.environ.get("TRANSCRIPT_PATH_VAL", "")
|
|
83
|
+
cwd = os.environ.get("CWD_VAL", "")
|
|
84
|
+
|
|
85
|
+
# Only inspect the very last assistant message for closing markers.
|
|
86
|
+
messages = _load_last_assistant_messages(transcript_path, n=1)
|
|
87
|
+
last = messages[-1] if messages else ""
|
|
88
|
+
|
|
89
|
+
phase13 = bool(re.search(r"\[arka:phase:13\]", last, re.IGNORECASE))
|
|
90
|
+
trivial = bool(re.search(r"\[arka:trivial\]", last, re.IGNORECASE))
|
|
91
|
+
closing_ok = phase13 or trivial
|
|
92
|
+
|
|
93
|
+
entry = {
|
|
94
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
95
|
+
"session_id": session_id,
|
|
96
|
+
"cwd": cwd,
|
|
97
|
+
"event": "stop-hook-flow-check",
|
|
98
|
+
"closing_marker_found": closing_ok,
|
|
99
|
+
"phase13": phase13,
|
|
100
|
+
"trivial": trivial,
|
|
101
|
+
"mode": "warn",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
with TELEMETRY_PATH.open("a", encoding="utf-8") as fh:
|
|
107
|
+
fh.write(json.dumps(entry) + "\n")
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# Clean up the session marker once Stop has evaluated.
|
|
112
|
+
try:
|
|
113
|
+
clear_flow_required(session_id)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
PY
|
|
117
|
+
|
|
118
|
+
# Belt-and-braces: remove the marker at shell level in case the Python
|
|
119
|
+
# block above crashed before reaching clear_flow_required(). Session_id
|
|
120
|
+
# is already validated by the Python helper; this shell remove is scoped
|
|
121
|
+
# to the exact marker path and is idempotent.
|
|
122
|
+
case "$SESSION_ID" in
|
|
123
|
+
*[!A-Za-z0-9._-]*|"") ;; # reject unsafe/empty session ids
|
|
124
|
+
*) rm -f "$WF_MARKER" 2>/dev/null ;;
|
|
125
|
+
esac
|
|
126
|
+
|
|
127
|
+
exit 0
|
|
@@ -83,14 +83,23 @@ mkdir -p "$CACHE_DIR" 2>/dev/null
|
|
|
83
83
|
|
|
84
84
|
# ─── Extract user input from hook JSON ───────────────────────────────────
|
|
85
85
|
user_input=""
|
|
86
|
+
SESSION_ID=""
|
|
86
87
|
if command -v jq &>/dev/null; then
|
|
87
88
|
user_input=$(echo "$input" | jq -r '.userInput // .message // ""' 2>/dev/null)
|
|
89
|
+
SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null)
|
|
88
90
|
fi
|
|
89
91
|
# Fallback: try to get the raw text
|
|
90
92
|
if [ -z "$user_input" ]; then
|
|
91
93
|
user_input=$(echo "$input" | head -c 2000)
|
|
92
94
|
fi
|
|
93
95
|
|
|
96
|
+
# ─── Load shared workflow classifier ─────────────────────────────────────
|
|
97
|
+
_CLASSIFIER_LIB="$(dirname "$0")/_lib/workflow-classifier.sh"
|
|
98
|
+
if [ -f "$_CLASSIFIER_LIB" ]; then
|
|
99
|
+
# shellcheck disable=SC1090
|
|
100
|
+
. "$_CLASSIFIER_LIB"
|
|
101
|
+
fi
|
|
102
|
+
|
|
94
103
|
# ─── Try Python Synapse bridge first ────────────────────────────────────
|
|
95
104
|
python_result=""
|
|
96
105
|
BRIDGE_SCRIPT="${ARKAOS_ROOT}/scripts/synapse-bridge.py"
|
|
@@ -276,21 +285,18 @@ When [knowledge:N chunks] is present, cite at least one source.
|
|
|
276
285
|
If [knowledge:N chunks] is absent on a non-trivial ArkaOS topic, query Obsidian first."
|
|
277
286
|
|
|
278
287
|
# ─── Workflow Classifier (hard enforcement for creation/implementation) ──
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
# untouched. Explicit slash commands pass through untouched.
|
|
288
|
+
# Uses the shared _lib/workflow-classifier.sh. When a creation/implementation
|
|
289
|
+
# verb is detected, the session is marked as flow-required so PreToolUse
|
|
290
|
+
# can block Write/Edit/MultiEdit until the agent emits [arka:routing] or
|
|
291
|
+
# [arka:trivial]. Explicit slash commands and bang shells pass through.
|
|
284
292
|
_WORKFLOW_DIRECTIVE=""
|
|
285
|
-
if [ -n "$user_input" ]; then
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if echo "$user_input" | grep -qiE "\b${_VERB_PATTERN}\b"; then
|
|
293
|
-
_WORKFLOW_DIRECTIVE="
|
|
293
|
+
if [ -n "$user_input" ] && command -v arka_wf_classify &>/dev/null; then
|
|
294
|
+
if [ "$(arka_wf_classify "$user_input")" = "true" ]; then
|
|
295
|
+
# Mark session as flow-required (consumed by pre-tool-use.sh and stop.sh)
|
|
296
|
+
if command -v arka_wf_mark_required &>/dev/null; then
|
|
297
|
+
arka_wf_mark_required "$SESSION_ID"
|
|
298
|
+
fi
|
|
299
|
+
_WORKFLOW_DIRECTIVE="
|
|
294
300
|
[ARKA:WORKFLOW-REQUIRED] Your user request matched a CREATION/IMPLEMENTATION pattern.
|
|
295
301
|
The ArkaOS mandatory 13-phase flow applies. It is NON-NEGOTIABLE (constitution rule
|
|
296
302
|
mandatory-flow). You MUST walk every phase, in order, emitting a [arka:phase:N] tag
|
|
@@ -323,7 +329,6 @@ Anything else runs the full 13 phases. Source: arka/skills/flow/SKILL.md.
|
|
|
323
329
|
This is enforced by the hook and the session-start systemMessage, not by convention.
|
|
324
330
|
Skipping violates: mandatory-flow, squad-routing, spec-driven, mandatory-qa,
|
|
325
331
|
sequential-validation, full-visibility, arka-supremacy."
|
|
326
|
-
fi
|
|
327
332
|
fi
|
|
328
333
|
fi
|
|
329
334
|
|
|
Binary file
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Mandatory 13-phase flow enforcement for write-mutation tools.
|
|
2
|
+
|
|
3
|
+
Invoked by the Claude Code `PreToolUse` hook. Decides whether a `Write`,
|
|
4
|
+
`Edit`, or `MultiEdit` tool call may proceed, based on markers observed
|
|
5
|
+
in the last N assistant messages of the session transcript.
|
|
6
|
+
|
|
7
|
+
Design contract:
|
|
8
|
+
- Stateless transcript parse (no /tmp state for decisions).
|
|
9
|
+
- Side effects limited to reading the transcript path supplied by the hook.
|
|
10
|
+
- Signals permission when the assistant has emitted one of the flow markers:
|
|
11
|
+
`[arka:routing]`, `[arka:trivial]`, or `[arka:phase:`.
|
|
12
|
+
- Respects `ARKA_BYPASS_FLOW=1` env var (installer/`/arka update` internal).
|
|
13
|
+
- Honors feature flag `hooks.hardEnforcement` in `~/.arkaos/config.json`.
|
|
14
|
+
- Gated tool list is closed: anything outside it is always allowed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import fcntl # POSIX only
|
|
27
|
+
_HAS_FLOCK = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
_HAS_FLOCK = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def _locked_append(path: Path):
|
|
34
|
+
"""Append to `path` under an exclusive advisory lock (POSIX flock).
|
|
35
|
+
|
|
36
|
+
On Windows or any platform without fcntl, falls back to a plain append
|
|
37
|
+
(single-process writers remain safe; cross-process interleaving is
|
|
38
|
+
mitigated by `O_APPEND` atomicity for writes up to PIPE_BUF).
|
|
39
|
+
"""
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
fh = path.open("a", encoding="utf-8")
|
|
42
|
+
try:
|
|
43
|
+
if _HAS_FLOCK:
|
|
44
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
45
|
+
yield fh
|
|
46
|
+
finally:
|
|
47
|
+
if _HAS_FLOCK:
|
|
48
|
+
try:
|
|
49
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
fh.close()
|
|
53
|
+
|
|
54
|
+
GATED_TOOLS: frozenset[str] = frozenset({"Write", "Edit", "MultiEdit"})
|
|
55
|
+
|
|
56
|
+
ROUTING_RE = re.compile(r"\[arka:routing\]\s*[\w-]+\s*->\s*\w+", re.IGNORECASE)
|
|
57
|
+
TRIVIAL_RE = re.compile(r"\[arka:trivial\]\s*\S+", re.IGNORECASE)
|
|
58
|
+
PHASE_RE = re.compile(r"\[arka:phase:\d+\]", re.IGNORECASE)
|
|
59
|
+
SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
60
|
+
|
|
61
|
+
ASSISTANT_WINDOW = 3
|
|
62
|
+
CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
63
|
+
BYPASS_AUDIT_PATH = Path.home() / ".arkaos" / "audit" / "bypass.log"
|
|
64
|
+
TELEMETRY_PATH = Path.home() / ".arkaos" / "telemetry" / "enforcement.jsonl"
|
|
65
|
+
FLOW_REQUIRED_DIR = Path("/tmp/arkaos-wf-required")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _safe_session_id(session_id: str) -> str | None:
|
|
69
|
+
"""Validate session_id against a strict allowlist (prevents path traversal).
|
|
70
|
+
|
|
71
|
+
Returns the id if safe, or None if it contains path separators, dots-dots,
|
|
72
|
+
or characters outside `[A-Za-z0-9._-]`. Callers MUST treat None as reject.
|
|
73
|
+
"""
|
|
74
|
+
if not session_id or not isinstance(session_id, str):
|
|
75
|
+
return None
|
|
76
|
+
if not SAFE_SESSION_ID_RE.match(session_id):
|
|
77
|
+
return None
|
|
78
|
+
return session_id
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Decision:
|
|
83
|
+
"""Outcome of enforcement evaluation for a single tool call."""
|
|
84
|
+
|
|
85
|
+
allow: bool
|
|
86
|
+
reason: str
|
|
87
|
+
marker_found: str | None = None
|
|
88
|
+
phase_observed: str | None = None
|
|
89
|
+
bypass_used: bool = False
|
|
90
|
+
|
|
91
|
+
def to_stderr_message(self) -> str:
|
|
92
|
+
if self.allow:
|
|
93
|
+
return ""
|
|
94
|
+
return (
|
|
95
|
+
f"[ARKA:ENFORCEMENT] Flow marker missing. "
|
|
96
|
+
f"Emit `[arka:routing] <dept> -> <lead>` or `[arka:trivial] <reason>` "
|
|
97
|
+
f"before any Write/Edit/MultiEdit. Reason: {self.reason}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _feature_flag_on() -> bool:
|
|
102
|
+
if not CONFIG_PATH.exists():
|
|
103
|
+
return False
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
106
|
+
except (json.JSONDecodeError, OSError):
|
|
107
|
+
return False
|
|
108
|
+
return bool(data.get("hooks", {}).get("hardEnforcement", False))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _bypass_env_active() -> bool:
|
|
112
|
+
return os.environ.get("ARKA_BYPASS_FLOW", "").strip() == "1"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _audit_bypass(session_id: str, tool: str, cwd: str) -> None:
|
|
116
|
+
entry = {
|
|
117
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
118
|
+
"session_id": session_id,
|
|
119
|
+
"tool": tool,
|
|
120
|
+
"cwd": cwd,
|
|
121
|
+
"reason": os.environ.get("ARKA_BYPASS_REASON", ""),
|
|
122
|
+
}
|
|
123
|
+
with _locked_append(BYPASS_AUDIT_PATH) as fh:
|
|
124
|
+
fh.write(json.dumps(entry) + "\n")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def record_telemetry(
|
|
128
|
+
session_id: str, tool: str, decision: Decision, cwd: str
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Append a structured record to the enforcement telemetry log."""
|
|
131
|
+
entry = {
|
|
132
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
133
|
+
"session_id": session_id,
|
|
134
|
+
"tool": tool,
|
|
135
|
+
"cwd": cwd,
|
|
136
|
+
**asdict(decision),
|
|
137
|
+
}
|
|
138
|
+
with _locked_append(TELEMETRY_PATH) as fh:
|
|
139
|
+
fh.write(json.dumps(entry) + "\n")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _flow_required_for_session(session_id: str) -> bool:
|
|
143
|
+
"""Check whether the UserPromptSubmit classifier flagged this session."""
|
|
144
|
+
safe = _safe_session_id(session_id)
|
|
145
|
+
if safe is None:
|
|
146
|
+
return False
|
|
147
|
+
marker = FLOW_REQUIRED_DIR / safe
|
|
148
|
+
return marker.exists()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _extract_text(content: object) -> str:
|
|
152
|
+
"""Flatten Claude transcript message content into a single string."""
|
|
153
|
+
if isinstance(content, str):
|
|
154
|
+
return content
|
|
155
|
+
if isinstance(content, list):
|
|
156
|
+
parts: list[str] = []
|
|
157
|
+
for item in content:
|
|
158
|
+
if isinstance(item, dict):
|
|
159
|
+
if "text" in item:
|
|
160
|
+
parts.append(str(item["text"]))
|
|
161
|
+
elif item.get("type") == "tool_use":
|
|
162
|
+
parts.append(f"<tool_use:{item.get('name', '')}>")
|
|
163
|
+
elif isinstance(item, str):
|
|
164
|
+
parts.append(item)
|
|
165
|
+
return "\n".join(parts)
|
|
166
|
+
return ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _load_last_assistant_messages(transcript_path: str, n: int) -> list[str]:
|
|
170
|
+
"""Read the last `n` assistant messages from a JSONL transcript."""
|
|
171
|
+
path = Path(transcript_path) if transcript_path else None
|
|
172
|
+
if path is None or not path.exists():
|
|
173
|
+
return []
|
|
174
|
+
messages: list[str] = []
|
|
175
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
176
|
+
if not line.strip():
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
record = json.loads(line)
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
continue
|
|
182
|
+
role = record.get("role") or record.get("message", {}).get("role")
|
|
183
|
+
if role != "assistant":
|
|
184
|
+
continue
|
|
185
|
+
content = record.get("content")
|
|
186
|
+
if content is None:
|
|
187
|
+
content = record.get("message", {}).get("content")
|
|
188
|
+
text = _extract_text(content)
|
|
189
|
+
if text:
|
|
190
|
+
messages.append(text)
|
|
191
|
+
return messages[-n:]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _scan_markers(messages: list[str]) -> tuple[str | None, str | None]:
|
|
195
|
+
"""Return (marker_found, phase_observed) across the given messages."""
|
|
196
|
+
marker_found: str | None = None
|
|
197
|
+
phase_observed: str | None = None
|
|
198
|
+
for text in messages:
|
|
199
|
+
if phase_observed is None:
|
|
200
|
+
phase_match = PHASE_RE.search(text)
|
|
201
|
+
if phase_match:
|
|
202
|
+
phase_observed = phase_match.group(0)
|
|
203
|
+
if marker_found is None:
|
|
204
|
+
if ROUTING_RE.search(text):
|
|
205
|
+
marker_found = "routing"
|
|
206
|
+
elif TRIVIAL_RE.search(text):
|
|
207
|
+
marker_found = "trivial"
|
|
208
|
+
elif PHASE_RE.search(text):
|
|
209
|
+
marker_found = "phase"
|
|
210
|
+
return marker_found, phase_observed
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def evaluate(
|
|
214
|
+
tool_name: str,
|
|
215
|
+
transcript_path: str,
|
|
216
|
+
session_id: str = "",
|
|
217
|
+
cwd: str = "",
|
|
218
|
+
) -> Decision:
|
|
219
|
+
"""Decide whether a tool call may proceed.
|
|
220
|
+
|
|
221
|
+
Returns a Decision. Caller is responsible for translating `allow=False`
|
|
222
|
+
into the appropriate hook exit code or permissionDecision output.
|
|
223
|
+
"""
|
|
224
|
+
if tool_name not in GATED_TOOLS:
|
|
225
|
+
return Decision(allow=True, reason="tool-not-gated")
|
|
226
|
+
|
|
227
|
+
if not _feature_flag_on():
|
|
228
|
+
return Decision(allow=True, reason="feature-flag-off")
|
|
229
|
+
|
|
230
|
+
if _bypass_env_active():
|
|
231
|
+
_audit_bypass(session_id, tool_name, cwd)
|
|
232
|
+
return Decision(allow=True, reason="env-bypass", bypass_used=True)
|
|
233
|
+
|
|
234
|
+
if not _flow_required_for_session(session_id):
|
|
235
|
+
return Decision(allow=True, reason="classifier-did-not-match")
|
|
236
|
+
|
|
237
|
+
messages = _load_last_assistant_messages(transcript_path, ASSISTANT_WINDOW)
|
|
238
|
+
marker_found, phase_observed = _scan_markers(messages)
|
|
239
|
+
|
|
240
|
+
if marker_found is None:
|
|
241
|
+
return Decision(
|
|
242
|
+
allow=False,
|
|
243
|
+
reason="no-flow-marker-in-last-3-assistant-messages",
|
|
244
|
+
phase_observed=phase_observed,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return Decision(
|
|
248
|
+
allow=True,
|
|
249
|
+
reason=f"marker-found:{marker_found}",
|
|
250
|
+
marker_found=marker_found,
|
|
251
|
+
phase_observed=phase_observed,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def mark_flow_required(session_id: str) -> None:
|
|
256
|
+
"""Invoked by UserPromptSubmit when classifier matches creation intent."""
|
|
257
|
+
safe = _safe_session_id(session_id)
|
|
258
|
+
if safe is None:
|
|
259
|
+
return
|
|
260
|
+
FLOW_REQUIRED_DIR.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
marker = FLOW_REQUIRED_DIR / safe
|
|
262
|
+
marker.write_text(datetime.now(timezone.utc).isoformat(), encoding="utf-8")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def clear_flow_required(session_id: str) -> None:
|
|
266
|
+
"""Clear the flow-required marker (end of session / rollout tooling)."""
|
|
267
|
+
safe = _safe_session_id(session_id)
|
|
268
|
+
if safe is None:
|
|
269
|
+
return
|
|
270
|
+
marker = FLOW_REQUIRED_DIR / safe
|
|
271
|
+
if marker.exists():
|
|
272
|
+
marker.unlink()
|
|
@@ -94,6 +94,19 @@ export default {
|
|
|
94
94
|
{ hooks: [hookEntry(hooksDir, "post-tool-use", 5)] },
|
|
95
95
|
];
|
|
96
96
|
|
|
97
|
+
// PreToolUse — Flow enforcement gate (gated by hooks.hardEnforcement
|
|
98
|
+
// feature flag in ~/.arkaos/config.json; no-op when flag is false).
|
|
99
|
+
settings.hooks.PreToolUse = [
|
|
100
|
+
{ hooks: [hookEntry(hooksDir, "pre-tool-use", 10)] },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Stop — Flow completion validator (WARN mode in v2.20.0; promotion
|
|
104
|
+
// to STRICT mode is gated on ≥ 2 weeks of clean telemetry per ADR
|
|
105
|
+
// 2026-04-17-binding-flow-enforcement).
|
|
106
|
+
settings.hooks.Stop = [
|
|
107
|
+
{ hooks: [hookEntry(hooksDir, "stop", 5)] },
|
|
108
|
+
];
|
|
109
|
+
|
|
97
110
|
// PreCompact — Session digest
|
|
98
111
|
settings.hooks.PreCompact = [
|
|
99
112
|
{ hooks: [hookEntry(hooksDir, "pre-compact", 30)] },
|
package/installer/doctor.js
CHANGED
|
@@ -62,7 +62,19 @@ const checks = [
|
|
|
62
62
|
name: "hooks-dir",
|
|
63
63
|
description: "Hook scripts installed",
|
|
64
64
|
severity: "fail",
|
|
65
|
-
check: () =>
|
|
65
|
+
check: () => {
|
|
66
|
+
const required = [
|
|
67
|
+
"session-start",
|
|
68
|
+
"user-prompt-submit",
|
|
69
|
+
"post-tool-use",
|
|
70
|
+
"pre-compact",
|
|
71
|
+
"cwd-changed",
|
|
72
|
+
"pre-tool-use",
|
|
73
|
+
"stop",
|
|
74
|
+
];
|
|
75
|
+
const hooksDir = join(INSTALL_DIR, "config", "hooks");
|
|
76
|
+
return required.every((h) => existsSync(join(hooksDir, `${h}${HOOK_EXT}`)));
|
|
77
|
+
},
|
|
66
78
|
fix: () => "Run: npx arkaos install --force",
|
|
67
79
|
},
|
|
68
80
|
{
|
package/installer/index.js
CHANGED
|
@@ -450,6 +450,8 @@ function installHooks(installDir) {
|
|
|
450
450
|
"post-tool-use",
|
|
451
451
|
"pre-compact",
|
|
452
452
|
"cwd-changed",
|
|
453
|
+
"pre-tool-use",
|
|
454
|
+
"stop",
|
|
453
455
|
];
|
|
454
456
|
const hookExt = HOOK_EXT;
|
|
455
457
|
|
|
@@ -481,6 +483,19 @@ function installHooks(installDir) {
|
|
|
481
483
|
ok(`Hook: ${filename}`);
|
|
482
484
|
}
|
|
483
485
|
}
|
|
486
|
+
|
|
487
|
+
const srcLibDir = join(srcHooksDir, "_lib");
|
|
488
|
+
if (existsSync(srcLibDir)) {
|
|
489
|
+
const destLibDir = join(hooksDir, "_lib");
|
|
490
|
+
ensureDir(destLibDir);
|
|
491
|
+
cpSync(srcLibDir, destLibDir, { recursive: true });
|
|
492
|
+
try {
|
|
493
|
+
for (const f of readdirSync(destLibDir)) {
|
|
494
|
+
if (f.endsWith(".sh")) chmodSync(join(destLibDir, f), 0o755);
|
|
495
|
+
}
|
|
496
|
+
} catch {}
|
|
497
|
+
ok("Hook lib: _lib/");
|
|
498
|
+
}
|
|
484
499
|
}
|
|
485
500
|
|
|
486
501
|
// Safe directory iteration. Returns an empty array instead of throwing
|
package/package.json
CHANGED