From 7116abd1a4496fc4375938ee337e2d39afee99fa Mon Sep 17 00:00:00 2001 From: Didactyl User Date: Fri, 10 Jul 2026 19:13:19 -0400 Subject: [PATCH] =?UTF-8?q?v0.2.52=20-=20Fix=20trigger=20manager=20bypassi?= =?UTF-8?q?ng=20skill=20adoption=20list=20=E2=80=94=20cron/webhook=20trigg?= =?UTF-8?q?ers=20now=20only=20fire=20for=20adopted=20and=20enabled=20skill?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- plans/fix_trigger_adoption_gate.md | 77 ++++++++++++ src/main.h | 4 +- src/trigger_manager.c | 183 ++++++++++++++++++++++++++++- 4 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 plans/fix_trigger_adoption_gate.md diff --git a/README.md b/README.md index ceb0c25..75e7520 100644 --- a/README.md +++ b/README.md @@ -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.52 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.2.51 — Implement 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.52 — Fix trigger manager bypassing skill adoption list — cron/webhook triggers now only fire for adopted and enabled skills - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/plans/fix_trigger_adoption_gate.md b/plans/fix_trigger_adoption_gate.md new file mode 100644 index 0000000..b896331 --- /dev/null +++ b/plans/fix_trigger_adoption_gate.md @@ -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). diff --git a/src/main.h b/src/main.h index 9bb0f76..456a8c5 100644 --- a/src/main.h +++ b/src/main.h @@ -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 52 +#define DIDACTYL_VERSION "v0.2.52" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/trigger_manager.c b/src/trigger_manager.c index 8a2bc93..3dea8ab 100644 --- a/src/trigger_manager.c +++ b/src/trigger_manager.c @@ -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; }