@onlooker-community/ecosystem 0.23.0 → 0.24.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/plugin.json +1 -1
- package/.github/workflows/autofix.yml +65 -0
- package/.release-please-manifest.json +3 -3
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/plugins/compass/README.md +173 -0
- package/plugins/counsel/README.md +98 -0
- package/plugins/governor/README.md +127 -0
- package/plugins/librarian/.claude-plugin/plugin.json +2 -2
- package/plugins/librarian/CHANGELOG.md +7 -0
- package/plugins/librarian/scripts/lib/librarian-cli.sh +339 -0
- package/plugins/librarian/skills/librarian/SKILL.md +63 -0
- package/plugins/scribe/.claude-plugin/plugin.json +1 -1
- package/plugins/scribe/CHANGELOG.md +7 -0
- package/plugins/scribe/README.md +118 -0
- package/plugins/scribe/scripts/hooks/scribe-capture.sh +0 -0
- package/plugins/scribe/scripts/hooks/scribe-session-start.sh +0 -0
- package/plugins/scribe/scripts/hooks/scribe-stop.sh +0 -0
- package/plugins/warden/README.md +185 -0
- package/test/bats/librarian-cli.bats +305 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Warden
|
|
2
|
+
|
|
3
|
+
Untrusted-content gate enforcing the Agents Rule of Two.
|
|
4
|
+
|
|
5
|
+
Warden scans content flowing into the agent through `WebFetch` and `Read` for prompt-injection patterns. When it finds a threat, it closes a session-scoped **content gate** that blocks `Write`, `Edit`, `MultiEdit`, and `Bash` until the user explicitly clears it.
|
|
6
|
+
|
|
7
|
+
Grounded in Meta's *Agents Rule of Two*: an agent should hold no more than two of {access to private data, ability to take external actions, processing of untrusted content} at once. A coding agent in a real repository already holds the first two — your source and secrets, plus the ability to write files and run commands. The moment it ingests untrusted content (a fetched page, a file of unknown provenance) it holds all three: the dangerous configuration in which untrusted content can steer private data into external actions. Warden cannot un-read content, so it removes the *external-actions* property instead — closing the gate keeps the agent reading and reasoning while a human reviews the situation. Three-of-three collapses back to two-of-three, with the user as the release valve.
|
|
8
|
+
|
|
9
|
+
Warden is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker observability substrate (`~/.onlooker/`) is present.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
Detection and enforcement are split across two hook surfaces, mediated only by the on-disk gate lock — the surfaces never call each other. See [ADR-001](docs/adr/001-detect-after-ingest-gate-before-action.md).
|
|
14
|
+
|
|
15
|
+
| Surface | What Warden does |
|
|
16
|
+
|---------|------------------|
|
|
17
|
+
| `PostToolUse` (`WebFetch`, `Read`) | Extracts ingested content from `tool_response`, applies the source and skip-glob filters and length cap, and runs the hybrid scanner. A strong pattern hit closes the gate immediately; a weak hit escalates to the evaluator. On a positive verdict it closes the session-scoped gate and emits `warden.threat.detected`. PostToolUse cannot block the read — and deliberately does not, because reading is how the threat is discovered. |
|
|
18
|
+
| `PreToolUse` (`Write`, `Edit`, `MultiEdit`, `Bash`) | 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. |
|
|
19
|
+
| `SessionStart` | Initializes Warden for the session. A new session always starts with the gate open, even if a prior session saw a threat. |
|
|
20
|
+
| `/warden` skill | The user-facing control surface — reports gate status and is the only sanctioned way to clear a closed gate. |
|
|
21
|
+
|
|
22
|
+
### Hybrid detection
|
|
23
|
+
|
|
24
|
+
Detection is a two-stage funnel, balancing coverage against cost and data egress:
|
|
25
|
+
|
|
26
|
+
1. **Pattern floor** (`warden-patterns.sh`) — a curated regex set mapped to five threat types: `prompt_injection`, `instruction_override`, `credential_exfiltration`, `command_injection`, and `social_engineering`. **Strong** signatures (explicit override/exfil/command-injection phrasing) score `detection.strong_pattern_confidence` (default `0.9`) and close the gate with no model call. **Weak** signatures (social-engineering pressure, soft instruction-shaped imperatives) score `detection.weak_pattern_confidence` (default `0.5`) — below `close_threshold` — and are treated as borderline.
|
|
27
|
+
2. **LLM escalation** (`warden-evaluator.sh`) — borderline content is sanitized and sent to N parallel Haiku judges (majority vote). The gate closes only if the panel judges it an injection with confidence `≥ close_threshold`.
|
|
28
|
+
|
|
29
|
+
Clean content (no signature) never reaches the model. Set `escalation.enabled: false` for a zero-egress, pattern-only posture.
|
|
30
|
+
|
|
31
|
+
### Fail-soft posture
|
|
32
|
+
|
|
33
|
+
- Detection never blocks the read — `PostToolUse` cannot. If escalation errors, Warden falls back to the deterministic pattern verdict.
|
|
34
|
+
- Enforcement is a pure lock check, trivially fail-closed: a present lock always blocks.
|
|
35
|
+
- Event emission is best-effort; a schema-validation or emit failure is logged to stderr and never blocks a session.
|
|
36
|
+
|
|
37
|
+
## Activation
|
|
38
|
+
|
|
39
|
+
Warden is **off by default**. Enable per-project in `.claude/settings.json`:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"warden": {
|
|
44
|
+
"enabled": true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or globally in `~/.claude/settings.json`.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
All keys are optional. Unset keys fall back to the plugin's `config.json` defaults.
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"warden": {
|
|
58
|
+
"enabled": false,
|
|
59
|
+
"scan": {
|
|
60
|
+
"sources": ["web_fetch", "file_read"],
|
|
61
|
+
"max_content_chars": 20000,
|
|
62
|
+
"skip_globs": ["**/*.lock", "**/*.sum", "**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
|
|
63
|
+
"store_snippet": true,
|
|
64
|
+
"snippet_max_chars": 240
|
|
65
|
+
},
|
|
66
|
+
"detection": {
|
|
67
|
+
"close_threshold": 0.65,
|
|
68
|
+
"strong_pattern_confidence": 0.9,
|
|
69
|
+
"weak_pattern_confidence": 0.5
|
|
70
|
+
},
|
|
71
|
+
"escalation": {
|
|
72
|
+
"enabled": true,
|
|
73
|
+
"borderline_only": true,
|
|
74
|
+
"model": "claude-haiku-4-5-20251001",
|
|
75
|
+
"n": 3,
|
|
76
|
+
"temperature": 0.0,
|
|
77
|
+
"max_output_tokens": 192,
|
|
78
|
+
"sample_timeout_seconds": 12,
|
|
79
|
+
"min_valid_samples": 2
|
|
80
|
+
},
|
|
81
|
+
"gate": {
|
|
82
|
+
"blocked_tools": ["Write", "Edit", "MultiEdit", "Bash"],
|
|
83
|
+
"clear_policy": "user_override_only"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Key | Default | Description |
|
|
90
|
+
|-----|---------|-------------|
|
|
91
|
+
| `enabled` | `false` | Must be `true` for any scanning or gating to run. |
|
|
92
|
+
| `scan.sources` | `["web_fetch", "file_read"]` | Which ingestion sources to scan. Matches the schema's `source_type` enum. |
|
|
93
|
+
| `scan.max_content_chars` | `20000` | Length cap on the content fed into detection. |
|
|
94
|
+
| `scan.skip_globs` | lockfiles, `node_modules`, `.git`, `dist`, `build`, … | Globs whose reads are not scanned. |
|
|
95
|
+
| `scan.store_snippet` | `true` | Whether to keep a flagged excerpt in the gate record and event payload. |
|
|
96
|
+
| `scan.snippet_max_chars` | `240` | Maximum length of a stored snippet. |
|
|
97
|
+
| `detection.close_threshold` | `0.65` | Confidence at or above which a verdict closes the gate. |
|
|
98
|
+
| `detection.strong_pattern_confidence` | `0.9` | Score assigned to strong pattern hits — above threshold, closes without a model call. |
|
|
99
|
+
| `detection.weak_pattern_confidence` | `0.5` | Score assigned to weak pattern hits — below threshold, escalates to the evaluator. |
|
|
100
|
+
| `escalation.enabled` | `true` | Whether borderline content escalates to the LLM evaluator. `false` is a zero-egress, pattern-only posture. |
|
|
101
|
+
| `escalation.borderline_only` | `true` | Escalate only weak/borderline hits, never clean content. |
|
|
102
|
+
| `escalation.model` | `claude-haiku-4-5-20251001` | Model used for the evaluator panel. |
|
|
103
|
+
| `escalation.n` | `3` | Number of parallel evaluator samples (majority vote). |
|
|
104
|
+
| `escalation.temperature` | `0.0` | Sampling temperature for the evaluator. |
|
|
105
|
+
| `escalation.max_output_tokens` | `192` | Token ceiling per evaluator sample. |
|
|
106
|
+
| `escalation.sample_timeout_seconds` | `12` | Per-sample wall-clock timeout. |
|
|
107
|
+
| `escalation.min_valid_samples` | `2` | Minimum valid samples required to form a verdict. |
|
|
108
|
+
| `gate.blocked_tools` | `["Write", "Edit", "MultiEdit", "Bash"]` | Tools blocked while the gate is closed. |
|
|
109
|
+
| `gate.clear_policy` | `user_override_only` | How a closed gate may be cleared. Only explicit user override is supported. |
|
|
110
|
+
|
|
111
|
+
On escalation, only a sanitized, length-capped excerpt of the ingested content is sent to the evaluator model. Setting `escalation.enabled: false` disables all egress — Warden then relies on the deterministic pattern floor alone.
|
|
112
|
+
|
|
113
|
+
## The gate model
|
|
114
|
+
|
|
115
|
+
The gate is a single session-scoped lock with two states:
|
|
116
|
+
|
|
117
|
+
- **Open** (default — file absent, or `{"state":"open"}`) — `Write`, `Edit`, `MultiEdit`, and `Bash` are allowed.
|
|
118
|
+
- **Closed** (`{"state":"closed", …}`) — those operations are blocked at `PreToolUse`.
|
|
119
|
+
|
|
120
|
+
The detection hook **closes** the gate on a positive scan. Once closed, it can be **cleared only by the user** via the `/warden` skill (`clear_policy: user_override_only`) — Warden does not auto-clear in this release. The gate is session-scoped: a brand-new session starts open even if a prior session saw a threat, because the untrusted content lives in a specific session's context.
|
|
121
|
+
|
|
122
|
+
Clearing the gate re-enables write-class tools but does not remove the flagged content from the conversation — it is still in context. The skill reminds the user of this.
|
|
123
|
+
|
|
124
|
+
## Storage layout
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
~/.onlooker/warden/sessions/<session_id>/
|
|
128
|
+
└── gate.json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`gate.json` when the gate is closed:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"state": "closed",
|
|
136
|
+
"closed_at": 1717000000,
|
|
137
|
+
"threat": {
|
|
138
|
+
"threat_id": "01J…",
|
|
139
|
+
"source_type": "web_fetch",
|
|
140
|
+
"threat_type": "credential_exfiltration",
|
|
141
|
+
"confidence": 0.9,
|
|
142
|
+
"source_url": "https://…",
|
|
143
|
+
"source_path": null,
|
|
144
|
+
"snippet": "…sanitized excerpt…",
|
|
145
|
+
"matched_pattern": "…",
|
|
146
|
+
"detection_method": "pattern_strong"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The local record keeps forensic fields (`threat_id`, `matched_pattern`, `detection_method`). The emitted `warden.threat.detected` event carries only the schema-permitted fields — warden payloads use `additionalProperties: false`. State is keyed by `session_id`, not by repository: the gate guards a single session's context.
|
|
152
|
+
|
|
153
|
+
## Events emitted
|
|
154
|
+
|
|
155
|
+
Warden emits the canonical `warden.*` event surface from [`@onlooker-community/schema`](https://github.com/onlooker-community/schema) (v2.4.0+). All events land in `~/.onlooker/logs/onlooker-events.jsonl` and are validated against the schema before write.
|
|
156
|
+
|
|
157
|
+
| Event | When | Payload |
|
|
158
|
+
|-------|------|---------|
|
|
159
|
+
| `warden.threat.detected` | A scan closes the gate. | `source_type`, `threat_type`, `confidence` (plus optional `source_url` / `source_path` / `snippet`) |
|
|
160
|
+
| `warden.gate.blocked` | A write/edit/bash operation is blocked by a closed gate. | `blocked_operation`, `threat_source_type` |
|
|
161
|
+
| `warden.threat.cleared` | The user clears the gate via `/warden`. | `source_type`, `cleared_by: user_override` |
|
|
162
|
+
|
|
163
|
+
## The `/warden` skill
|
|
164
|
+
|
|
165
|
+
`/warden` is the user-facing control surface for the gate that the hooks open and close automatically.
|
|
166
|
+
|
|
167
|
+
- `/warden` or `/warden 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 (when storage is enabled).
|
|
168
|
+
- `/warden clear` (also `reopen`, `override`, `unblock`) — verifies the gate is closed, removes the lock, re-enables `Write`/`Edit`/`Bash`, and emits `warden.threat.cleared` with `cleared_by: user_override`.
|
|
169
|
+
|
|
170
|
+
The skill resolves the active session automatically: it prefers `$CLAUDE_SESSION_ID`, falls back to the single closed gate when exactly one exists, and reports ambiguity if several sessions have closed gates (re-run with an explicit session id in that case). Closing is automatic; clearing is always a deliberate user decision.
|
|
171
|
+
|
|
172
|
+
## Requirements
|
|
173
|
+
|
|
174
|
+
- The `ecosystem` plugin installed (for the `~/.onlooker/` substrate and canonical event emission).
|
|
175
|
+
- `claude` CLI on `PATH` (the evaluator shells out to `claude -p` when escalation is enabled).
|
|
176
|
+
- `jq` for JSON manipulation.
|
|
177
|
+
- `node` for canonical-event emission.
|
|
178
|
+
|
|
179
|
+
## Architecture decisions
|
|
180
|
+
|
|
181
|
+
Key decisions made during initial design are recorded in [`docs/adr/`](docs/adr/):
|
|
182
|
+
|
|
183
|
+
- [ADR-001](docs/adr/001-detect-after-ingest-gate-before-action.md) — Detect after ingestion, gate before action (the detection/enforcement split and its Rule-of-Two mapping)
|
|
184
|
+
|
|
185
|
+
See also the full plugin design in [`docs/design.md`](docs/design.md).
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
#
|
|
3
|
+
# Exercises the librarian-cli surface that the /librarian review skill
|
|
4
|
+
# drives. Each test seeds one or more proposals directly into the
|
|
5
|
+
# librarian storage layer (skipping the SessionEnd scan pipeline) and
|
|
6
|
+
# verifies that list/show/accept/reject/defer/status behave as the
|
|
7
|
+
# skill expects:
|
|
8
|
+
#
|
|
9
|
+
# - list → returns a count + table, sized to pending proposals only
|
|
10
|
+
# - show → renders provenance + body, fails clean on unknown id
|
|
11
|
+
# - accept → writes the typed memory file with provenance frontmatter,
|
|
12
|
+
# appends to MEMORY.md, sets status=accepted, emits
|
|
13
|
+
# librarian.proposal.accepted
|
|
14
|
+
# - reject → writes a body-hash tombstone, sets status=rejected,
|
|
15
|
+
# emits librarian.proposal.rejected AND
|
|
16
|
+
# librarian.tombstone.created
|
|
17
|
+
# - defer → leaves status pending but stamps the proposal so a
|
|
18
|
+
# reviewer can tell it was visited
|
|
19
|
+
# - status → reports pending/accepted/rejected counts
|
|
20
|
+
#
|
|
21
|
+
# The CLI is sourced into the bats shell directly (it's a library, not
|
|
22
|
+
# a hook), so assertions can read both stdout and side-effects on disk.
|
|
23
|
+
|
|
24
|
+
setup() {
|
|
25
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
26
|
+
setup_test_env
|
|
27
|
+
|
|
28
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
|
|
29
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
30
|
+
export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
|
|
31
|
+
|
|
32
|
+
PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
|
|
33
|
+
mkdir -p "$PROJECT_REPO"
|
|
34
|
+
git -C "$PROJECT_REPO" init -q
|
|
35
|
+
git -C "$PROJECT_REPO" config user.email t@example.com
|
|
36
|
+
git -C "$PROJECT_REPO" config user.name "Test"
|
|
37
|
+
git -C "$PROJECT_REPO" remote add origin git@github.com:org/librarian-cli-test.git
|
|
38
|
+
|
|
39
|
+
# Source the five libs the skill loads, in the same order the SKILL.md
|
|
40
|
+
# walkthrough sources them. librarian-cli depends on storage + emit +
|
|
41
|
+
# project-key (and indirectly config).
|
|
42
|
+
# shellcheck disable=SC1091
|
|
43
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
|
|
44
|
+
# shellcheck disable=SC1091
|
|
45
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
|
|
46
|
+
# shellcheck disable=SC1091
|
|
47
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
48
|
+
# shellcheck disable=SC1091
|
|
49
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
|
|
50
|
+
# shellcheck disable=SC1091
|
|
51
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
|
|
52
|
+
|
|
53
|
+
PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
|
|
54
|
+
[ -n "$PROJECT_KEY" ]
|
|
55
|
+
|
|
56
|
+
LIBRARIAN_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}"
|
|
57
|
+
ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
|
|
58
|
+
export ONLOOKER_EVENTS_LOG
|
|
59
|
+
|
|
60
|
+
# Where librarian_cli_accept writes typed memory. The CLI derives the
|
|
61
|
+
# encoded path from cwd when CLAUDE_PROJECT_ENCODED is unset; mirror
|
|
62
|
+
# that derivation so tests can assert against the resulting file.
|
|
63
|
+
ABS_CWD=$(cd "$PROJECT_REPO" && pwd -P)
|
|
64
|
+
ENCODED=$(printf '%s' "$ABS_CWD" | sed -E 's#/#-#g')
|
|
65
|
+
MEM_DIR="${TEST_HOME}/.claude/projects/${ENCODED}/memory"
|
|
66
|
+
|
|
67
|
+
librarian_storage_init "$PROJECT_KEY"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Seed a single proposal JSON file directly into the storage layer.
|
|
71
|
+
# Usage: _seed_proposal <id> <type> <title> <filename> <body> [confidence] [status]
|
|
72
|
+
_seed_proposal() {
|
|
73
|
+
local id="$1" type="$2" title="$3" filename="$4" body="$5"
|
|
74
|
+
local confidence="${6:-0.82}" status="${7:-pending}"
|
|
75
|
+
local now json
|
|
76
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
77
|
+
json=$(jq -cn \
|
|
78
|
+
--arg id "$id" \
|
|
79
|
+
--arg type "$type" \
|
|
80
|
+
--arg title "$title" \
|
|
81
|
+
--arg filename "$filename" \
|
|
82
|
+
--arg body "$body" \
|
|
83
|
+
--argjson conf "$confidence" \
|
|
84
|
+
--arg status "$status" \
|
|
85
|
+
--arg now "$now" \
|
|
86
|
+
--arg project_key "$PROJECT_KEY" \
|
|
87
|
+
'{
|
|
88
|
+
id: $id,
|
|
89
|
+
project_key: $project_key,
|
|
90
|
+
status: $status,
|
|
91
|
+
conflict_state: "none",
|
|
92
|
+
created_at: $now,
|
|
93
|
+
updated_at: $now,
|
|
94
|
+
source_session_ids: ["sess-seeded"],
|
|
95
|
+
source_artifact_ids: ["01ARTIFACT00000000000000"],
|
|
96
|
+
proposed: {
|
|
97
|
+
type: $type,
|
|
98
|
+
title: $title,
|
|
99
|
+
filename: $filename,
|
|
100
|
+
body: $body,
|
|
101
|
+
classifier_confidence: $conf
|
|
102
|
+
}
|
|
103
|
+
}')
|
|
104
|
+
librarian_storage_write_proposal "$PROJECT_KEY" "$id" "$json" >/dev/null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@test "list reports no-pending when the queue is empty" {
|
|
108
|
+
run librarian_cli_list "$PROJECT_REPO"
|
|
109
|
+
[ "$status" -eq 0 ]
|
|
110
|
+
[[ "$output" == *"No pending proposals."* ]]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@test "list summarizes pending proposals and ignores resolved ones" {
|
|
114
|
+
_seed_proposal "01LISTPENDINGA000000000000" \
|
|
115
|
+
"feedback" "Prefer functional patterns" "feedback_functional.md" \
|
|
116
|
+
"Body A."
|
|
117
|
+
_seed_proposal "01LISTPENDINGB000000000000" \
|
|
118
|
+
"project" "Auth rewrite is compliance" "project_auth_compliance.md" \
|
|
119
|
+
"Body B." "0.91"
|
|
120
|
+
_seed_proposal "01LISTACCEPTED0000000000000" \
|
|
121
|
+
"user" "Already accepted" "user_old.md" \
|
|
122
|
+
"Body C." "0.7" "accepted"
|
|
123
|
+
|
|
124
|
+
run librarian_cli_list "$PROJECT_REPO"
|
|
125
|
+
[ "$status" -eq 0 ]
|
|
126
|
+
# Header counts only pending entries (2 of 3).
|
|
127
|
+
[[ "$output" == *"2 pending proposal"* ]]
|
|
128
|
+
# Both pending titles surface.
|
|
129
|
+
[[ "$output" == *"Prefer functional patterns"* ]]
|
|
130
|
+
[[ "$output" == *"Auth rewrite is compliance"* ]]
|
|
131
|
+
# Pending rows include full IDs that can be used with show/accept/reject/defer.
|
|
132
|
+
[[ "$output" == *"01LISTPENDINGA000000000000"* ]]
|
|
133
|
+
[[ "$output" == *"01LISTPENDINGB000000000000"* ]]
|
|
134
|
+
# Accepted entry does NOT appear in the list output.
|
|
135
|
+
[[ "$output" != *"Already accepted"* ]]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@test "show renders provenance + body for an existing proposal" {
|
|
139
|
+
_seed_proposal "01SHOWPROPOSAL0000000000000" \
|
|
140
|
+
"feedback" "Some title" "feedback_x.md" \
|
|
141
|
+
"Body of the memory."
|
|
142
|
+
|
|
143
|
+
run librarian_cli_show "01SHOWPROPOSAL0000000000000" "$PROJECT_REPO"
|
|
144
|
+
[ "$status" -eq 0 ]
|
|
145
|
+
[[ "$output" == *"01SHOWPROPOSAL0000000000000"* ]]
|
|
146
|
+
[[ "$output" == *"type: feedback"* ]]
|
|
147
|
+
[[ "$output" == *"filename: feedback_x.md"* ]]
|
|
148
|
+
[[ "$output" == *"classifier_confidence: 0.82"* ]]
|
|
149
|
+
[[ "$output" == *"Body of the memory."* ]]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@test "show fails clean on an unknown proposal id" {
|
|
153
|
+
run librarian_cli_show "01NOSUCHPROPOSAL00000000000" "$PROJECT_REPO"
|
|
154
|
+
[ "$status" -eq 1 ]
|
|
155
|
+
[[ "$output" == *"not found"* ]]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@test "accept writes a memory file with provenance frontmatter" {
|
|
159
|
+
_seed_proposal "01ACCEPTPROPOSAL000000000000" \
|
|
160
|
+
"feedback" "Prefer functional patterns" "feedback_functional.md" \
|
|
161
|
+
"User prefers functional patterns.
|
|
162
|
+
|
|
163
|
+
**Why:** Stated explicitly.
|
|
164
|
+
**How to apply:** Default to plain functions."
|
|
165
|
+
|
|
166
|
+
run librarian_cli_accept "01ACCEPTPROPOSAL000000000000" "$PROJECT_REPO"
|
|
167
|
+
[ "$status" -eq 0 ]
|
|
168
|
+
[[ "$output" == *"Accepted."* ]]
|
|
169
|
+
|
|
170
|
+
local out_file="${MEM_DIR}/feedback_functional.md"
|
|
171
|
+
[ -f "$out_file" ]
|
|
172
|
+
|
|
173
|
+
# Frontmatter records who promoted it, when, and from where.
|
|
174
|
+
grep -q "^source: librarian$" "$out_file"
|
|
175
|
+
grep -q "^type: feedback$" "$out_file"
|
|
176
|
+
grep -q "^name: Prefer functional patterns$" "$out_file"
|
|
177
|
+
grep -q "^classifier_confidence: 0.82$" "$out_file"
|
|
178
|
+
grep -q "^promoted_at: " "$out_file"
|
|
179
|
+
# Body survives in full.
|
|
180
|
+
grep -q "Default to plain functions" "$out_file"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@test "accept appends to MEMORY.md and creates it if missing" {
|
|
184
|
+
_seed_proposal "01ACCEPTINDEX000000000000000" \
|
|
185
|
+
"project" "Auth rewrite is compliance" "project_auth_compliance.md" \
|
|
186
|
+
"Compliance-driven."
|
|
187
|
+
|
|
188
|
+
[ ! -f "${MEM_DIR}/MEMORY.md" ]
|
|
189
|
+
|
|
190
|
+
run librarian_cli_accept "01ACCEPTINDEX000000000000000" "$PROJECT_REPO"
|
|
191
|
+
[ "$status" -eq 0 ]
|
|
192
|
+
|
|
193
|
+
[ -f "${MEM_DIR}/MEMORY.md" ]
|
|
194
|
+
grep -F -q "(project_auth_compliance.md)" "${MEM_DIR}/MEMORY.md"
|
|
195
|
+
grep -F -q "Auth rewrite is compliance" "${MEM_DIR}/MEMORY.md"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@test "accept marks the proposal accepted and emits librarian.proposal.accepted" {
|
|
199
|
+
_seed_proposal "01ACCEPTEVENT000000000000000" \
|
|
200
|
+
"user" "User role" "user_role.md" "Body."
|
|
201
|
+
|
|
202
|
+
run librarian_cli_accept "01ACCEPTEVENT000000000000000" "$PROJECT_REPO"
|
|
203
|
+
[ "$status" -eq 0 ]
|
|
204
|
+
|
|
205
|
+
# Proposal file flipped to accepted.
|
|
206
|
+
local proposal_path="${LIBRARIAN_DIR}/proposals/01ACCEPTEVENT000000000000000.json"
|
|
207
|
+
jq -e '.status == "accepted"' "$proposal_path" >/dev/null
|
|
208
|
+
jq -e '.final_filename == "user_role.md"' "$proposal_path" >/dev/null
|
|
209
|
+
|
|
210
|
+
# Event landed in the canonical events log.
|
|
211
|
+
[ -f "$ONLOOKER_EVENTS_LOG" ]
|
|
212
|
+
grep -q '"event_type":"librarian.proposal.accepted"' "$ONLOOKER_EVENTS_LOG"
|
|
213
|
+
grep '"event_type":"librarian.proposal.accepted"' "$ONLOOKER_EVENTS_LOG" \
|
|
214
|
+
| jq -e '.payload.proposal_id == "01ACCEPTEVENT000000000000000" and .payload.final_filename == "user_role.md"' >/dev/null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@test "accept refuses path-traversal filenames and writes nothing" {
|
|
218
|
+
_seed_proposal "01ACCEPTUNSAFE00000000000000" \
|
|
219
|
+
"user" "Bad filename" "../escape.md" "Body."
|
|
220
|
+
|
|
221
|
+
run librarian_cli_accept "01ACCEPTUNSAFE00000000000000" "$PROJECT_REPO"
|
|
222
|
+
[ "$status" -eq 1 ]
|
|
223
|
+
[[ "$output" == *"Failed to write memory file."* ]]
|
|
224
|
+
|
|
225
|
+
# No memory file written anywhere under MEM_DIR or its parent.
|
|
226
|
+
[ ! -f "${MEM_DIR}/../escape.md" ]
|
|
227
|
+
[ ! -f "${MEM_DIR}/escape.md" ]
|
|
228
|
+
|
|
229
|
+
# Proposal stays pending so a reviewer can resolve it manually.
|
|
230
|
+
local proposal_path="${LIBRARIAN_DIR}/proposals/01ACCEPTUNSAFE00000000000000.json"
|
|
231
|
+
jq -e '.status == "pending"' "$proposal_path" >/dev/null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@test "reject writes a tombstone and marks the proposal rejected" {
|
|
235
|
+
local body="This is the body whose hash anchors the tombstone."
|
|
236
|
+
_seed_proposal "01REJECTPROPOSAL00000000000" \
|
|
237
|
+
"feedback" "Some idea" "feedback_some_idea.md" "$body"
|
|
238
|
+
|
|
239
|
+
run librarian_cli_reject "01REJECTPROPOSAL00000000000" "stale guidance" "$PROJECT_REPO"
|
|
240
|
+
[ "$status" -eq 0 ]
|
|
241
|
+
[[ "$output" == *"Rejected"* ]]
|
|
242
|
+
[[ "$output" == *"stale guidance"* ]]
|
|
243
|
+
|
|
244
|
+
# Proposal status now rejected with the reason captured.
|
|
245
|
+
local proposal_path="${LIBRARIAN_DIR}/proposals/01REJECTPROPOSAL00000000000.json"
|
|
246
|
+
jq -e '.status == "rejected"' "$proposal_path" >/dev/null
|
|
247
|
+
jq -e '.reason == "stale guidance"' "$proposal_path" >/dev/null
|
|
248
|
+
|
|
249
|
+
# Tombstone file present, keyed on the body hash.
|
|
250
|
+
local expected_hash
|
|
251
|
+
expected_hash=$(librarian_body_hash "$body")
|
|
252
|
+
[ -n "$expected_hash" ]
|
|
253
|
+
[ -f "${LIBRARIAN_DIR}/tombstones/${expected_hash}.json" ]
|
|
254
|
+
librarian_storage_has_tombstone "$PROJECT_KEY" "$expected_hash"
|
|
255
|
+
|
|
256
|
+
# Both events fired.
|
|
257
|
+
grep -q '"event_type":"librarian.proposal.rejected"' "$ONLOOKER_EVENTS_LOG"
|
|
258
|
+
grep -q '"event_type":"librarian.tombstone.created"' "$ONLOOKER_EVENTS_LOG"
|
|
259
|
+
grep '"event_type":"librarian.proposal.rejected"' "$ONLOOKER_EVENTS_LOG" \
|
|
260
|
+
| jq -e '.payload.reason == "stale guidance"' >/dev/null
|
|
261
|
+
grep '"event_type":"librarian.tombstone.created"' "$ONLOOKER_EVENTS_LOG" \
|
|
262
|
+
| jq -e --arg h "$expected_hash" '.payload.body_hash == $h' >/dev/null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
@test "defer stamps the proposal but leaves it pending" {
|
|
266
|
+
_seed_proposal "01DEFERPROPOSAL000000000000" \
|
|
267
|
+
"user" "Maybe later" "user_maybe.md" "Body."
|
|
268
|
+
|
|
269
|
+
run librarian_cli_defer "01DEFERPROPOSAL000000000000" "$PROJECT_REPO"
|
|
270
|
+
[ "$status" -eq 0 ]
|
|
271
|
+
[[ "$output" == *"Deferred"* ]]
|
|
272
|
+
|
|
273
|
+
local proposal_path="${LIBRARIAN_DIR}/proposals/01DEFERPROPOSAL000000000000.json"
|
|
274
|
+
jq -e '.status == "pending"' "$proposal_path" >/dev/null
|
|
275
|
+
jq -e '.deferred == true' "$proposal_path" >/dev/null
|
|
276
|
+
|
|
277
|
+
# Defer should NOT touch the memory store.
|
|
278
|
+
[ ! -d "$MEM_DIR" ] || [ -z "$(ls -A "$MEM_DIR" 2>/dev/null)" ]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@test "status reports counts across pending, accepted, and rejected" {
|
|
282
|
+
_seed_proposal "01STATUSPENDING0000000000000" "user" "P" "user_p.md" "p" "0.7" "pending"
|
|
283
|
+
_seed_proposal "01STATUSACCEPTED000000000000" "user" "A" "user_a.md" "a" "0.7" "accepted"
|
|
284
|
+
_seed_proposal "01STATUSREJECTED000000000000" "user" "R" "user_r.md" "r" "0.7" "rejected"
|
|
285
|
+
|
|
286
|
+
run librarian_cli_status "$PROJECT_REPO"
|
|
287
|
+
[ "$status" -eq 0 ]
|
|
288
|
+
[[ "$output" == *"pending: 1"* ]]
|
|
289
|
+
[[ "$output" == *"accepted: 1"* ]]
|
|
290
|
+
[[ "$output" == *"rejected: 1"* ]]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@test "librarian_cli dispatch routes to the right subcommand" {
|
|
294
|
+
_seed_proposal "01DISPATCH00000000000000000" \
|
|
295
|
+
"user" "Dispatch test" "user_dispatch.md" "Body."
|
|
296
|
+
|
|
297
|
+
# Unknown action returns exit 2.
|
|
298
|
+
run librarian_cli "explode"
|
|
299
|
+
[ "$status" -eq 2 ]
|
|
300
|
+
|
|
301
|
+
# Known action delegates correctly.
|
|
302
|
+
run librarian_cli "show" "01DISPATCH00000000000000000" "$PROJECT_REPO"
|
|
303
|
+
[ "$status" -eq 0 ]
|
|
304
|
+
[[ "$output" == *"Dispatch test"* ]]
|
|
305
|
+
}
|