@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.
- package/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -2
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +1 -0
- package/package.json +2 -2
- package/plugins/counsel/.claude-plugin/plugin.json +14 -0
- package/plugins/counsel/CHANGELOG.md +8 -0
- package/plugins/counsel/config.json +20 -0
- package/plugins/counsel/hooks/hooks.json +15 -0
- package/plugins/counsel/scripts/hooks/counsel-session-start.sh +106 -0
- package/plugins/counsel/scripts/lib/counsel-brief.sh +247 -0
- package/plugins/counsel/scripts/lib/counsel-config.sh +72 -0
- package/plugins/counsel/scripts/lib/counsel-events.sh +80 -0
- package/plugins/counsel/scripts/lib/counsel-project-key.sh +79 -0
- package/plugins/counsel/scripts/lib/counsel-reader.sh +114 -0
- package/plugins/counsel/scripts/lib/counsel-synthesize.sh +103 -0
- package/plugins/counsel/scripts/lib/counsel-ulid.sh +45 -0
- package/plugins/warden/.claude-plugin/plugin.json +14 -0
- package/plugins/warden/CHANGELOG.md +10 -0
- package/plugins/warden/config.json +51 -0
- package/plugins/warden/docs/adr/001-detect-after-ingest-gate-before-action.md +62 -0
- package/plugins/warden/docs/design.md +123 -0
- package/plugins/warden/hooks/hooks.json +73 -0
- package/plugins/warden/scripts/hooks/warden-post-tool-use.sh +201 -0
- package/plugins/warden/scripts/hooks/warden-pre-tool-use.sh +94 -0
- package/plugins/warden/scripts/hooks/warden-session-start.sh +52 -0
- package/plugins/warden/scripts/lib/warden-cli.sh +124 -0
- package/plugins/warden/scripts/lib/warden-config.sh +79 -0
- package/plugins/warden/scripts/lib/warden-evaluator.sh +246 -0
- package/plugins/warden/scripts/lib/warden-events.sh +85 -0
- package/plugins/warden/scripts/lib/warden-gate-state.sh +105 -0
- package/plugins/warden/scripts/lib/warden-patterns.sh +132 -0
- package/plugins/warden/scripts/lib/warden-sanitizer.sh +80 -0
- package/plugins/warden/scripts/lib/warden-scanner.sh +119 -0
- package/plugins/warden/scripts/lib/warden-ulid.sh +50 -0
- package/plugins/warden/skills/warden/SKILL.md +49 -0
- package/release-please-config.json +32 -0
- package/test/bats/counsel-project-key.bats +82 -0
- package/test/bats/counsel-reader.bats +132 -0
- package/test/bats/warden-config.bats +54 -0
- package/test/bats/warden-events.bats +85 -0
- package/test/bats/warden-gate-state.bats +67 -0
- package/test/bats/warden-patterns.bats +58 -0
- package/test/bats/warden-sanitizer.bats +53 -0
- package/test/bats/warden-scanner.bats +56 -0
- package/test/bats/warden-ulid.bats +30 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Canonical counsel.* event emission.
|
|
3
|
+
#
|
|
4
|
+
# Thin wrapper around the ecosystem plugin's onlooker-event.mjs `emit` mode.
|
|
5
|
+
# Every emission is validated against @onlooker-community/schema before being
|
|
6
|
+
# appended to $ONLOOKER_EVENTS_LOG (defaults to $ONLOOKER_DIR/logs/onlooker-events.jsonl).
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# counsel_emit_event "counsel.brief.generated" '{"period_start":"...","period_end":"...","recommendation_count":3}'
|
|
10
|
+
|
|
11
|
+
_COUNSEL_PLUGIN_NAME="counsel"
|
|
12
|
+
|
|
13
|
+
_counsel_event_js_path() {
|
|
14
|
+
if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
|
|
15
|
+
printf '%s' "$_ONLOOKER_EVENT_JS"
|
|
16
|
+
return 0
|
|
17
|
+
fi
|
|
18
|
+
local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
|
|
19
|
+
local candidates=(
|
|
20
|
+
"${plugin_root}/scripts/lib/onlooker-event.mjs"
|
|
21
|
+
"${plugin_root}/../../scripts/lib/onlooker-event.mjs"
|
|
22
|
+
)
|
|
23
|
+
local c
|
|
24
|
+
for c in "${candidates[@]}"; do
|
|
25
|
+
[[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
|
|
26
|
+
done
|
|
27
|
+
return 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_counsel_session_id() {
|
|
31
|
+
if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
|
|
32
|
+
printf '%s' "$_HOOK_SESSION_ID"
|
|
33
|
+
return 0
|
|
34
|
+
fi
|
|
35
|
+
if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
|
|
36
|
+
printf '%s' "$CLAUDE_SESSION_ID"
|
|
37
|
+
return 0
|
|
38
|
+
fi
|
|
39
|
+
printf 'unknown'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
counsel_emit_event() {
|
|
43
|
+
local event_type="${1:-}"
|
|
44
|
+
local payload="${2:-}"
|
|
45
|
+
|
|
46
|
+
[[ -z "$event_type" || -z "$payload" ]] && return 1
|
|
47
|
+
|
|
48
|
+
local event_js
|
|
49
|
+
event_js=$(_counsel_event_js_path) || return 1
|
|
50
|
+
|
|
51
|
+
local session_id
|
|
52
|
+
session_id=$(_counsel_session_id)
|
|
53
|
+
|
|
54
|
+
local params
|
|
55
|
+
params=$(jq -n \
|
|
56
|
+
--arg plugin "$_COUNSEL_PLUGIN_NAME" \
|
|
57
|
+
--arg sid "$session_id" \
|
|
58
|
+
--arg type "$event_type" \
|
|
59
|
+
--argjson payload "$payload" \
|
|
60
|
+
'{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
|
|
61
|
+
2>/dev/null) || return 1
|
|
62
|
+
|
|
63
|
+
local event
|
|
64
|
+
local stderr_file
|
|
65
|
+
stderr_file=$(mktemp -t counsel-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/counsel-event-err.$$"
|
|
66
|
+
event=$(printf '%s' "$params" \
|
|
67
|
+
| ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
|
|
68
|
+
ONLOOKER_PLUGIN_NAME="$_COUNSEL_PLUGIN_NAME" \
|
|
69
|
+
node "$event_js" emit 2>"$stderr_file") || {
|
|
70
|
+
printf 'counsel_emit_event: schema validation failed for %s\n' "$event_type" >&2
|
|
71
|
+
[[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
|
|
72
|
+
rm -f "$stderr_file"
|
|
73
|
+
return 1
|
|
74
|
+
}
|
|
75
|
+
rm -f "$stderr_file"
|
|
76
|
+
|
|
77
|
+
local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
|
|
78
|
+
mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
|
|
79
|
+
printf '%s\n' "$event" >> "$log_path"
|
|
80
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Project key derivation for Counsel.
|
|
3
|
+
#
|
|
4
|
+
# Mirrors the tribunal/scribe project-key scheme so plugins partition storage
|
|
5
|
+
# identically. A project key is a stable 12-char hex identifier derived from
|
|
6
|
+
# the git remote URL (preferred) or repo root path (fallback).
|
|
7
|
+
|
|
8
|
+
_counsel_sha256_first12() {
|
|
9
|
+
local input="$1"
|
|
10
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
11
|
+
printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
|
|
12
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
13
|
+
printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
|
|
14
|
+
else
|
|
15
|
+
return 1
|
|
16
|
+
fi
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
counsel_project_remote_url() {
|
|
20
|
+
local cwd="${1:-}"
|
|
21
|
+
[[ -z "$cwd" || ! -d "$cwd" ]] && return 0
|
|
22
|
+
git -C "$cwd" remote get-url origin 2>/dev/null || true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Worktree-aware: uses common-dir so worktrees share a key with the main repo.
|
|
26
|
+
counsel_project_repo_root() {
|
|
27
|
+
local cwd="${1:-}"
|
|
28
|
+
[[ -z "$cwd" || ! -d "$cwd" ]] && return 0
|
|
29
|
+
|
|
30
|
+
if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
31
|
+
return 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
local common_dir toplevel
|
|
35
|
+
common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
|
|
36
|
+
|
|
37
|
+
if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
|
|
38
|
+
common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [[ -n "$common_dir" && -d "$common_dir" ]]; then
|
|
42
|
+
toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
if [[ -z "$toplevel" ]]; then
|
|
46
|
+
toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
|
|
47
|
+
[[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
printf '%s' "$toplevel"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
counsel_project_key() {
|
|
54
|
+
local cwd="${1:-}"
|
|
55
|
+
[[ -z "$cwd" ]] && cwd="$(pwd)"
|
|
56
|
+
|
|
57
|
+
local remote
|
|
58
|
+
remote=$(counsel_project_remote_url "$cwd")
|
|
59
|
+
if [[ -n "$remote" ]]; then
|
|
60
|
+
_counsel_sha256_first12 "remote:$remote"
|
|
61
|
+
return 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
local root
|
|
65
|
+
root=$(counsel_project_repo_root "$cwd")
|
|
66
|
+
if [[ -n "$root" ]]; then
|
|
67
|
+
_counsel_sha256_first12 "root:$root"
|
|
68
|
+
return 0
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
counsel_project_dir() {
|
|
75
|
+
local project_key="${1:-}"
|
|
76
|
+
[[ -z "$project_key" ]] && return 1
|
|
77
|
+
local onlooker_dir="${ONLOOKER_DIR:-${HOME}/.onlooker}"
|
|
78
|
+
printf '%s' "${onlooker_dir}/counsel/${project_key}/briefs"
|
|
79
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Event log reader for Counsel.
|
|
3
|
+
#
|
|
4
|
+
# Reads $ONLOOKER_EVENTS_LOG and returns a filtered, summarized view of
|
|
5
|
+
# the last N days suitable for passing to the synthesis prompt.
|
|
6
|
+
#
|
|
7
|
+
# Exposes:
|
|
8
|
+
# counsel_read_events <lookback_days> <chars_max>
|
|
9
|
+
# Echoes a structured text summary of events, or empty string on failure.
|
|
10
|
+
#
|
|
11
|
+
# counsel_sources_from_events <events_json>
|
|
12
|
+
# Echoes a JSON array of CounselSource strings present in the event batch.
|
|
13
|
+
|
|
14
|
+
# Maps event_type prefixes to CounselSource values.
|
|
15
|
+
_counsel_source_for_type() {
|
|
16
|
+
local event_type="${1:-}"
|
|
17
|
+
case "$event_type" in
|
|
18
|
+
tribunal.*) printf 'tribunal_verdicts' ;;
|
|
19
|
+
echo.*) printf 'echo_regressions' ;;
|
|
20
|
+
sentinel.*) printf 'sentinel_audit' ;;
|
|
21
|
+
warden.*) printf 'warden_audit' ;;
|
|
22
|
+
oracle.*) printf 'oracle_calibrations' ;;
|
|
23
|
+
meridian.*) printf 'meridian_reliance' ;;
|
|
24
|
+
*) printf 'onlooker_events' ;;
|
|
25
|
+
esac
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
counsel_read_events() {
|
|
29
|
+
local lookback_days="${1:-30}"
|
|
30
|
+
local chars_max="${2:-60000}"
|
|
31
|
+
|
|
32
|
+
local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
|
|
33
|
+
[[ -f "$log_path" ]] || { printf ''; return 0; }
|
|
34
|
+
|
|
35
|
+
# Compute cutoff as an ISO 8601 date string. ISO 8601 strings are
|
|
36
|
+
# lexicographically sortable, so string comparison is safe for filtering.
|
|
37
|
+
local cutoff_ts
|
|
38
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
39
|
+
cutoff_ts=$(date -v "-${lookback_days}d" -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
|
|
40
|
+
|| cutoff_ts=""
|
|
41
|
+
else
|
|
42
|
+
cutoff_ts=$(date -d "-${lookback_days} days" -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
|
|
43
|
+
|| cutoff_ts=""
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Filter to events within the lookback window. If cutoff_ts is empty (date
|
|
47
|
+
# command unavailable) fall through and include all events.
|
|
48
|
+
local summary
|
|
49
|
+
# -rc: compact output keeps each object on one line (JSONL-shaped), which
|
|
50
|
+
# downstream counsel_count_events and counsel_sources_from_events require.
|
|
51
|
+
summary=$(jq -rc --arg cutoff "$cutoff_ts" '
|
|
52
|
+
select(.timestamp != null) |
|
|
53
|
+
select($cutoff == "" or .timestamp >= $cutoff) |
|
|
54
|
+
{
|
|
55
|
+
type: .event_type,
|
|
56
|
+
plugin: (.plugin // "unknown"),
|
|
57
|
+
ts: .timestamp,
|
|
58
|
+
session: (.session_id // ""),
|
|
59
|
+
payload: (.payload // {})
|
|
60
|
+
}
|
|
61
|
+
' "$log_path" 2>/dev/null | head -c "$chars_max") || summary=""
|
|
62
|
+
|
|
63
|
+
printf '%s' "$summary"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
counsel_sources_from_events() {
|
|
67
|
+
local events_text="${1:-}"
|
|
68
|
+
[[ -z "$events_text" ]] && { printf '["onlooker_events"]'; return 0; }
|
|
69
|
+
|
|
70
|
+
local sources=()
|
|
71
|
+
local seen_tribunal=0 seen_echo=0 seen_sentinel=0 seen_warden=0 seen_oracle=0 seen_meridian=0 seen_other=0
|
|
72
|
+
|
|
73
|
+
while IFS= read -r line; do
|
|
74
|
+
[[ -z "$line" ]] && continue
|
|
75
|
+
local etype
|
|
76
|
+
etype=$(printf '%s' "$line" | jq -r '.type // ""' 2>/dev/null) || continue
|
|
77
|
+
case "$etype" in
|
|
78
|
+
tribunal.*) seen_tribunal=1 ;;
|
|
79
|
+
echo.*) seen_echo=1 ;;
|
|
80
|
+
sentinel.*) seen_sentinel=1 ;;
|
|
81
|
+
warden.*) seen_warden=1 ;;
|
|
82
|
+
oracle.*) seen_oracle=1 ;;
|
|
83
|
+
meridian.*) seen_meridian=1 ;;
|
|
84
|
+
*) seen_other=1 ;;
|
|
85
|
+
esac
|
|
86
|
+
done <<< "$events_text"
|
|
87
|
+
|
|
88
|
+
[[ "$seen_other" -eq 1 ]] && sources+=("\"onlooker_events\"")
|
|
89
|
+
[[ "$seen_tribunal" -eq 1 ]] && sources+=("\"tribunal_verdicts\"")
|
|
90
|
+
[[ "$seen_echo" -eq 1 ]] && sources+=("\"echo_regressions\"")
|
|
91
|
+
[[ "$seen_sentinel" -eq 1 ]] && sources+=("\"sentinel_audit\"")
|
|
92
|
+
[[ "$seen_warden" -eq 1 ]] && sources+=("\"warden_audit\"")
|
|
93
|
+
[[ "$seen_oracle" -eq 1 ]] && sources+=("\"oracle_calibrations\"")
|
|
94
|
+
[[ "$seen_meridian" -eq 1 ]] && sources+=("\"meridian_reliance\"")
|
|
95
|
+
|
|
96
|
+
if [[ "${#sources[@]}" -eq 0 ]]; then
|
|
97
|
+
printf '["onlooker_events"]'
|
|
98
|
+
return 0
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
local joined
|
|
102
|
+
joined=$(IFS=,; printf '%s' "${sources[*]}")
|
|
103
|
+
printf '[%s]' "$joined"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
counsel_count_events() {
|
|
107
|
+
local events_text="${1:-}"
|
|
108
|
+
[[ -z "$events_text" ]] && { printf '0'; return 0; }
|
|
109
|
+
local count=0
|
|
110
|
+
while IFS= read -r line; do
|
|
111
|
+
[[ -n "$line" ]] && count=$((count + 1))
|
|
112
|
+
done <<< "$events_text"
|
|
113
|
+
printf '%s' "$count"
|
|
114
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Synthesis pass for Counsel.
|
|
3
|
+
#
|
|
4
|
+
# Runs a single Haiku call over the event summary to produce a structured
|
|
5
|
+
# improvement brief. The brief is returned as a JSON object.
|
|
6
|
+
#
|
|
7
|
+
# Exposes:
|
|
8
|
+
# counsel_synthesize <events_text> <model> <timeout_s> <max_tokens> <temperature>
|
|
9
|
+
# Echoes a JSON object on success, empty string on failure.
|
|
10
|
+
# JSON shape:
|
|
11
|
+
# {
|
|
12
|
+
# "summary": string,
|
|
13
|
+
# "patterns": [string],
|
|
14
|
+
# "recommendations": [{title, rationale, priority:"high"|"medium"|"low"}],
|
|
15
|
+
# "wins": [string],
|
|
16
|
+
# "watch": [string]
|
|
17
|
+
# }
|
|
18
|
+
|
|
19
|
+
_COUNSEL_SYNTHESIS_PROMPT='You are an engineering coach analyzing an AI agent observability log. You have been given a structured dump of plugin events from the onlooker ecosystem over the past several weeks. Your job is to synthesize patterns, surface improvement opportunities, and highlight what is working well.
|
|
20
|
+
|
|
21
|
+
Focus on:
|
|
22
|
+
- Recurring failure modes or blocked gates (tribunal, sentinel, warden)
|
|
23
|
+
- Prompt regression trends (echo plugin)
|
|
24
|
+
- Budget or resource pressure patterns (governor plugin)
|
|
25
|
+
- Quality trends over time
|
|
26
|
+
- What the team is consistently doing well
|
|
27
|
+
|
|
28
|
+
Return a JSON object with exactly these keys:
|
|
29
|
+
{
|
|
30
|
+
"summary": "2-3 sentence executive summary of the period",
|
|
31
|
+
"patterns": ["observed pattern — what is happening and how often"],
|
|
32
|
+
"recommendations": [
|
|
33
|
+
{
|
|
34
|
+
"title": "short action title",
|
|
35
|
+
"rationale": "1-2 sentences explaining why this matters",
|
|
36
|
+
"priority": "high"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"wins": ["thing that is working well — be specific"],
|
|
40
|
+
"watch": ["trend to monitor — not urgent but worth watching"]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Rules:
|
|
44
|
+
- All fields are required; use empty arrays [] if no items found
|
|
45
|
+
- recommendations must have priority: "high", "medium", or "low"
|
|
46
|
+
- Keep each item to 1-2 sentences
|
|
47
|
+
- Return ONLY the JSON object — no prose, no markdown fences, no explanation
|
|
48
|
+
- If there is insufficient data to draw conclusions, say so in summary and return empty arrays
|
|
49
|
+
|
|
50
|
+
'
|
|
51
|
+
|
|
52
|
+
counsel_synthesize() {
|
|
53
|
+
local events_text="${1:-}"
|
|
54
|
+
local model="${2:-claude-haiku-4-5-20251001}"
|
|
55
|
+
local timeout_s="${3:-90}"
|
|
56
|
+
local max_tokens="${4:-4096}"
|
|
57
|
+
local temperature="${5:-0.4}"
|
|
58
|
+
|
|
59
|
+
[[ -z "$events_text" ]] && return 1
|
|
60
|
+
|
|
61
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
62
|
+
printf 'counsel_synthesize: claude CLI not found\n' >&2
|
|
63
|
+
return 1
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
local prompt_file
|
|
67
|
+
prompt_file=$(mktemp -t counsel-synth.XXXXXX 2>/dev/null) || prompt_file="/tmp/counsel-synth.$$"
|
|
68
|
+
trap 'rm -f "$prompt_file"' RETURN
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
printf '%s' "$_COUNSEL_SYNTHESIS_PROMPT"
|
|
72
|
+
printf '<event_log>\n'
|
|
73
|
+
printf '%s\n' "$events_text"
|
|
74
|
+
printf '</event_log>\n'
|
|
75
|
+
} > "$prompt_file"
|
|
76
|
+
|
|
77
|
+
local claude_args=(-p --max-turns 1 --model "$model" --max-tokens "$max_tokens")
|
|
78
|
+
|
|
79
|
+
local response=""
|
|
80
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
81
|
+
response=$(timeout "$timeout_s" claude "${claude_args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
82
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
83
|
+
response=$(gtimeout "$timeout_s" claude "${claude_args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
84
|
+
else
|
|
85
|
+
response=$(claude "${claude_args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
[[ -z "$response" ]] && return 1
|
|
89
|
+
|
|
90
|
+
# Strip markdown fences if present.
|
|
91
|
+
local clean
|
|
92
|
+
clean=$(printf '%s' "$response" \
|
|
93
|
+
| sed -e 's/^```json[[:space:]]*//' -e 's/^```[[:space:]]*//' -e 's/[[:space:]]*```$//')
|
|
94
|
+
|
|
95
|
+
if ! printf '%s' "$clean" | jq -e \
|
|
96
|
+
'.summary and (.patterns | type == "array") and (.recommendations | type == "array") and (.wins | type == "array") and (.watch | type == "array")' \
|
|
97
|
+
>/dev/null 2>&1; then
|
|
98
|
+
printf 'counsel_synthesize: response missing required keys\n' >&2
|
|
99
|
+
return 1
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
printf '%s' "$clean"
|
|
103
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Minimal ULID generator for Counsel brief IDs.
|
|
3
|
+
|
|
4
|
+
_COUNSEL_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
5
|
+
|
|
6
|
+
_counsel_ulid_encode() {
|
|
7
|
+
local n="$1"
|
|
8
|
+
local len="$2"
|
|
9
|
+
local out=""
|
|
10
|
+
local i
|
|
11
|
+
for ((i = 0; i < len; i++)); do
|
|
12
|
+
out="${_COUNSEL_ULID_ALPHABET:$((n % 32)):1}${out}"
|
|
13
|
+
n=$((n / 32))
|
|
14
|
+
done
|
|
15
|
+
printf '%s' "$out"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
counsel_ulid() {
|
|
19
|
+
local now_ms
|
|
20
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
21
|
+
now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
|
|
22
|
+
|| now_ms=$(($(date +%s) * 1000))
|
|
23
|
+
else
|
|
24
|
+
now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
local rand_hex rand_hi rand_lo
|
|
28
|
+
rand_hex=$(openssl rand -hex 10 2>/dev/null)
|
|
29
|
+
if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
|
|
30
|
+
rand_hi=$((16#${rand_hex:0:10}))
|
|
31
|
+
rand_lo=$((16#${rand_hex:10:10}))
|
|
32
|
+
else
|
|
33
|
+
rand_hi=$((RANDOM * 32768 + RANDOM))
|
|
34
|
+
rand_lo=$((RANDOM * 32768 + RANDOM))
|
|
35
|
+
rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
36
|
+
rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
local ts_part hi_part lo_part
|
|
40
|
+
ts_part=$(_counsel_ulid_encode "$now_ms" 10)
|
|
41
|
+
hi_part=$(_counsel_ulid_encode "$rand_hi" 8)
|
|
42
|
+
lo_part=$(_counsel_ulid_encode "$rand_lo" 8)
|
|
43
|
+
|
|
44
|
+
printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "warden",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Untrusted-content gate. Scans content flowing in through WebFetch and Read for prompt-injection patterns, and when a threat is detected closes a session-scoped gate that blocks Write, Edit, and Bash until the user explicitly clears it. Grounded in Meta's Agents Rule of Two: an agent should hold no more than two of {private data, external actions, untrusted content} at once — warden removes the external-actions property while untrusted content is in play. Builds on the Onlooker ecosystem plugin.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Onlooker Community",
|
|
7
|
+
"url": "https://onlooker.dev"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://onlooker.dev",
|
|
10
|
+
"repository": "https://github.com/onlooker-community/ecosystem",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"skills": ["./skills/warden"],
|
|
13
|
+
"agents": []
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/warden-v0.1.0...warden-v0.2.0) (2026-06-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **warden:** untrusted-content gate enforcing the Agents Rule of Two :shield: ([#53](https://github.com/onlooker-community/ecosystem/issues/53)) ([210aa51](https://github.com/onlooker-community/ecosystem/commit/210aa51bff66226a0eec1f17292a2af4ea4ef56a))
|
|
9
|
+
|
|
10
|
+
## Changelog
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"plugin_name": "warden",
|
|
3
|
+
"storage_path": "~/.onlooker",
|
|
4
|
+
"warden": {
|
|
5
|
+
"enabled": false,
|
|
6
|
+
"scan": {
|
|
7
|
+
"sources": ["web_fetch", "file_read"],
|
|
8
|
+
"max_content_chars": 20000,
|
|
9
|
+
"skip_globs": ["**/*.lock", "**/*.sum", "**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
|
|
10
|
+
"store_snippet": true,
|
|
11
|
+
"snippet_max_chars": 240
|
|
12
|
+
},
|
|
13
|
+
"detection": {
|
|
14
|
+
"close_threshold": 0.65,
|
|
15
|
+
"strong_pattern_confidence": 0.9,
|
|
16
|
+
"weak_pattern_confidence": 0.5,
|
|
17
|
+
"threshold_calibration_note": "Strong pattern hits (explicit override/exfil phrasing) score 0.9 and close the gate without an LLM call. Weak hits (suspicion markers near imperative verbs, delimiter tags, long base64 blobs) score 0.5 — below close_threshold — and escalate to the evaluator when escalation.enabled is true. Clean content never calls the model."
|
|
18
|
+
},
|
|
19
|
+
"escalation": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"borderline_only": true,
|
|
22
|
+
"model": "claude-haiku-4-5-20251001",
|
|
23
|
+
"n": 3,
|
|
24
|
+
"temperature": 0.0,
|
|
25
|
+
"max_output_tokens": 192,
|
|
26
|
+
"sample_timeout_seconds": 12,
|
|
27
|
+
"min_valid_samples": 2
|
|
28
|
+
},
|
|
29
|
+
"gate": {
|
|
30
|
+
"blocked_tools": ["Write", "Edit", "MultiEdit", "Bash"],
|
|
31
|
+
"clear_policy": "user_override_only"
|
|
32
|
+
},
|
|
33
|
+
"sanitization": {
|
|
34
|
+
"strip_sequences": [
|
|
35
|
+
"<source_content>",
|
|
36
|
+
"</source_content>",
|
|
37
|
+
"<instructions>",
|
|
38
|
+
"</instructions>",
|
|
39
|
+
"<|",
|
|
40
|
+
"[INST]",
|
|
41
|
+
"[/INST]",
|
|
42
|
+
"<<SYS>>",
|
|
43
|
+
"<</SYS>>"
|
|
44
|
+
],
|
|
45
|
+
"strip_null_bytes": true
|
|
46
|
+
},
|
|
47
|
+
"data_egress": {
|
|
48
|
+
"note": "On escalation, only a sanitized, length-capped excerpt of the ingested content is sent to the evaluator model. Set escalation.enabled=false to disable all egress — warden then relies on the deterministic pattern floor alone (zero network, zero egress, weaker coverage of novel phrasing)."
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ADR-001: Warden Detects After Ingestion and Gates Before Action
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-06-02
|
|
5
|
+
- Deciders: Meagan
|
|
6
|
+
- Tags: warden, rule-of-two, hook-architecture, prompt-injection, content-gate
|
|
7
|
+
|
|
8
|
+
## Context and Problem Statement
|
|
9
|
+
|
|
10
|
+
Warden defends against prompt injection arriving through untrusted content — content the agent ingests via `WebFetch` and `Read`. The naive instinct for a "scan content before the agent processes it" plugin is to scan at `PreToolUse`: inspect the thing before it enters the context, and block it if it's hostile.
|
|
11
|
+
|
|
12
|
+
That instinct does not fit the actual data flow:
|
|
13
|
+
|
|
14
|
+
1. **The content does not exist before the tool runs.** A `WebFetch` result is only known *after* the fetch. A `Read` result is the file's contents, surfaced in the `tool_response`. At `PreToolUse` there is nothing to scan but a URL or a path — far too little signal to classify an injection, and scanning the URL/path alone would miss the entire payload.
|
|
15
|
+
2. **Blocking the read is the wrong lever.** Reading a hostile page is not itself harmful; reading is how the agent and the user *discover* that the page is hostile. The harm is what the agent does *next* with that content — writing a file, editing code, running a command, exfiltrating a secret. The threat is downstream of ingestion.
|
|
16
|
+
|
|
17
|
+
So the question is not "how do we stop the agent from reading bad content" (we can't, and shouldn't), but "once bad content is in the context, how do we prevent it from driving an external action." This is precisely the framing of Meta's **Agents Rule of Two**: untrusted content (property C) is now present alongside private-data access (A) and external-action capability (B); we must drop one of the other two. Dropping B — external actions — is the safe, reversible choice.
|
|
18
|
+
|
|
19
|
+
## Decision Drivers
|
|
20
|
+
|
|
21
|
+
- **Signal availability**: the injection payload only exists in `tool_response`, which is a `PostToolUse` field. Detection must run where the content is.
|
|
22
|
+
- **No timing skew**: `PostToolUse` fires after the content is committed to the transcript, so the scan sees exactly what the agent sees — no race.
|
|
23
|
+
- **Reversibility**: the response to a detected threat should be a *pause a human can lift*, not a destructive or silent action. Revoking external actions is reversible; un-reading is not.
|
|
24
|
+
- **Rule-of-Two alignment**: the mitigation should map cleanly onto removing exactly one of the three properties. Gating B (Write/Edit/Bash) is that mapping.
|
|
25
|
+
- **Fail-soft**: a detector that runs on every read must not block reads when it errors, and the enforcement check must be cheap enough to run before every write without latency cost.
|
|
26
|
+
|
|
27
|
+
## Considered Options
|
|
28
|
+
|
|
29
|
+
1. **Scan at `PreToolUse` on WebFetch/Read and block the read.** Inspect before ingestion.
|
|
30
|
+
2. **Detect at `PostToolUse` on WebFetch/Read; gate at `PreToolUse` on Write/Edit/MultiEdit/Bash.** Split detection from enforcement across two hook surfaces, mediated by a session-scoped lock.
|
|
31
|
+
3. **Single `PreToolUse` hook on the write-class tools that re-scans the whole transcript each time.** No PostToolUse; scan lazily at write time.
|
|
32
|
+
|
|
33
|
+
## Decision
|
|
34
|
+
|
|
35
|
+
We adopt **Option 2: detect after ingestion, gate before action.**
|
|
36
|
+
|
|
37
|
+
- **Detection** runs on `PostToolUse` for `WebFetch` and `Read`. It extracts the ingested content from `tool_response`, runs the hybrid scanner, and on a positive verdict **closes a session-scoped content gate** (`gate.json`) and emits `warden.threat.detected`. PostToolUse cannot block the tool — and deliberately does not need to, because blocking the read is not the goal.
|
|
38
|
+
- **Enforcement** runs on `PreToolUse` for `Write`, `Edit`, `MultiEdit`, and `Bash`. It is a pure lock check: if the gate is closed, it returns `{"decision":"block", …}` and emits `warden.gate.blocked`; otherwise it allows silently. No model call, no command parsing.
|
|
39
|
+
- The two surfaces communicate **only** through the gate lock on disk — never by calling each other — consistent with the ecosystem's event-bus discipline.
|
|
40
|
+
|
|
41
|
+
Option 1 is rejected: there is nothing meaningful to scan at `PreToolUse` for these tools, and blocking the read is both ineffective (the threat is downstream) and user-hostile (it prevents discovery). Option 3 is rejected: re-scanning the full transcript on every write is expensive, repeats work, and loses the clean "this specific source was hostile" provenance that the PostToolUse scan captures at ingestion time.
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
### Positive
|
|
46
|
+
|
|
47
|
+
- Detection sees the real payload (`tool_response`), so classification is meaningful.
|
|
48
|
+
- The response is reversible and human-gated: external actions pause; the user clears the gate with `/warden clear`.
|
|
49
|
+
- Enforcement is O(1) and fail-closed (a present lock always blocks), so gating every write is cheap.
|
|
50
|
+
- The design maps one-to-one onto the Rule of Two: detection observes property C arriving; enforcement removes property B until a human restores it.
|
|
51
|
+
- Clean separation: detection cost (possibly a model call) is paid once per ingested source; enforcement cost is a file stat.
|
|
52
|
+
|
|
53
|
+
### Negative / trade-offs
|
|
54
|
+
|
|
55
|
+
- The hostile content **is** in the context by the time the gate closes — warden mitigates the consequence (external action), not the ingestion. This is inherent to the threat model and is exactly why the mitigation targets property B.
|
|
56
|
+
- A gate closed late in a turn can block writes the agent already intended as benign; the user must clear it. This is the intended friction, not a bug.
|
|
57
|
+
- Session-scoped state means a brand-new session starts open even if a prior session saw a threat. Acceptable: the untrusted content lives in a specific session's context, and warden gates that context.
|
|
58
|
+
|
|
59
|
+
## Related
|
|
60
|
+
|
|
61
|
+
- Plugin design: [`../design.md`](../design.md)
|
|
62
|
+
- Schema: `warden.threat.detected`, `warden.gate.blocked`, `warden.threat.cleared` in `@onlooker-community/schema` (plugins-safety payloads).
|