@onlooker-community/ecosystem 0.18.0 → 0.20.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +13 -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/docs/memory-architecture.md +102 -0
  7. package/package.json +3 -3
  8. package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
  9. package/plugins/curator/docs/design.md +311 -0
  10. package/plugins/historian/docs/adr/001-local-embeddings-only.md +96 -0
  11. package/plugins/historian/docs/design.md +317 -0
  12. package/plugins/librarian/.claude-plugin/plugin.json +14 -0
  13. package/plugins/librarian/CHANGELOG.md +10 -0
  14. package/plugins/librarian/README.md +51 -0
  15. package/plugins/librarian/config.json +52 -0
  16. package/plugins/librarian/docs/adr/001-propose-dont-auto-write.md +87 -0
  17. package/plugins/librarian/docs/design.md +301 -0
  18. package/plugins/librarian/hooks/hooks.json +26 -0
  19. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +312 -0
  20. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +103 -0
  21. package/plugins/librarian/scripts/lib/librarian-archivist-reader.sh +67 -0
  22. package/plugins/librarian/scripts/lib/librarian-classifier.sh +139 -0
  23. package/plugins/librarian/scripts/lib/librarian-config.sh +74 -0
  24. package/plugins/librarian/scripts/lib/librarian-durability.sh +77 -0
  25. package/plugins/librarian/scripts/lib/librarian-emit.sh +72 -0
  26. package/plugins/librarian/scripts/lib/librarian-project-key.sh +83 -0
  27. package/plugins/librarian/scripts/lib/librarian-storage.sh +222 -0
  28. package/plugins/librarian/scripts/lib/librarian-ulid.sh +50 -0
  29. package/plugins/warden/.claude-plugin/plugin.json +14 -0
  30. package/plugins/warden/CHANGELOG.md +10 -0
  31. package/plugins/warden/config.json +51 -0
  32. package/plugins/warden/docs/adr/001-detect-after-ingest-gate-before-action.md +62 -0
  33. package/plugins/warden/docs/design.md +123 -0
  34. package/plugins/warden/hooks/hooks.json +73 -0
  35. package/plugins/warden/scripts/hooks/warden-post-tool-use.sh +201 -0
  36. package/plugins/warden/scripts/hooks/warden-pre-tool-use.sh +94 -0
  37. package/plugins/warden/scripts/hooks/warden-session-start.sh +52 -0
  38. package/plugins/warden/scripts/lib/warden-cli.sh +124 -0
  39. package/plugins/warden/scripts/lib/warden-config.sh +79 -0
  40. package/plugins/warden/scripts/lib/warden-evaluator.sh +246 -0
  41. package/plugins/warden/scripts/lib/warden-events.sh +85 -0
  42. package/plugins/warden/scripts/lib/warden-gate-state.sh +105 -0
  43. package/plugins/warden/scripts/lib/warden-patterns.sh +132 -0
  44. package/plugins/warden/scripts/lib/warden-sanitizer.sh +80 -0
  45. package/plugins/warden/scripts/lib/warden-scanner.sh +119 -0
  46. package/plugins/warden/scripts/lib/warden-ulid.sh +50 -0
  47. package/plugins/warden/skills/warden/SKILL.md +49 -0
  48. package/release-please-config.json +32 -0
  49. package/test/bats/librarian-session-end.bats +182 -0
  50. package/test/bats/librarian-session-start.bats +136 -0
  51. package/test/bats/warden-config.bats +54 -0
  52. package/test/bats/warden-events.bats +85 -0
  53. package/test/bats/warden-gate-state.bats +67 -0
  54. package/test/bats/warden-patterns.bats +58 -0
  55. package/test/bats/warden-sanitizer.bats +53 -0
  56. package/test/bats/warden-scanner.bats +56 -0
  57. package/test/bats/warden-ulid.bats +30 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env bash
