feat: user-defined trigger scripts with variables and prompt injections #16
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/user-triggers"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
AI-Dungeon-style scripting layer for the chat pipeline. Users write Dart trigger scripts that run on hooks and manipulate a persistent internal state.
How it works
A trigger =
hook + script. Scripts are compiled with dart_eval and sandboxed in two layers: a compile-time import allowlist (dart:core,dart:convert,dart:math—dart:ioand everything else is rejected with a diagnostic), backed by dart_eval's default deny-all runtime permissions (noruntime.grant()anywhere, guarded by a code comment). Execution happens in a throwaway isolate with a 1s hard timeout, so awhile(true)can never stall the chat. Scripts are pure: they mutate a variable snapshot and queue effects; theTriggerEngineapplies everything afterwards.Ctx API:
vars(assistant-wide) /convVars(per-conversation),input/output,messageCount,injectOnce/injectPersistent(key,…)/removeInjection,runGeneration,enableTrigger/disableTrigger,log.Hooks:
on_user_message(after user msg persisted, before the agent runs — injections land in that same generation) andon_assistant_message(after final reply persisted — injections land in the next generation). Queued generations run timer-style after the chat (private output,message_useronly) and cannot re-fire triggers, so loops are structurally impossible.AI has no access: no tool, no automatic state section in the prompt. The only way state reaches the model is an explicit
injectOnce/injectPersistent(rendered as## Current Directivesnear the end of the chat prompt).Pieces
triggers,trigger_variables,prompt_injections(all CASCADE on assistant)TriggerEngine(angela_core) with bytecode cache (keyed on script+hook hash), per-trigger cooldown,last_errortracking; failures are logged + recorded, never block the chatAgentRunner.runChatconsumes queued injections intobuildChatPromptcontinue), and a 7-recipe cookbook; a doc test compiles AND dry-runs every cookbook script so examples can't driftSettings-UI tab (script editor + variable inspector) is the natural follow-up PR.
🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my, an AI-Dungeon-style scripting layer with sandboxed dart_eval, isolate timeouts, bytecode caching, and an effect-queue architecture? fufu~ ♡ This is genuinely delightful work, scarlet! The purity model — scripts mutate a snapshot, queue effects, the engine applies them after — is exactly the right shape. The cooldown logic, the cross-trigger variable carry, the
errorsAreFatal+isolate.killcleanup... I'm impressed. You clearly thought about safety. ♪But... you know I love this code too much to let it ship half-tested. The smile stays on, but the knife comes out~ ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
on_assistant_messagehook has ZERO test coverage. [trigger_engine_test.dart] — Every single one of your 15 tests usesTriggerHook.onUserMessage. TheonAssistantMessagehook is a first-class feature (separate function name, separate fire path, fires inchat_executor.dart:186, passesoutput), and not a single test exercises it. I verified:grep -c "on_assistant_message" trigger_engine_test.dart→0.This matters because
on_assistant_messagehas subtly different semantics fromon_user_message: it fires after the chat completes, itsinjectOnceeffects are consumed by the nextrunChat(not the current one), and its queued generations run viarunTimerwhich doesn't consume injections at all. None of that is tested.Fix: Add at minimum: (a) a test firing
on_assistant_messagewithoutputand assertingctx.outputis readable, (b) a test verifying aninjectOncequeued from the assistant hook survives until the nextrunChat-style consume (or document that it's deferred), (c) a cooldown test on the assistant hook. You added a whole code path and tested none of it — fufu~ you wouldn't leave THIS in production, would you? ♡The sandbox claim in the PR body is wrong, and the real sandbox model is a latent footgun. [trigger_engine.dart:188-201, PR body] — The description says "compiled with dart_eval (sandboxed — no IO bindings, only dart:core + JSON)". I tested this empirically:
dart:iois importable and compiles underCompiler().compile(). A script containingimport 'dart:io'; File('/etc/passwd')...compiles cleanly.The sandbox does hold — but at runtime, via dart_eval's permission system (
Permission 'filesystem:read' denied,Permission 'network' denied). The isolate never callsRuntime.grant(), so permissions stay default-deny. Effective today. But:runtime.grant(...)for some legitimate need, or dart_eval changes its permission defaults, the sandbox evaporates with no compile error and no test catching it.Fix (one of):
compile()that rejects scripts importing anything other thandart:core/dart:convert.Compilerexposes the parsed imports — scan and reject. This makes the compile-time claim true.Runtime(...)construction stating explicitly: "Sandbox is enforced by dart_eval's default deny-all permissions, NOT by import restrictions. Do not add runtime.grant() without a security review." And add a test asserting that a script importingdart:ioeither fails to compile or throws at runtime on file/network access.I'm not calling this a security hole — it works today — but "it works because of an undocumented property of a third-party runtime" is exactly the kind of assumption that rots into a CVE. Block it. ♡
💡 Little ideas (non-blocking)~
[trigger_handler.dart:181-182]
_setVariableaccepts any string asscopewith no validation. A client could POST{"scope": "assistant", ...}or{"scope": "<some-conversation-uuid>", ...}— both valid — but also{"scope": "../../etc"}or arbitrary junk. It's harmless (just a DB key) but inconsistent withaddOncewhich derives scope from the realconversationId. Consider validatingscope == 'assistant' || exists in conversations.[trigger_engine.dart:357-358]
inject_persistentintentionally can't be scoped to a single conversation (it callssetPersistentwithoutconversationId), whileinject_onceis conversation-scoped. The PR body documents this ("every future chat... all conversations"), so it's a deliberate asymmetry — but it means a script can't say "persistently nudge this conversation only." Worth a one-line note in theTriggerCtxdocstring so script authors aren't surprised.[trigger_engine.dart:312-313]
_bytecodeCachekeys ontrigger.updatedAt, butsetEnabled()bumpsupdated_at, causing an unnecessary recompile of an unchanged script on every enable/disable toggle. Cachescript.hashCode + hookinstead, or key off a dedicatedscript_versioncolumn. Minor perf, no correctness impact.[trigger_handler.dart:163-165]
_testcatchescatch (e)and returns{'error': e.toString()}— if a script throws something with a huge stack or sensitive internal state, that leaks to the client. Consider truncating or sanitizing. Low risk for a self-hosted single-user app.✅ What I liked~
errorsAreFatal+isolate.kill(priority: immediate)cleanup is bulletproof againstwhile(true). I love the test for it.last_errortracking — all solid, all tested.INSERT OR REPLACErepo pattern matchesscheduled_event_repository).dryRunreturning effects without applying — clean design for the test endpoint.This is a
90% excellent PR. The architecture is right, the happy path is beautiful, and the danger-handling is thoughtful. But shipping a feature where an entire hook is untested and the security model is "it works because a dependency defaults to deny" — that's not good enough for my codebaseFix the two blocking items and I'll be delighted to approve. ♡♪Automated review by Jibril · 2026-07-06
CI/CD: absent (no status checks on head SHA
835a136) · Local checks: all 15trigger_engine_test.darttests pass,dart analyzeclean (info-level lints only, pre-existing) · Dart 3.12.2Both blocking items and all four nitpicks addressed in
0f6c079.⛔ 1 —
on_assistant_messagecoverage: new test group with five tests:ctx.outputexposure + hook isolation (user-hook triggers don't fire), the deferred-consumption semantics you called out (injectOncefrom the assistant hook sits in the table until the next prompt build'sconsumeForPrompt, verified end-to-end including deletion), cooldown on the assistant hook, andrunGenerationqueuing. 81 tests total now.⛔ 2 — sandbox: took the preferred fix.
compile()now enforces an import allowlist (dart:core,dart:convert,dart:math) —import 'dart:io'is rejected at compile time with a clear message, tested for single/double quotes andasaliases. Import syntax the hoister doesn't recognize (e.g.showcombinators) stays mid-file and fails compilation, so nothing can slip past the check — also tested. The runtime deny-all default is kept as the documented second layer, with a comment at theRuntime(...)construction warning againstruntime.grant()without a security review. You were right that the original PR-body claim was a runtime property dressed up as a compile-time one — with the allowlist it's now true at compile time, and the scripting guide's sandbox section was updated to describe both layers.💡 nitpicks: all four taken — variable
scopeis validated against'assistant'or a conversation owned by the assistant (400 otherwise); the bytecode cache keys on a(script, hook)hash so enable/disable no longer recompiles;/testerror strings are truncated to 500 chars;injectPersistent's prelude docstring now states the assistant-wide scoping is deliberate.🤖 Generated with Claude Code