Compare commits

...

2 Commits

5 changed files with 261 additions and 9 deletions

View File

@@ -123,7 +123,7 @@ RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1
src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_nostr_block.c src/tools/tool_local.c \
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/tools/tool_cashu_wallet.c src/tools/tool_blossom.c src/trigger_manager.c \
src/cashu_wallet.c src/nostr_block_list.c src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \
src/json_to_markdown.c src/context_roles.c src/context_format.c \
src/json_to_markdown.c src/context_roles.c src/context_format.c src/signer_health.c \
-o /build/didactyl_static \
$NOSTR_LIB \
-lsecp256k1 \

View File

@@ -54,11 +54,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
## Current Status — v0.2.51
## Current Status — v0.2.53
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.51Implement n_signer integration through Phase 4: signer config/CLI/wizard, signer-backed signing/encryption/auth/cashu/blossom paths, nsigner_unix e2e validation, and signer health observability
> Last release update: v0.2.53Add signer_health.c to Dockerfile.alpine-musl source list (was missing, causing static build failure)
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected

View File

@@ -0,0 +1,77 @@
# Fix: Trigger Manager Bypasses Skill Adoption List
## Problem
`trigger_manager_load_from_skills()` and `trigger_manager_reconcile_from_relays()`
activate cron/webhook/subscription triggers for **every** self-authored skill event
(kind 31123/31124) that has trigger metadata, without checking whether the skill is
on the kind 10123 adoption list.
This means a skill that was published but never adopted (or was un-adopted) will
still have its cron trigger firing.
Contrast: `agent.c:refresh_adopted_skills_cache_if_needed()` properly fetches the
kind 10123 list, parses `"a"` tags, and only loads adopted skills — and also checks
the `enabled` tag. The trigger manager was written as a parallel system that skips
both gates.
## Root Cause
- `trigger_manager.c:939``trigger_manager_load_from_skills()`: no reference to
kind 10123 or adoption anywhere in the function. Calls `trigger_manager_add()`
unconditionally.
- `trigger_manager.c:592``trigger_manager_reconcile_from_relays()`: same bug.
Currently disabled (`enable_periodic_reconcile = 0` at line 1575) but should be
fixed before re-enabling.
- Neither function checks the `enabled` tag either, unlike
`trigger_manager_load_from_startup_events()` (line 1078) which does check it.
## Fix
### 1. Add a static helper: `load_adopted_d_tags()`
Add a static function in `trigger_manager.c` that:
- Fetches kind 10123 events via `nostr_handler_get_self_events_by_kind_json(10123)`
- Selects the latest event by `created_at` (same logic as agent.c)
- Parses `"a"` tags using a local `parse_skill_address_tag_local()` equivalent
- Populates a fixed-size array of adopted d_tag strings
- Returns the count
Since both call sites only deal with self-authored skills, matching on d_tag
alone is sufficient (the pubkey in the "a" tag will be the agent's own).
### 2. Add `enabled` tag check
Both functions should read the `enabled` tag (defaulting to `"true"`) and skip
skills where `enabled` is `false` or `0`, matching the pattern already used in
`trigger_manager_load_from_startup_events()`.
### 3. Gate `trigger_manager_load_from_skills()`
Before calling `trigger_manager_add()`, check:
1. The skill's d_tag is in the adopted set
2. The skill's `enabled` tag is not false/0
If the adoption list is empty or missing, log a warning and load nothing (do not
fall back to loading all skills — that's the bug).
### 4. Gate `trigger_manager_reconcile_from_relays()`
Apply the same adoption + enabled checks. This ensures the code is correct when
`enable_periodic_reconcile` is eventually flipped back to 1.
### 5. Pass `is_enabled` to `trigger_manager_add()`
Currently both functions pass `1` (hardcoded enabled) as the `enabled` parameter.
Change to pass the actual `is_enabled` value derived from the `enabled` tag.
## Files Changed
- `src/trigger_manager.c` — add helper, fix both functions
## Risk
Low. The fix adds a filter before `trigger_manager_add()`. If the adoption list
is present and correct, behavior is unchanged for adopted skills. If the adoption
list is missing, triggers simply won't load (which is the correct behavior —
nothing should fire if nothing is adopted).

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 2
#define DIDACTYL_VERSION_PATCH 51
#define DIDACTYL_VERSION "v0.2.51"
#define DIDACTYL_VERSION_PATCH 53
#define DIDACTYL_VERSION "v0.2.53"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -589,6 +589,126 @@ static void parse_trigger_runtime_tags(cJSON* tags,
}
}
/*
* Load the set of adopted skill d_tags from the kind 10123 adoption list.
*
* Fetches the agent's latest kind 10123 event from cache, parses the "a" tags,
* and populates out_d_tags with the d_tag portion of each adopted skill address.
*
* Since both trigger load paths only deal with self-authored skills, matching on
* d_tag alone is sufficient — the pubkey in the "a" tag will be the agent's own.
*
* Returns the number of adopted d_tags, or -1 on error.
*/
#define TRIGGER_ADOPTED_D_TAGS_MAX 64
static int load_adopted_d_tags(trigger_manager_t* mgr,
char out_d_tags[][65],
int max_d_tags) {
if (!mgr || !mgr->cfg || !out_d_tags || max_d_tags <= 0) {
return -1;
}
int count = 0;
char* adoption_json = nostr_handler_get_self_events_by_kind_json(10123);
if (!adoption_json) {
DEBUG_WARN("[didactyl] trigger adoption check: no kind 10123 event in cache");
return 0;
}
cJSON* adoption_events = cJSON_Parse(adoption_json);
free(adoption_json);
if (!adoption_events || !cJSON_IsArray(adoption_events) ||
cJSON_GetArraySize(adoption_events) <= 0) {
cJSON_Delete(adoption_events);
DEBUG_WARN("[didactyl] trigger adoption check: kind 10123 parse failed or empty");
return 0;
}
/* Find the latest adoption list event by created_at */
cJSON* list_event = NULL;
double list_event_created_at = -1.0;
int adoption_n = cJSON_GetArraySize(adoption_events);
for (int ai = 0; ai < adoption_n; ai++) {
cJSON* ev = cJSON_GetArrayItem(adoption_events, ai);
if (!ev || !cJSON_IsObject(ev)) {
continue;
}
cJSON* ev_pubkey = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
strcmp(ev_pubkey->valuestring, mgr->cfg->keys.public_key_hex) != 0) {
continue;
}
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
double created = (created_at && cJSON_IsNumber(created_at)) ? created_at->valuedouble : 0.0;
if (!list_event || created > list_event_created_at) {
list_event = ev;
list_event_created_at = created;
}
}
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
if (!list_tags || !cJSON_IsArray(list_tags)) {
cJSON_Delete(adoption_events);
DEBUG_INFO("[didactyl] trigger adoption check: no tags in adoption list event");
return 0;
}
int tn = cJSON_GetArraySize(list_tags);
for (int i = 0; i < tn && count < max_d_tags; i++) {
cJSON* tag = cJSON_GetArrayItem(list_tags, i);
if (!tag || !cJSON_IsArray(tag)) {
continue;
}
cJSON* key = cJSON_GetArrayItem(tag, 0);
cJSON* val = cJSON_GetArrayItem(tag, 1);
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) ||
!key->valuestring || !val->valuestring ||
strcmp(key->valuestring, "a") != 0) {
continue;
}
/* Parse "kind:pubkey:d_tag" — we only need the d_tag (3rd field) */
const char* p1 = strchr(val->valuestring, ':');
if (!p1) continue;
const char* p2 = strchr(p1 + 1, ':');
if (!p2) continue;
const char* d_tag_start = p2 + 1;
size_t d_tag_len = strlen(d_tag_start);
if (d_tag_len == 0 || d_tag_len >= 65U) {
continue;
}
snprintf(out_d_tags[count], 65, "%s", d_tag_start);
count++;
}
cJSON_Delete(adoption_events);
DEBUG_INFO("[didactyl] trigger adoption check: %d adopted skill(s) found", count);
return count;
}
/*
* Check whether d_tag appears in the adopted_d_tags array.
*/
static int is_d_tag_adopted(const char* d_tag,
char adopted_d_tags[][65],
int adopted_count) {
if (!d_tag || d_tag[0] == '\0' || !adopted_d_tags || adopted_count <= 0) {
return 0;
}
for (int i = 0; i < adopted_count; i++) {
if (strcmp(adopted_d_tags[i], d_tag) == 0) {
return 1;
}
}
return 0;
}
static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
if (!mgr || !mgr->cfg) {
return -1;
@@ -628,6 +748,18 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
int considered = 0;
int loaded = 0;
int skipped_not_adopted = 0;
int skipped_disabled = 0;
/* Load adopted d_tags from kind 10123 adoption list */
char adopted_d_tags[TRIGGER_ADOPTED_D_TAGS_MAX][65];
memset(adopted_d_tags, 0, sizeof(adopted_d_tags));
int adopted_count = load_adopted_d_tags(mgr, adopted_d_tags, TRIGGER_ADOPTED_D_TAGS_MAX);
if (adopted_count <= 0) {
DEBUG_WARN("[didactyl] trigger reconcile: no adopted skills found — skipping all trigger registration");
cJSON_Delete(events);
return 0;
}
int event_count = cJSON_GetArraySize(events);
for (int i = 0; i < event_count; i++) {
@@ -642,17 +774,25 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
cJSON* trigger = find_tag_value_string(tags, "trigger");
cJSON* filter_j = find_tag_value_string(tags, "filter");
cJSON* action = find_tag_value_string(tags, "action");
cJSON* enabled = find_tag_value_string(tags, "enabled");
const char* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
const char* filter_s = (filter_j && cJSON_IsString(filter_j) && filter_j->valuestring) ? filter_j->valuestring : NULL;
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
considered++;
if (!d_tag || d_tag[0] == '\0') {
continue;
}
/* Adoption gate: only register triggers for adopted skills */
if (!is_d_tag_adopted(d_tag, adopted_d_tags, adopted_count)) {
skipped_not_adopted++;
continue;
}
int trigger_supported = trigger_s &&
(strcmp(trigger_s, "nostr-subscription") == 0 ||
strcmp(trigger_s, "webhook") == 0 ||
@@ -663,6 +803,13 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
continue;
}
/* Enabled gate: skip skills explicitly disabled */
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
if (!is_enabled) {
skipped_disabled++;
continue;
}
if (strcmp(action_s, "template") == 0) {
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
}
@@ -691,7 +838,7 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
filter_s,
TRIGGER_ACTION_LLM,
trigger_s,
1,
is_enabled,
llm_s,
tools_s,
has_max_tokens,
@@ -705,7 +852,8 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
}
cJSON_Delete(events);
DEBUG_INFO("[didactyl] trigger reconcile complete: loaded=%d considered=%d", loaded, considered);
DEBUG_INFO("[didactyl] trigger reconcile complete: loaded=%d considered=%d skipped_not_adopted=%d skipped_disabled=%d",
loaded, considered, skipped_not_adopted, skipped_disabled);
return 0;
}
@@ -943,8 +1091,19 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
int loaded = 0;
int considered = 0;
int skipped_not_adopted = 0;
int skipped_disabled = 0;
const int kinds[2] = {31123, 31124};
/* Load adopted d_tags from kind 10123 adoption list */
char adopted_d_tags[TRIGGER_ADOPTED_D_TAGS_MAX][65];
memset(adopted_d_tags, 0, sizeof(adopted_d_tags));
int adopted_count = load_adopted_d_tags(mgr, adopted_d_tags, TRIGGER_ADOPTED_D_TAGS_MAX);
if (adopted_count <= 0) {
DEBUG_WARN("[didactyl] trigger load from skills: no adopted skills found — skipping all trigger registration");
return 0;
}
for (int k = 0; k < 2; k++) {
char* skill_json = nostr_handler_get_self_events_by_kind_json(kinds[k]);
if (!skill_json) {
@@ -971,17 +1130,25 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
cJSON* trigger = find_tag_value_string(tags, "trigger");
cJSON* filter = find_tag_value_string(tags, "filter");
cJSON* action = find_tag_value_string(tags, "action");
cJSON* enabled = find_tag_value_string(tags, "enabled");
const char* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
considered++;
if (!d_tag || d_tag[0] == '\0') {
continue;
}
/* Adoption gate: only register triggers for adopted skills */
if (!is_d_tag_adopted(d_tag, adopted_d_tags, adopted_count)) {
skipped_not_adopted++;
continue;
}
int trigger_supported = trigger_s &&
(strcmp(trigger_s, "nostr-subscription") == 0 ||
strcmp(trigger_s, "webhook") == 0 ||
@@ -992,6 +1159,13 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
continue;
}
/* Enabled gate: skip skills explicitly disabled */
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
if (!is_enabled) {
skipped_disabled++;
continue;
}
if (strcmp(action_s, "template") == 0) {
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
}
@@ -1020,7 +1194,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
filter_s,
TRIGGER_ACTION_LLM,
trigger_s,
1,
is_enabled,
llm_s,
tools_s,
has_max_tokens,
@@ -1036,7 +1210,8 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
cJSON_Delete(skill_events);
}
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from self skills (considered=%d)", loaded, considered);
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from self skills (considered=%d skipped_not_adopted=%d skipped_disabled=%d)",
loaded, considered, skipped_not_adopted, skipped_disabled);
return 0;
}