2
+ # Warden PostToolUse hook — detection path for WebFetch and Read.
3
+ #
4
+ # Fires after content has been ingested. Extracts the returned content,
5
+ # runs the hybrid scanner, and on a positive detection closes the session
6
+ # gate and emits warden.threat.detected.
7
+ #
8
+ # Why PostToolUse and not PreToolUse: the fetched/read content does not exist
9
+ # until the tool runs, and the threat model is what the agent does NEXT with
10
+ # that content. PostToolUse cannot (and need not) block the read itself — the
11
+ # PreToolUse enforcement hook blocks the downstream external action. See
12
+ # docs/adr/001-detect-after-ingest-gate-before-action.md.
13
+ #
14
+ # Hook contract:
15
+ # - Always exits 0. Never blocks PostToolUse.
16
+ # - Errors are written to stderr only; stdout is kept clean.
17
+
18
+ set -uo pipefail
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
22
+
23
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
24
+
25
+ # shellcheck source=../lib/warden-config.sh
26
+ source "${PLUGIN_ROOT}/scripts/lib/warden-config.sh"
27
+ # shellcheck source=../lib/warden-events.sh
28
+ source "${PLUGIN_ROOT}/scripts/lib/warden-events.sh"
29
+ # shellcheck source=../lib/warden-sanitizer.sh
30
+ source "${PLUGIN_ROOT}/scripts/lib/warden-sanitizer.sh"
31
+ # shellcheck source=../lib/warden-patterns.sh
32
+ source "${PLUGIN_ROOT}/scripts/lib/warden-patterns.sh"
33
+ # shellcheck source=../lib/warden-evaluator.sh
34
+ source "${PLUGIN_ROOT}/scripts/lib/warden-evaluator.sh"
35
+ # shellcheck source=../lib/warden-scanner.sh
36
+ source "${PLUGIN_ROOT}/scripts/lib/warden-scanner.sh"
37
+ # shellcheck source=../lib/warden-gate-state.sh
38
+ source "${PLUGIN_ROOT}/scripts/lib/warden-gate-state.sh"
39
+ # shellcheck source=../lib/warden-ulid.sh
40
+ source "${PLUGIN_ROOT}/scripts/lib/warden-ulid.sh"
41
+
42
+ INPUT=$(cat)
43
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
44
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
45
+ TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || TOOL_NAME=""
46
+
47
+ export _HOOK_SESSION_ID="$SESSION_ID"
48
+
49
+ _done() { exit 0; }
50
+
51
+ warden_config_load "$CWD"
52
+
53
+ if ! warden_config_enabled; then
54
+ _done
55
+ fi
56
+
57
+ [[ -z "$SESSION_ID" ]] && _done
58
+
59
+ # If the gate is already closed, there is nothing more to do — it stays closed
60
+ # until the user clears it. Skip the (potentially paid) scan entirely.
61
+ if warden_gate_is_closed "$SESSION_ID"; then
62
+ _done
63
+ fi
64
+
65
+ # ---- Resolve source_type from the tool name. -------------------------
66
+ SOURCE_TYPE=""
67
+ SOURCE_URL=""
68
+ SOURCE_PATH=""
69
+ case "$TOOL_NAME" in
70
+ WebFetch)
71
+ SOURCE_TYPE="web_fetch"
72
+ SOURCE_URL=$(printf '%s' "$INPUT" | jq -r '.tool_input.url // ""' 2>/dev/null) || SOURCE_URL=""
73
+ ;;
74
+ Read)
75
+ SOURCE_TYPE="file_read"
76
+ SOURCE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null) || SOURCE_PATH=""
77
+ ;;
78
+ *)
79
+ _done
80
+ ;;
81
+ esac
82
+
83
+ # Honor configured scan.sources.
84
+ SOURCES_JSON=$(warden_config_get_json '.warden.scan.sources') || SOURCES_JSON="[]"
85
+ if ! printf '%s' "$SOURCES_JSON" | jq -e --arg s "$SOURCE_TYPE" 'index($s) != null' >/dev/null 2>&1; then
86
+ _done
87
+ fi
88
+
89
+ # ---- skip_globs (file reads only). -----------------------------------
90
+ _matches_skip_glob() {
91
+ local file_path="$1"
92
+ local globs_json="$2"
93
+ [[ -z "$file_path" || -z "$globs_json" ]] && return 1
94
+ # bash 3.2 (macOS default) has no `mapfile`; collect with a while-read loop.
95
+ local globs=() glob pattern
96
+ while IFS= read -r glob; do
97
+ [[ -n "$glob" ]] && globs+=("$glob")
98
+ done < <(printf '%s' "$globs_json" | jq -r '.[]' 2>/dev/null)
99
+ for glob in "${globs[@]}"; do
100
+ pattern="${glob//\*\*/DOUBLE_STAR}"
101
+ pattern="${pattern//\*/[^/]*}"
102
+ pattern="${pattern//DOUBLE_STAR/.*}"
103
+ if [[ "$file_path" =~ $pattern ]]; then
104
+ return 0
105
+ fi
106
+ done
107
+ return 1
108
+ }
109
+
110
+ if [[ -n "$SOURCE_PATH" ]]; then
111
+ SKIP_GLOBS_JSON=$(warden_config_get_json '.warden.scan.skip_globs') || SKIP_GLOBS_JSON="[]"
112
+ if _matches_skip_glob "$SOURCE_PATH" "$SKIP_GLOBS_JSON"; then
113
+ _done
114
+ fi
115
+ fi
116
+
117
+ # ---- Extract ingested content from the tool response. ----------------
118
+ MAX_CHARS=$(warden_config_get '.warden.scan.max_content_chars')
119
+ MAX_CHARS="${MAX_CHARS:-20000}"
120
+
121
+ CONTENT=$(printf '%s' "$INPUT" | jq -r '
122
+ .tool_response as $r
123
+ | if ($r|type) == "string" then $r
124
+ elif ($r|type) == "object" then ($r.content // $r.text // $r.output // $r.result // ($r|tostring))
125
+ else ($r|tostring) end
126
+ | if (type == "string") then . else tostring end
127
+ ' 2>/dev/null) || CONTENT=""
128
+
129
+ [[ -z "$CONTENT" ]] && _done
130
+
131
+ # Cap length before scanning (the scanner caps again before any model call).
132
+ CONTENT="${CONTENT:0:$MAX_CHARS}"
133
+
134
+ # ---- Run the hybrid scanner. -----------------------------------------
135
+ SCAN=$(warden_scan "$SOURCE_TYPE" "$CONTENT")
136
+ DETECTED=$(printf '%s' "$SCAN" | jq -r '.detected // false' 2>/dev/null) || DETECTED="false"
137
+
138
+ if [[ "$DETECTED" != "true" ]]; then
139
+ _done
140
+ fi
141
+
142
+ THREAT_TYPE=$(printf '%s' "$SCAN" | jq -r '.threat_type // "prompt_injection"' 2>/dev/null) || THREAT_TYPE="prompt_injection"
143
+ CONFIDENCE=$(printf '%s' "$SCAN" | jq -r '.confidence // 0.9' 2>/dev/null) || CONFIDENCE="0.9"
144
+ MATCHED_PATTERN=$(printf '%s' "$SCAN" | jq -r '.matched_pattern // ""' 2>/dev/null) || MATCHED_PATTERN=""
145
+ METHOD=$(printf '%s' "$SCAN" | jq -r '.method // "pattern_strong"' 2>/dev/null) || METHOD="pattern_strong"
146
+
147
+ # ---- Build a snippet for the local record (config-gated). ------------
148
+ STORE_SNIPPET=$(warden_config_get '.warden.scan.store_snippet')
149
+ STORE_SNIPPET="${STORE_SNIPPET:-true}"
150
+ SNIPPET_MAX=$(warden_config_get '.warden.scan.snippet_max_chars')
151
+ SNIPPET_MAX="${SNIPPET_MAX:-240}"
152
+ SNIPPET=""
153
+ if [[ "$STORE_SNIPPET" == "true" ]]; then
154
+ SNIPPET=$(warden_sanitize "$CONTENT" "$SNIPPET_MAX")
155
+ fi
156
+
157
+ THREAT_ID=$(warden_ulid)
158
+
159
+ # ---- Close the gate with the full local threat record. ---------------
160
+ # (The local record keeps matched_pattern / threat_id / method for forensics;
161
+ # the emitted event below carries only schema-permitted fields.)
162
+ THREAT_RECORD=$(jq -n \
163
+ --arg id "$THREAT_ID" \
164
+ --arg st "$SOURCE_TYPE" \
165
+ --arg tt "$THREAT_TYPE" \
166
+ --argjson conf "${CONFIDENCE:-0.9}" \
167
+ --arg url "$SOURCE_URL" \
168
+ --arg path "$SOURCE_PATH" \
169
+ --arg snip "$SNIPPET" \
170
+ --arg mp "$MATCHED_PATTERN" \
171
+ --arg method "$METHOD" \
172
+ '{
173
+ threat_id:$id, source_type:$st, threat_type:$tt, confidence:$conf,
174
+ source_url:(if $url == "" then null else $url end),
175
+ source_path:(if $path == "" then null else $path end),
176
+ snippet:(if $snip == "" then null else $snip end),
177
+ matched_pattern:(if $mp == "" then null else $mp end),
178
+ detection_method:$method
179
+ }' 2>/dev/null) || THREAT_RECORD="{}"
180
+
181
+ warden_gate_close "$SESSION_ID" "$THREAT_RECORD" || {
182
+ printf 'warden-post-tool-use: failed to close gate for session %s\n' "$SESSION_ID" >&2
183
+ _done
184
+ }
185
+
186
+ # ---- Emit warden.threat.detected (schema-permitted fields only). -----
187
+ EVENT_PAYLOAD=$(jq -n \
188
+ --arg st "$SOURCE_TYPE" \
189
+ --arg tt "$THREAT_TYPE" \
190
+ --argjson conf "${CONFIDENCE:-0.9}" \
191
+ --arg url "$SOURCE_URL" \
192
+ --arg path "$SOURCE_PATH" \
193
+ --arg snip "$SNIPPET" \
194
+ '{source_type:$st, threat_type:$tt, confidence:$conf}
195
+ + (if $url != "" then {source_url:$url} else {} end)
196
+ + (if $path != "" then {source_path:$path} else {} end)
197
+ + (if $snip != "" then {snippet:$snip} else {} end)' 2>/dev/null) || EVENT_PAYLOAD=""
198
+
199
+ [[ -n "$EVENT_PAYLOAD" ]] && warden_emit_event "warden.threat.detected" "$EVENT_PAYLOAD" || true
200
+
201
+ _done
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bash
2
+ # Warden PreToolUse hook — enforcement path for Write, Edit, MultiEdit, Bash.
3
+ #
4
+ # Tool-agnostic gate check: if this session's content gate is closed, block
5
+ # the operation and tell the user how to clear it. Otherwise allow silently.
6
+ # No LLM call, no parsing — just a lock check, so it is fast and trivially
7
+ # fail-closed (a present lock always blocks).
8
+ #
9
+ # Hook contract (Claude Code PreToolUse protocol):
10
+ # - Always exits 0.
11
+ # - To block: write {"decision":"block","reason":"..."} to stdout.
12
+ # - To allow: write nothing to stdout.
13
+ # - Errors are written to stderr only.
14
+
15
+ set -uo pipefail
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
19
+
20
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
21
+
22
+ # shellcheck source=../lib/warden-config.sh
23
+ source "${PLUGIN_ROOT}/scripts/lib/warden-config.sh"
24
+ # shellcheck source=../lib/warden-events.sh
25
+ source "${PLUGIN_ROOT}/scripts/lib/warden-events.sh"
26
+ # shellcheck source=../lib/warden-gate-state.sh
27
+ source "${PLUGIN_ROOT}/scripts/lib/warden-gate-state.sh"
28
+
29
+ INPUT=$(cat)
30
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
31
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
32
+ TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || TOOL_NAME=""
33
+
34
+ export _HOOK_SESSION_ID="$SESSION_ID"
35
+
36
+ warden_config_load "$CWD"
37
+
38
+ if ! warden_config_enabled; then
39
+ exit 0
40
+ fi
41
+
42
+ [[ -z "$SESSION_ID" ]] && exit 0
43
+
44
+ # Gate open → allow silently.
45
+ if ! warden_gate_is_closed "$SESSION_ID"; then
46
+ exit 0
47
+ fi
48
+
49
+ # ---- Gate closed → block this operation. -----------------------------
50
+ # Map the tool to the schema's blocked_operation enum.
51
+ case "$TOOL_NAME" in
52
+ Write) BLOCKED_OP="tool.file.write" ;;
53
+ Edit|MultiEdit) BLOCKED_OP="tool.file.edit" ;;
54
+ Bash) BLOCKED_OP="tool.shell.exec" ;;
55
+ *) BLOCKED_OP="tool.file.write" ;;
56
+ esac
57
+
58
+ THREAT=$(warden_gate_threat "$SESSION_ID") || THREAT=""
59
+ THREAT_SOURCE_TYPE=$(printf '%s' "$THREAT" | jq -r '.source_type // "web_fetch"' 2>/dev/null) || THREAT_SOURCE_TYPE="web_fetch"
60
+ THREAT_TYPE=$(printf '%s' "$THREAT" | jq -r '.threat_type // "prompt_injection"' 2>/dev/null) || THREAT_TYPE="prompt_injection"
61
+ THREAT_SOURCE=$(printf '%s' "$THREAT" | jq -r '.source_url // .source_path // "(unknown source)"' 2>/dev/null) || THREAT_SOURCE="(unknown source)"
62
+ THREAT_SNIPPET=$(printf '%s' "$THREAT" | jq -r '.snippet // ""' 2>/dev/null) || THREAT_SNIPPET=""
63
+
64
+ # Emit warden.gate.blocked (schema-permitted fields only).
65
+ EVENT_PAYLOAD=$(jq -n \
66
+ --arg op "$BLOCKED_OP" \
67
+ --arg st "$THREAT_SOURCE_TYPE" \
68
+ '{blocked_operation:$op, threat_source_type:$st}' 2>/dev/null) || EVENT_PAYLOAD=""
69
+ [[ -n "$EVENT_PAYLOAD" ]] && warden_emit_event "warden.gate.blocked" "$EVENT_PAYLOAD" || true
70
+
71
+ # Build the block message.
72
+ SNIPPET_LINE=""
73
+ [[ -n "$THREAT_SNIPPET" ]] && SNIPPET_LINE=$(printf '\n Flagged excerpt: %s' "$THREAT_SNIPPET")
74
+
75
+ MESSAGE=$(printf \
76
+ 'Warden closed the content gate — external actions are paused.
77
+
78
+ A %s threat was detected in untrusted content from %s (%s).
79
+ Under the Agents Rule of Two, warden has revoked the "external actions"
80
+ property while that content is in your context: Write, Edit, and Bash are
81
+ blocked until you clear the gate.%s
82
+
83
+ To proceed:
84
+ • Review the flagged source, then run /warden clear to reopen the gate.
85
+ • Run /warden status to see the full threat record.
86
+ • If this was a false positive, /warden clear records your override.' \
87
+ "$THREAT_TYPE" "$THREAT_SOURCE" "$THREAT_SOURCE_TYPE" "$SNIPPET_LINE")
88
+
89
+ jq -n \
90
+ --arg message "$MESSAGE" \
91
+ '{"decision":"block","reason":$message}' 2>/dev/null \
92
+ || printf '{"decision":"block","reason":"Warden closed the content gate. Run /warden clear to reopen."}'
93
+
94
+ exit 0
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ # Warden SessionStart hook.
3
+ #
4
+ # Fires at every session start. Responsibilities:
5
+ # 1. Skip silently when warden.enabled is false.
6
+ # 2. Ensure the session gate directory exists.
7
+ #
8
+ # A new session starts with the gate OPEN — the gate is session-scoped because
9
+ # the threat model is untrusted content ingested into THIS session's context.
10
+ # We never carry a closed gate across sessions, and we never auto-create a
11
+ # closed lock here.
12
+ #
13
+ # Hook contract:
14
+ # - Always exits 0. Never blocks SessionStart.
15
+ # - Errors are written to stderr only; stdout is kept clean.
16
+
17
+ set -uo pipefail
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
21
+
22
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
23
+
24
+ # shellcheck source=../lib/warden-config.sh
25
+ source "${PLUGIN_ROOT}/scripts/lib/warden-config.sh"
26
+ # shellcheck source=../lib/warden-gate-state.sh
27
+ source "${PLUGIN_ROOT}/scripts/lib/warden-gate-state.sh"
28
+
29
+ INPUT=$(cat)
30
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
31
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
32
+
33
+ _done() { exit 0; }
34
+
35
+ warden_config_load "$CWD"
36
+
37
+ if ! warden_config_enabled; then
38
+ _done
39
+ fi
40
+
41
+ [[ -z "$SESSION_ID" ]] && {
42
+ printf 'warden-session-start: no session_id in hook input\n' >&2
43
+ _done
44
+ }
45
+
46
+ GATE_DIR=$(warden_gate_dir "$SESSION_ID")
47
+ mkdir -p "$GATE_DIR" 2>/dev/null || {
48
+ printf 'warden-session-start: failed to create gate dir %s\n' "$GATE_DIR" >&2
49
+ _done
50
+ }
51
+
52
+ _done
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bash
2
+ # Interactive control surface for the /warden skill.
3
+ #
4
+ # Exposes:
5
+ # warden_cli status [session_id] # print the gate state + threat record
6
+ # warden_cli clear [session_id] # explicit user override: reopen the gate
7
+ #
8
+ # Session resolution order:
9
+ # 1. explicit session_id argument
10
+ # 2. $CLAUDE_SESSION_ID (when its gate is closed)
11
+ # 3. the single closed gate, if exactly one exists
12
+ # 4. otherwise: report ambiguity / no closed gate and do nothing
13
+ #
14
+ # Depends on (sourced by the caller): warden-gate-state.sh · warden-events.sh
15
+
16
+ # Resolve the session whose gate the command should act on.
17
+ # Echoes the session id, or empty. Second arg "require_closed" (default true)
18
+ # restricts auto-resolution to sessions with a closed gate.
19
+ _warden_cli_resolve_session() {
20
+ local explicit="${1:-}"
21
+
22
+ if [[ -n "$explicit" ]]; then
23
+ printf '%s' "$explicit"
24
+ return 0
25
+ fi
26
+
27
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]] && warden_gate_is_closed "$CLAUDE_SESSION_ID"; then
28
+ printf '%s' "$CLAUDE_SESSION_ID"
29
+ return 0
30
+ fi
31
+
32
+ # bash 3.2 (macOS default) has no `mapfile`; collect with a while-read loop.
33
+ local closed=() line
34
+ while IFS= read -r line; do
35
+ [[ -n "$line" ]] && closed+=("$line")
36
+ done < <(warden_list_closed_sessions)
37
+ if [[ "${#closed[@]}" -eq 1 ]]; then
38
+ printf '%s' "${closed[0]}"
39
+ return 0
40
+ fi
41
+
42
+ # Fall back to the current session id even if its gate is open, so status
43
+ # can report "open" for the right session.
44
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
45
+ printf '%s' "$CLAUDE_SESSION_ID"
46
+ return 0
47
+ fi
48
+
49
+ printf ''
50
+ return 1
51
+ }
52
+
53
+ warden_cli() {
54
+ local action="${1:-status}"
55
+ local session_arg="${2:-}"
56
+
57
+ local session_id
58
+ session_id=$(_warden_cli_resolve_session "$session_arg") || session_id=""
59
+
60
+ # Report ambiguity when multiple gates are closed and none was specified.
61
+ if [[ -z "$session_id" ]]; then
62
+ local closed=() line
63
+ while IFS= read -r line; do
64
+ [[ -n "$line" ]] && closed+=("$line")
65
+ done < <(warden_list_closed_sessions)
66
+ if [[ "${#closed[@]}" -gt 1 ]]; then
67
+ printf 'Multiple sessions have a closed gate. Re-run with an explicit session id:\n'
68
+ printf ' %s\n' "${closed[@]}"
69
+ return 0
70
+ fi
71
+ printf 'No closed gate found and no session id available.\n'
72
+ return 0
73
+ fi
74
+
75
+ case "$action" in
76
+ status)
77
+ if warden_gate_is_closed "$session_id"; then
78
+ local threat
79
+ threat=$(warden_gate_threat "$session_id")
80
+ printf 'Gate: CLOSED (session %s)\n\n' "$session_id"
81
+ printf '%s\n' "$threat" | jq -r '
82
+ " threat_type: \(.threat_type // "unknown")",
83
+ " source_type: \(.source_type // "unknown")",
84
+ " source: \(.source_url // .source_path // "(unknown)")",
85
+ " confidence: \(.confidence // "n/a")",
86
+ " detection: \(.detection_method // "unknown")",
87
+ " matched_pattern: \(.matched_pattern // "n/a")",
88
+ " snippet: \(.snippet // "(not stored)")"
89
+ ' 2>/dev/null || printf ' (threat record unavailable)\n'
90
+ printf '\nRun /warden clear to reopen the gate (records a user override).\n'
91
+ else
92
+ printf 'Gate: OPEN (session %s) — no active threat. Write, Edit, and Bash are allowed.\n' "$session_id"
93
+ fi
94
+ ;;
95
+ clear)
96
+ if ! warden_gate_is_closed "$session_id"; then
97
+ printf 'Gate already OPEN (session %s) — nothing to clear.\n' "$session_id"
98
+ return 0
99
+ fi
100
+ local prior_threat source_type
101
+ prior_threat=$(warden_gate_threat "$session_id")
102
+ source_type=$(printf '%s' "$prior_threat" | jq -r '.source_type // "web_fetch"' 2>/dev/null) || source_type="web_fetch"
103
+
104
+ warden_gate_clear "$session_id" >/dev/null || {
105
+ printf 'Failed to clear the gate for session %s.\n' "$session_id"
106
+ return 1
107
+ }
108
+
109
+ # Emit warden.threat.cleared (schema-permitted fields only).
110
+ local payload
111
+ payload=$(jq -n --arg st "$source_type" \
112
+ '{source_type:$st, cleared_by:"user_override"}' 2>/dev/null) || payload=""
113
+ if [[ -n "$payload" ]]; then
114
+ _HOOK_SESSION_ID="$session_id" warden_emit_event "warden.threat.cleared" "$payload" || true
115
+ fi
116
+
117
+ printf 'Gate CLEARED (session %s). External actions re-enabled by user override.\n' "$session_id"
118
+ ;;
119
+ *)
120
+ printf 'Unknown action "%s". Use: status | clear\n' "$action"
121
+ return 1
122
+ ;;
123
+ esac
124
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for Warden.
3
+ #
4
+ # Reads three layers, latest wins:
5
+ # 1. plugins/warden/config.json (defaults shipped with the plugin)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # warden_config_load <repo_root> # populates _WARDEN_CONFIG (JSON)
11
+ # warden_config_get <jq-path> # echoes string value (empty if unset)
12
+ # warden_config_get_json <jq-path> # echoes JSON value (null if unset)
13
+ # warden_config_enabled # 0 if warden.enabled is true
14
+
15
+ _WARDEN_CONFIG="{}"
16
+
17
+ warden_config_load() {
18
+ local repo_root="${1:-}"
19
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
20
+ local home_dir="${HOME:-}"
21
+
22
+ local merged="{}"
23
+ local file
24
+
25
+ file="${plugin_root}/config.json"
26
+ if [[ -f "$file" ]]; then
27
+ local defaults
28
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
29
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
30
+ || merged="$defaults"
31
+ fi
32
+
33
+ local repo_settings=""
34
+ [[ -n "$repo_root" ]] && repo_settings="${repo_root}/.claude/settings.json"
35
+
36
+ for file in "${home_dir}/.claude/settings.json" "$repo_settings"; do
37
+ [[ -n "$file" && -f "$file" ]] || continue
38
+ local overlay
39
+ overlay=$(jq '{ warden: (.warden // {}) }' "$file" 2>/dev/null) || continue
40
+ [[ -z "$overlay" ]] && continue
41
+ local attempt
42
+ if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
43
+ def deepmerge($a; $b):
44
+ if ($a|type) == "object" and ($b|type) == "object" then
45
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
46
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
47
+ elif $b == null then $a
48
+ else $b end;
49
+ deepmerge($a; $b)
50
+ ' 2>/dev/null) && [[ -n "$attempt" ]]; then
51
+ merged="$attempt"
52
+ fi
53
+ done
54
+
55
+ _WARDEN_CONFIG="$merged"
56
+ }
57
+
58
+ warden_config_get() {
59
+ local path="$1"
60
+ # NB: do NOT use `${path} // empty` — jq's `//` treats `false` and `0` as
61
+ # empty, so a `false` boolean would read back as "" and a `${v:-true}`
62
+ # default would silently flip it to true. Emit the raw value and map only a
63
+ # literal JSON null to the empty string.
64
+ local v
65
+ v=$(printf '%s' "$_WARDEN_CONFIG" | jq -r "${path}" 2>/dev/null) || return 1
66
+ [[ "$v" == "null" ]] && v=""
67
+ printf '%s' "$v"
68
+ }
69
+
70
+ warden_config_get_json() {
71
+ local path="$1"
72
+ printf '%s' "$_WARDEN_CONFIG" | jq -c "${path}" 2>/dev/null
73
+ }
74
+
75
+ warden_config_enabled() {
76
+ local v
77
+ v=$(warden_config_get '.warden.enabled')
78
+ [[ "$v" == "true" ]]
79
+ }