@onlooker-community/ecosystem 0.17.0 → 0.19.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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +26 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -2
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +1 -0
  6. package/package.json +2 -2
  7. package/plugins/counsel/.claude-plugin/plugin.json +14 -0
  8. package/plugins/counsel/CHANGELOG.md +8 -0
  9. package/plugins/counsel/config.json +20 -0
  10. package/plugins/counsel/hooks/hooks.json +15 -0
  11. package/plugins/counsel/scripts/hooks/counsel-session-start.sh +106 -0
  12. package/plugins/counsel/scripts/lib/counsel-brief.sh +247 -0
  13. package/plugins/counsel/scripts/lib/counsel-config.sh +72 -0
  14. package/plugins/counsel/scripts/lib/counsel-events.sh +80 -0
  15. package/plugins/counsel/scripts/lib/counsel-project-key.sh +79 -0
  16. package/plugins/counsel/scripts/lib/counsel-reader.sh +114 -0
  17. package/plugins/counsel/scripts/lib/counsel-synthesize.sh +103 -0
  18. package/plugins/counsel/scripts/lib/counsel-ulid.sh +45 -0
  19. package/plugins/warden/.claude-plugin/plugin.json +14 -0
  20. package/plugins/warden/CHANGELOG.md +10 -0
  21. package/plugins/warden/config.json +51 -0
  22. package/plugins/warden/docs/adr/001-detect-after-ingest-gate-before-action.md +62 -0
  23. package/plugins/warden/docs/design.md +123 -0
  24. package/plugins/warden/hooks/hooks.json +73 -0
  25. package/plugins/warden/scripts/hooks/warden-post-tool-use.sh +201 -0
  26. package/plugins/warden/scripts/hooks/warden-pre-tool-use.sh +94 -0
  27. package/plugins/warden/scripts/hooks/warden-session-start.sh +52 -0
  28. package/plugins/warden/scripts/lib/warden-cli.sh +124 -0
  29. package/plugins/warden/scripts/lib/warden-config.sh +79 -0
  30. package/plugins/warden/scripts/lib/warden-evaluator.sh +246 -0
  31. package/plugins/warden/scripts/lib/warden-events.sh +85 -0
  32. package/plugins/warden/scripts/lib/warden-gate-state.sh +105 -0
  33. package/plugins/warden/scripts/lib/warden-patterns.sh +132 -0
  34. package/plugins/warden/scripts/lib/warden-sanitizer.sh +80 -0
  35. package/plugins/warden/scripts/lib/warden-scanner.sh +119 -0
  36. package/plugins/warden/scripts/lib/warden-ulid.sh +50 -0
  37. package/plugins/warden/skills/warden/SKILL.md +49 -0
  38. package/release-please-config.json +32 -0
  39. package/test/bats/counsel-project-key.bats +82 -0
  40. package/test/bats/counsel-reader.bats +132 -0
  41. package/test/bats/warden-config.bats +54 -0
  42. package/test/bats/warden-events.bats +85 -0
  43. package/test/bats/warden-gate-state.bats +67 -0
  44. package/test/bats/warden-patterns.bats +58 -0
  45. package/test/bats/warden-sanitizer.bats +53 -0
  46. package/test/bats/warden-scanner.bats +56 -0
  47. package/test/bats/warden-ulid.bats +30 -0
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # Input sanitization for Warden evaluator-bound content.
3
+ #
4
+ # Applied before any ingested content is interpolated into the escalation
5
+ # evaluator prompt. The content warden scans is, by definition, untrusted —
6
+ # so before it is shown to the evaluator it must be neutralized against a
7
+ # second-order injection (content that tries to talk the evaluator out of
8
+ # flagging it).
9
+ #
10
+ # Exposes:
11
+ # warden_sanitize <string> <max_chars> # echoes sanitized, truncated string
12
+ #
13
+ # Sanitization steps (applied in order):
14
+ # 1. Null-byte removal
15
+ # 2. Control-character removal (0x00–0x1F and 0x7F, except \t and \n)
16
+ # 3. Prompt-delimiter stripping (evaluator prompt tag sequences → [STRIPPED])
17
+ # 4. Truncation to max_chars
18
+
19
+ # Tags that, if present in scanned content, would inject into the evaluator prompt.
20
+ _WARDEN_STRIP_SEQUENCES=(
21
+ "<source_content>"
22
+ "</source_content>"
23
+ "<instructions>"
24
+ "</instructions>"
25
+ "<|"
26
+ "[INST]"
27
+ "[/INST]"
28
+ "<<SYS>>"
29
+ "<</SYS>>"
30
+ )
31
+
32
+ # Remove null bytes and ASCII control characters except \t (0x09) and \n (0x0A).
33
+ _warden_strip_control_chars() {
34
+ local input="$1"
35
+ printf '%s' "$input" \
36
+ | tr -d '\000-\010\013-\037\177' \
37
+ 2>/dev/null
38
+ }
39
+
40
+ # Replace all occurrences of a literal string with [STRIPPED].
41
+ #
42
+ # Uses bash native substring replacement rather than sed: the strip sequences
43
+ # contain '/', '[', and '|', any of which would collide with sed's delimiter
44
+ # or regex syntax. Quoting the needle in ${var//"needle"/repl} forces a literal
45
+ # (non-glob) match that is safe for arbitrary bytes.
46
+ _warden_strip_literal() {
47
+ local input="$1"
48
+ local needle="$2"
49
+ printf '%s' "${input//"$needle"/[STRIPPED]}"
50
+ }
51
+
52
+ # Truncate a string to at most max_chars characters.
53
+ _warden_truncate() {
54
+ local input="$1"
55
+ local max_chars="${2:-0}"
56
+ if [[ "$max_chars" -le 0 ]]; then
57
+ printf '%s' "$input"
58
+ return
59
+ fi
60
+ printf '%s' "$input" | cut -c "1-${max_chars}" 2>/dev/null
61
+ }
62
+
63
+ # Full sanitization pipeline. Echoes the sanitized string.
64
+ # $1 — raw input string
65
+ # $2 — max chars (0 = no truncation)
66
+ warden_sanitize() {
67
+ local input="$1"
68
+ local max_chars="${2:-0}"
69
+
70
+ local s
71
+ s=$(_warden_strip_control_chars "$input")
72
+
73
+ local seq
74
+ for seq in "${_WARDEN_STRIP_SEQUENCES[@]}"; do
75
+ s=$(_warden_strip_literal "$s" "$seq")
76
+ done
77
+
78
+ s=$(_warden_truncate "$s" "$max_chars")
79
+ printf '%s' "$s"
80
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # Hybrid scanner orchestration for Warden.
3
+ #
4
+ # Combines the deterministic pattern floor (warden-patterns.sh) with optional
5
+ # LLM escalation (warden-evaluator.sh):
6
+ #
7
+ # strong pattern hit → detected immediately (no model call)
8
+ # weak pattern hit → escalate to the evaluator when enabled; otherwise
9
+ # fall back to the weak-pattern confidence
10
+ # no hit → clean (no model call)
11
+ #
12
+ # On evaluator error the scanner falls back to the pattern verdict, so a model
13
+ # outage degrades coverage but never silently closes the gate on every read.
14
+ #
15
+ # Depends on (sourced by the caller):
16
+ # warden-config.sh · warden-patterns.sh · warden-sanitizer.sh · warden-evaluator.sh
17
+ #
18
+ # Exposes:
19
+ # warden_scan <source_type> <content>
20
+ # → JSON {"detected":bool, "threat_type":"<t>", "confidence":<f>,
21
+ # "matched_pattern":"<p>", "method":"<m>", "rationale":"<str>"}
22
+
23
+ # awk-based float >= comparison. Returns 0 (true) if $1 >= $2.
24
+ #
25
+ # Values are passed via `awk -v` (data), never interpolated into the program
26
+ # string: thresholds can originate from repo-level .claude/settings.json, which
27
+ # is untrusted under warden's threat model. -v also makes non-numeric input
28
+ # degrade to 0 rather than executing as awk code.
29
+ _warden_ge() {
30
+ awk -v a="${1:-0}" -v b="${2:-0}" 'BEGIN {exit !(a >= b)}' 2>/dev/null
31
+ }
32
+
33
+ _warden_scan_result() {
34
+ local detected="$1" threat="$2" confidence="$3" pattern="$4" method="$5" rationale="$6"
35
+ jq -n \
36
+ --argjson detected "$detected" \
37
+ --arg t "$threat" \
38
+ --argjson c "${confidence:-0}" \
39
+ --arg p "$pattern" \
40
+ --arg m "$method" \
41
+ --arg r "$rationale" \
42
+ '{detected:$detected, threat_type:$t, confidence:$c, matched_pattern:$p, method:$m, rationale:$r}' \
43
+ 2>/dev/null \
44
+ || printf '{"detected":%s,"threat_type":"%s","confidence":%s,"matched_pattern":"%s","method":"%s","rationale":"%s"}' \
45
+ "$detected" "$threat" "${confidence:-0}" "$pattern" "$method" "$rationale"
46
+ }
47
+
48
+ warden_scan() {
49
+ local source_type="$1"
50
+ local content="$2"
51
+
52
+ local close_threshold strong_conf weak_conf
53
+ close_threshold=$(warden_config_get '.warden.detection.close_threshold')
54
+ close_threshold="${close_threshold:-0.65}"
55
+ strong_conf=$(warden_config_get '.warden.detection.strong_pattern_confidence')
56
+ strong_conf="${strong_conf:-0.9}"
57
+ weak_conf=$(warden_config_get '.warden.detection.weak_pattern_confidence')
58
+ weak_conf="${weak_conf:-0.5}"
59
+
60
+ local classify severity threat pattern
61
+ classify=$(warden_pattern_classify "$content")
62
+ severity=$(printf '%s' "$classify" | jq -r '.severity // "none"' 2>/dev/null) || severity="none"
63
+ threat=$(printf '%s' "$classify" | jq -r '.threat_type // "none"' 2>/dev/null) || threat="none"
64
+ pattern=$(printf '%s' "$classify" | jq -r '.matched_pattern // ""' 2>/dev/null) || pattern=""
65
+
66
+ # ---- Clean: no signal at all. ------------------------------------
67
+ if [[ "$severity" == "none" ]]; then
68
+ _warden_scan_result false "none" 0 "" "none" "no injection pattern matched"
69
+ return 0
70
+ fi
71
+
72
+ # ---- Strong: explicit, high-precision phrasing. ------------------
73
+ if [[ "$severity" == "strong" ]]; then
74
+ local detected="false"
75
+ _warden_ge "$strong_conf" "$close_threshold" && detected="true"
76
+ _warden_scan_result "$detected" "$threat" "$strong_conf" "$pattern" "pattern_strong" "matched a strong injection signature"
77
+ return 0
78
+ fi
79
+
80
+ # ---- Weak: borderline. Escalate when enabled. --------------------
81
+ local escalation_enabled
82
+ escalation_enabled=$(warden_config_get '.warden.escalation.enabled')
83
+ escalation_enabled="${escalation_enabled:-true}"
84
+
85
+ if [[ "$escalation_enabled" == "true" ]]; then
86
+ local max_chars excerpt
87
+ max_chars=$(warden_config_get '.warden.scan.max_content_chars')
88
+ max_chars="${max_chars:-20000}"
89
+ excerpt=$(warden_sanitize "$content" "$max_chars")
90
+
91
+ local eval_result decision eval_conf eval_threat eval_rationale
92
+ eval_result=$(warden_evaluate "$source_type" "$excerpt" "$threat")
93
+ decision=$(printf '%s' "$eval_result" | jq -r '.decision // "error"' 2>/dev/null) || decision="error"
94
+ eval_conf=$(printf '%s' "$eval_result" | jq -r '.confidence // 0' 2>/dev/null) || eval_conf="0"
95
+ eval_threat=$(printf '%s' "$eval_result" | jq -r '.threat_type // "none"' 2>/dev/null) || eval_threat="none"
96
+ eval_rationale=$(printf '%s' "$eval_result" | jq -r '.rationale // ""' 2>/dev/null) || eval_rationale=""
97
+
98
+ if [[ "$decision" == "injection" ]]; then
99
+ [[ "$eval_threat" == "none" || -z "$eval_threat" ]] && eval_threat="$threat"
100
+ local detected="false"
101
+ _warden_ge "$eval_conf" "$close_threshold" && detected="true"
102
+ _warden_scan_result "$detected" "$eval_threat" "$eval_conf" "$pattern" "escalation" "$eval_rationale"
103
+ return 0
104
+ fi
105
+
106
+ if [[ "$decision" == "clean" ]]; then
107
+ _warden_scan_result false "none" "$eval_conf" "$pattern" "escalation" "evaluator judged the borderline content clean"
108
+ return 0
109
+ fi
110
+
111
+ # decision == error → fall back to the weak-pattern verdict below.
112
+ fi
113
+
114
+ # ---- Weak fallback: no escalation, or evaluator errored. ---------
115
+ local detected="false"
116
+ _warden_ge "$weak_conf" "$close_threshold" && detected="true"
117
+ _warden_scan_result "$detected" "$threat" "$weak_conf" "$pattern" "pattern_weak" "weak injection signal; escalation unavailable"
118
+ return 0
119
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for Warden threat_id values.
3
+ #
4
+ # Spec: https://github.com/ulid/spec
5
+ # - 48-bit timestamp (ms since epoch) → 10 chars Crockford Base32
6
+ # - 80-bit randomness → 16 chars Crockford Base32
7
+ # - lexicographically sortable, time-ordered
8
+
9
+ _WARDEN_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
10
+
11
+ _warden_ulid_encode() {
12
+ local n="$1"
13
+ local len="$2"
14
+ local out=""
15
+ local i
16
+ for ((i = 0; i < len; i++)); do
17
+ out="${_WARDEN_ULID_ALPHABET:$((n % 32)):1}${out}"
18
+ n=$((n / 32))
19
+ done
20
+ printf '%s' "$out"
21
+ }
22
+
23
+ warden_ulid() {
24
+ local now_ms
25
+ if [[ "$(uname)" == "Darwin" ]]; then
26
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
27
+ || now_ms=$(($(date +%s) * 1000))
28
+ else
29
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
30
+ fi
31
+
32
+ local rand_hex rand_hi rand_lo
33
+ rand_hex=$(openssl rand -hex 10 2>/dev/null)
34
+ if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
35
+ rand_hi=$((16#${rand_hex:0:10}))
36
+ rand_lo=$((16#${rand_hex:10:10}))
37
+ else
38
+ rand_hi=$((RANDOM * 32768 + RANDOM))
39
+ rand_lo=$((RANDOM * 32768 + RANDOM))
40
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
41
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
42
+ fi
43
+
44
+ local ts_part hi_part lo_part
45
+ ts_part=$(_warden_ulid_encode "$now_ms" 10)
46
+ hi_part=$(_warden_ulid_encode "$rand_hi" 8)
47
+ lo_part=$(_warden_ulid_encode "$rand_lo" 8)
48
+
49
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
50
+ }
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: warden
3
+ description: Inspect and control the Warden content gate. Shows whether the session's content gate is open or closed and the threat that closed it (`/warden` or `/warden status`), and explicitly clears a closed gate to re-enable Write/Edit/Bash (`/warden clear`). Clearing is the only sanctioned way to reopen the gate — it records a user override in the warden.* event stream. Use when Warden has blocked a write/edit/bash operation, or when the user asks to check or clear the content gate.
4
+ ---
5
+
6
+ # Warden: Content Gate Control
7
+
8
+ You are operating the **Warden** content gate — the user-facing control surface for the gate that Warden's hooks open and close automatically.
9
+
10
+ Warden enforces Meta's **Agents Rule of Two**: an agent should hold at most two of {access to private data, ability to take external actions, processing of untrusted content}. When Warden's detection hook finds an injection pattern in content ingested via WebFetch or Read, it closes a session-scoped gate that revokes the *external actions* property — blocking Write, Edit, MultiEdit, and Bash until the user explicitly clears it. This skill is that explicit clear (and a status readout).
11
+
12
+ ## Parse the request
13
+
14
+ Read the user's argument after `/warden`:
15
+
16
+ - no argument, or `status` → **status** action
17
+ - `clear`, `reopen`, `override`, `unblock` → **clear** action
18
+
19
+ If the user passed a session id explicitly (rare), capture it as the optional second argument.
20
+
21
+ ## Run the control surface
22
+
23
+ Source the plugin helpers and invoke `warden_cli`. Run this in a single bash call:
24
+
25
+ ```bash
26
+ set -uo pipefail
27
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/warden-config.sh"
28
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/warden-events.sh"
29
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/warden-gate-state.sh"
30
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/warden-cli.sh"
31
+
32
+ # action is "status" or "clear"; SESSION_ID_ARG is optional and usually empty.
33
+ warden_cli "<action>" "${SESSION_ID_ARG:-}"
34
+ ```
35
+
36
+ `warden_cli` resolves the session automatically: it prefers `$CLAUDE_SESSION_ID`, falls back to the single closed gate if exactly one exists, and reports ambiguity if several sessions have closed gates (re-run with an explicit session id in that case).
37
+
38
+ ## Behavior
39
+
40
+ - **status** — prints whether the gate is OPEN or CLOSED. When closed, prints the recorded threat: `threat_type`, `source_type`, source URL/path, confidence, detection method, matched pattern, and the flagged snippet (if storage is enabled).
41
+ - **clear** — verifies the gate is closed, removes the lock, and emits `warden.threat.cleared` with `cleared_by: user_override`. This re-enables Write/Edit/Bash for the session.
42
+
43
+ ## After clearing
44
+
45
+ When you clear the gate on the user's behalf:
46
+
47
+ 1. Confirm the gate is reopened and name the source that triggered it.
48
+ 2. Remind the user briefly that the flagged content is still in the conversation context — clearing the gate does not remove it. If they have not reviewed the source, suggest they do before continuing with external actions.
49
+ 3. Do not clear a gate the user has not asked you to clear. Closing is automatic; clearing is always a deliberate user decision.
@@ -126,6 +126,38 @@
126
126
  "jsonpath": "$.version"
127
127
  }
128
128
  ]
129
+ },
130
+ "plugins/counsel": {
131
+ "changelog-path": "CHANGELOG.md",
132
+ "release-type": "simple",
133
+ "bump-minor-pre-major": true,
134
+ "bump-patch-for-minor-pre-major": false,
135
+ "component": "counsel",
136
+ "draft": false,
137
+ "prerelease": false,
138
+ "extra-files": [
139
+ {
140
+ "type": "json",
141
+ "path": ".claude-plugin/plugin.json",
142
+ "jsonpath": "$.version"
143
+ }
144
+ ]
145
+ },
146
+ "plugins/warden": {
147
+ "changelog-path": "CHANGELOG.md",
148
+ "release-type": "simple",
149
+ "bump-minor-pre-major": true,
150
+ "bump-patch-for-minor-pre-major": false,
151
+ "component": "warden",
152
+ "draft": false,
153
+ "prerelease": false,
154
+ "extra-files": [
155
+ {
156
+ "type": "json",
157
+ "path": ".claude-plugin/plugin.json",
158
+ "jsonpath": "$.version"
159
+ }
160
+ ]
129
161
  }
130
162
  },
131
163
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ setup_test_env
7
+
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/counsel"
9
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
10
+ # shellcheck disable=SC1091
11
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-project-key.sh"
12
+ }
13
+
14
+ @test "non-git directory returns empty key" {
15
+ local d="${BATS_TEST_TMPDIR}/non-git"
16
+ mkdir -p "$d"
17
+ run counsel_project_key "$d"
18
+ [ "$status" -eq 0 ]
19
+ [ -z "$output" ]
20
+ }
21
+
22
+ @test "git repo without remote falls back to repo-root hash" {
23
+ local d="${BATS_TEST_TMPDIR}/local-only-repo"
24
+ mkdir -p "$d"
25
+ git -C "$d" init -q
26
+ git -C "$d" config user.email t@example.com
27
+ git -C "$d" config user.name "Test"
28
+
29
+ local k1
30
+ k1=$(counsel_project_key "$d")
31
+ [ -n "$k1" ]
32
+ [ "${#k1}" -eq 12 ]
33
+
34
+ local k2
35
+ k2=$(counsel_project_key "$d")
36
+ [ "$k1" = "$k2" ]
37
+ }
38
+
39
+ @test "git repo with remote uses remote hash, ignores local path" {
40
+ local a="${BATS_TEST_TMPDIR}/clone-a"
41
+ local b="${BATS_TEST_TMPDIR}/clone-b"
42
+ mkdir -p "$a" "$b"
43
+ for d in "$a" "$b"; do
44
+ git -C "$d" init -q
45
+ git -C "$d" config user.email t@example.com
46
+ git -C "$d" config user.name "Test"
47
+ git -C "$d" remote add origin git@github.com:org/proj.git
48
+ done
49
+
50
+ local ka kb
51
+ ka=$(counsel_project_key "$a")
52
+ kb=$(counsel_project_key "$b")
53
+ [ -n "$ka" ]
54
+ [ "$ka" = "$kb" ]
55
+ }
56
+
57
+ @test "different remotes yield different keys" {
58
+ local a="${BATS_TEST_TMPDIR}/proj-a"
59
+ local b="${BATS_TEST_TMPDIR}/proj-b"
60
+ mkdir -p "$a" "$b"
61
+ for d in "$a" "$b"; do
62
+ git -C "$d" init -q
63
+ git -C "$d" config user.email t@example.com
64
+ git -C "$d" config user.name "Test"
65
+ done
66
+ git -C "$a" remote add origin git@github.com:org/proj-a.git
67
+ git -C "$b" remote add origin git@github.com:org/proj-b.git
68
+
69
+ local ka kb
70
+ ka=$(counsel_project_key "$a")
71
+ kb=$(counsel_project_key "$b")
72
+ [ -n "$ka" ]
73
+ [ -n "$kb" ]
74
+ [ "$ka" != "$kb" ]
75
+ }
76
+
77
+ @test "counsel_project_dir includes project key in path" {
78
+ local key="abc123def456"
79
+ run counsel_project_dir "$key"
80
+ [ "$status" -eq 0 ]
81
+ [[ "$output" == *"counsel/${key}/briefs" ]]
82
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ setup_test_env
7
+
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/counsel"
9
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
10
+ # shellcheck disable=SC1091
11
+ source "${PLUGIN_ROOT}/scripts/lib/counsel-reader.sh"
12
+ }
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # counsel_count_events
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @test "count_events returns 0 for empty input" {
19
+ run counsel_count_events ""
20
+ [ "$status" -eq 0 ]
21
+ [ "$output" = "0" ]
22
+ }
23
+
24
+ @test "count_events counts non-blank lines" {
25
+ local text
26
+ text=$(printf '%s\n%s\n%s\n' '{"type":"a"}' '{"type":"b"}' '{"type":"c"}')
27
+ run counsel_count_events "$text"
28
+ [ "$status" -eq 0 ]
29
+ [ "$output" = "3" ]
30
+ }
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # counsel_sources_from_events
34
+ # ---------------------------------------------------------------------------
35
+
36
+ @test "sources_from_events returns onlooker_events for unknown types" {
37
+ local text='{"type":"scribe.distill.complete"}'
38
+ run counsel_sources_from_events "$text"
39
+ [ "$status" -eq 0 ]
40
+ [[ "$output" == *"onlooker_events"* ]]
41
+ }
42
+
43
+ @test "sources_from_events detects tribunal events" {
44
+ local text='{"type":"tribunal.gate.blocked"}'
45
+ run counsel_sources_from_events "$text"
46
+ [ "$status" -eq 0 ]
47
+ [[ "$output" == *"tribunal_verdicts"* ]]
48
+ }
49
+
50
+ @test "sources_from_events detects echo events" {
51
+ local text='{"type":"echo.regression.detected"}'
52
+ run counsel_sources_from_events "$text"
53
+ [ "$status" -eq 0 ]
54
+ [[ "$output" == *"echo_regressions"* ]]
55
+ }
56
+
57
+ @test "sources_from_events returns onlooker_events for empty input" {
58
+ run counsel_sources_from_events ""
59
+ [ "$status" -eq 0 ]
60
+ [ "$output" = '["onlooker_events"]' ]
61
+ }
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # counsel_read_events — file-based
65
+ # ---------------------------------------------------------------------------
66
+
67
+ @test "read_events returns empty when log does not exist" {
68
+ export ONLOOKER_EVENTS_LOG="${BATS_TEST_TMPDIR}/no-log.jsonl"
69
+ run counsel_read_events "30" "60000"
70
+ [ "$status" -eq 0 ]
71
+ [ -z "$output" ]
72
+ }
73
+
74
+ @test "read_events returns empty for empty log" {
75
+ local log="${BATS_TEST_TMPDIR}/empty-log.jsonl"
76
+ touch "$log"
77
+ export ONLOOKER_EVENTS_LOG="$log"
78
+ run counsel_read_events "30" "60000"
79
+ [ "$status" -eq 0 ]
80
+ [ -z "$output" ]
81
+ }
82
+
83
+ @test "read_events filters events within lookback window" {
84
+ local log="${BATS_TEST_TMPDIR}/events.jsonl"
85
+ # Use a timestamp far in the future to ensure it passes any lookback filter.
86
+ local ts
87
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
88
+ printf '%s\n' \
89
+ "{\"event_type\":\"scribe.distill.complete\",\"timestamp\":\"${ts}\",\"session_id\":\"s1\",\"payload\":{}}" \
90
+ > "$log"
91
+ export ONLOOKER_EVENTS_LOG="$log"
92
+ run counsel_read_events "30" "60000"
93
+ [ "$status" -eq 0 ]
94
+ [[ "$output" == *"scribe.distill.complete"* ]]
95
+ }
96
+
97
+ @test "read_events output is JSONL-shaped: one object per line" {
98
+ local log="${BATS_TEST_TMPDIR}/multi-events.jsonl"
99
+ local ts
100
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
101
+ printf '%s\n' \
102
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s1\",\"payload\":{}}" \
103
+ "{\"event_type\":\"echo.regression.detected\",\"timestamp\":\"${ts}\",\"session_id\":\"s2\",\"payload\":{}}" \
104
+ "{\"event_type\":\"scribe.distill.complete\",\"timestamp\":\"${ts}\",\"session_id\":\"s3\",\"payload\":{}}" \
105
+ > "$log"
106
+ export ONLOOKER_EVENTS_LOG="$log"
107
+
108
+ local events
109
+ events=$(counsel_read_events "30" "60000")
110
+
111
+ # count_events must see exactly 3 records, not inflated by pretty-printing.
112
+ run counsel_count_events "$events"
113
+ [ "$status" -eq 0 ]
114
+ [ "$output" = "3" ]
115
+ }
116
+
117
+ @test "read_events output preserves source types for sources_from_events" {
118
+ local log="${BATS_TEST_TMPDIR}/source-events.jsonl"
119
+ local ts
120
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
121
+ printf '%s\n' \
122
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s1\",\"payload\":{}}" \
123
+ > "$log"
124
+ export ONLOOKER_EVENTS_LOG="$log"
125
+
126
+ local events
127
+ events=$(counsel_read_events "30" "60000")
128
+
129
+ run counsel_sources_from_events "$events"
130
+ [ "$status" -eq 0 ]
131
+ [[ "$output" == *"tribunal_verdicts"* ]]
132
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/warden"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/warden-config.sh"
11
+ }
12
+
13
+ @test "warden is disabled by default" {
14
+ warden_config_load ""
15
+ run warden_config_enabled
16
+ [ "$status" -ne 0 ]
17
+ }
18
+
19
+ @test "user-level settings.json can enable warden" {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"warden":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ warden_config_load ""
23
+ run warden_config_enabled
24
+ [ "$status" -eq 0 ]
25
+ }
26
+
27
+ @test "repo-level settings.json overrides user-level" {
28
+ mkdir -p "${HOME}/.claude"
29
+ printf '%s\n' '{"warden":{"enabled":true}}' > "${HOME}/.claude/settings.json"
30
+ local repo="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "${repo}/.claude"
32
+ printf '%s\n' '{"warden":{"enabled":false}}' > "${repo}/.claude/settings.json"
33
+ warden_config_load "$repo"
34
+ run warden_config_enabled
35
+ [ "$status" -ne 0 ]
36
+ }
37
+
38
+ @test "defaults are preserved when an overlay sets only some keys" {
39
+ mkdir -p "${HOME}/.claude"
40
+ printf '%s\n' '{"warden":{"enabled":true,"escalation":{"enabled":false}}}' > "${HOME}/.claude/settings.json"
41
+ warden_config_load ""
42
+ # escalation.enabled overridden to false…
43
+ [ "$(warden_config_get '.warden.escalation.enabled')" = "false" ]
44
+ # …but shipped defaults survive the deep merge.
45
+ [ "$(warden_config_get '.warden.detection.close_threshold')" = "0.65" ]
46
+ [ "$(warden_config_get '.warden.scan.max_content_chars')" = "20000" ]
47
+ }
48
+
49
+ @test "config_get_json returns arrays" {
50
+ warden_config_load ""
51
+ run warden_config_get_json '.warden.scan.sources'
52
+ [ "$status" -eq 0 ]
53
+ printf '%s' "$output" | jq -e 'index("web_fetch") != null and index("file_read") != null' >/dev/null
54
+ }