instar 1.3.474 → 1.3.475

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.
@@ -728,60 +728,7 @@ export function createRoutes(ctx) {
728
728
  // reply isn't re-sent across a restart (finding_cross_restart_duplicate_replies,
729
729
  // topic 21816). Fail-open: a backing hiccup degrades to in-memory-only.
730
730
  new SqliteOutboundDedupStore(SqliteOutboundDedupStore.defaultPath(ctx.config.stateDir)));
731
- // ── Messaging tone gate ──────────────────────────────────────────
732
- //
733
- // Invoked before forwarding agent-authored messages to a user. Runs the
734
- // ConversationalToneReviewer (single Haiku-class call, ~500ms) to catch CLI
735
- // commands, file paths, config keys, and other technical leakage that the
736
- // agent's internal memory discipline missed.
737
- //
738
- // Returns true if the message was blocked (response already sent as 422).
739
- // Returns false if safe to proceed (pass, fail-open, or gate unavailable).
740
- /**
741
- * Outbound-message gate — SINGLE authority for agent→user message delivery.
742
- *
743
- * Combines structured signals from upstream detectors (junk-payload matcher,
744
- * outbound dedup gate) with conversational context, and makes the block/allow
745
- * decision in one LLM call via MessagingToneGate.
746
- *
747
- * This is the reshaping called for by docs/signal-vs-authority.md: detectors
748
- * emit signals, the authority decides. No detector holds independent block
749
- * power — "test" is junk sometimes and legitimate other times; a near-
750
- * duplicate is respawn-race sometimes and a legitimate re-ask other times.
751
- * Only the authority has the conversational context to tell them apart.
752
- *
753
- * Bypass flags preserved:
754
- * metadata.isProxy = true → skip all gating (system-generated msgs)
755
- * metadata.allowDebugText = true → junk signal suppressed
756
- * metadata.allowDuplicate = true → duplicate signal suppressed
757
- *
758
- * Returns true if blocked (response already sent as 422). False if safe.
759
- */
760
- async function checkOutboundMessage(text, channel, res, options) {
761
- // ── Self-Violation Signal (OBSERVE-ONLY) ──────────────────────────
762
- // Record — but NEVER act on — the case where this finalized outbound
763
- // message contradicts a stored preference. This runs as a fire-and-forget
764
- // VOID call that is structurally independent of the tone-gate verdict and
765
- // of this function's return value: it cannot block, delay, or rewrite the
766
- // message. It runs FIRST (before the gate-availability early-return below)
767
- // so the observation happens regardless of whether the tone gate exists or
768
- // what it decides. Dark by default (gated inside on enabled+selfViolationSignal).
769
- void observeSelfViolation(text, options.topicId).catch(() => {
770
- /* @silent-fallback-ok — observe-only; a detector error never affects delivery */
771
- });
772
- // ── Principal-Coherence Signal (OBSERVE-ONLY) ─────────────────────
773
- // Record — but NEVER act on — the case where this finalized outbound
774
- // message credits an operator-ROLE decision (approval / mandate / credential
775
- // / lock / acting-for) to a principal who is NOT the topic's VERIFIED
776
- // operator. That is the "Caroline" identity-bleed failure, caught in the
777
- // agent's OWN output where no inbound gate watches. Same fire-and-forget
778
- // VOID contract as observeSelfViolation: structurally independent of the
779
- // tone-gate verdict and of this function's return value — it cannot block,
780
- // delay, or rewrite the message. Dark by default (gated inside on
781
- // monitoring.principalCoherence.enabled + a wired TopicOperatorStore).
782
- void observePrincipalCoherence(text, options.topicId).catch(() => {
783
- /* @silent-fallback-ok — observe-only; a detector error never affects delivery */
784
- });
731
+ async function evaluateOutbound(text, channel, options) {
785
732
  // ── Localhost-link guard (operator-mandated HARD rule, 2026-06-05) ──
786
733
  // A clickable localhost/loopback link must never reach a user — the user
787
734
  // is almost never on the machine this server runs on. This is a
@@ -793,18 +740,22 @@ export function createRoutes(ctx) {
793
740
  if (!options.allowLocalhostLink) {
794
741
  const localLink = detectLocalhostLink(text);
795
742
  if (localLink.detected) {
796
- res.status(422).json({
797
- error: `Message blocked: contains a machine-local link (${localLink.match}) that the user cannot open from their device. ` +
798
- `Replace it with the public tunnel URL (GET /tunnel → url + path), or omit the link and say it will follow. ` +
799
- `If the operator explicitly asked for the raw local URL, resend with metadata.allowLocalhostLink: true.`,
800
- blockedBy: 'localhost-link-guard',
801
- match: localLink.match,
802
- });
803
- return true;
743
+ return {
744
+ ok: false,
745
+ status: 422,
746
+ reason: 'localhost-link-guard',
747
+ body: {
748
+ error: `Message blocked: contains a machine-local link (${localLink.match}) that the user cannot open from their device. ` +
749
+ `Replace it with the public tunnel URL (GET /tunnel → url + path), or omit the link and say it will follow. ` +
750
+ `If the operator explicitly asked for the raw local URL, resend with metadata.allowLocalhostLink: true.`,
751
+ blockedBy: 'localhost-link-guard',
752
+ match: localLink.match,
753
+ },
754
+ };
804
755
  }
805
756
  }
806
757
  if (!ctx.messagingToneGate)
807
- return false; // No authority configured — pass through
758
+ return { ok: true }; // No authority configured — pass through
808
759
  try {
809
760
  // ── Collect signals from upstream detectors ──
810
761
  const signals = {};
@@ -916,21 +867,94 @@ export function createRoutes(ctx) {
916
867
  result,
917
868
  });
918
869
  if (!result.pass) {
919
- res.status(422).json({
920
- error: 'tone-gate-blocked',
921
- rule: result.rule,
922
- issue: result.issue,
923
- suggestion: result.suggestion,
924
- latencyMs: result.latencyMs,
925
- });
926
- return true;
870
+ return {
871
+ ok: false,
872
+ status: 422,
873
+ reason: 'tone-gate-blocked',
874
+ body: {
875
+ error: 'tone-gate-blocked',
876
+ rule: result.rule,
877
+ issue: result.issue,
878
+ suggestion: result.suggestion,
879
+ latencyMs: result.latencyMs,
880
+ },
881
+ };
927
882
  }
928
883
  }
929
884
  catch {
930
885
  // Fail-open — any error short of a clean block lets the message through.
931
886
  }
887
+ return { ok: true };
888
+ }
889
+ /**
890
+ * Route adapter — fires the observe-only observers (route-side) then delegates
891
+ * the block/allow decision to the shared res-free `evaluateOutbound`. Returns
892
+ * true if blocked (the mapped status/body already written). Behavior is
893
+ * byte-identical to the pre-extraction function for every existing caller.
894
+ */
895
+ async function checkOutboundMessage(text, channel, res, options) {
896
+ // ── Self-Violation Signal (OBSERVE-ONLY) ──────────────────────────
897
+ // Record — but NEVER act on — the case where this finalized outbound message
898
+ // contradicts a stored preference. Fire-and-forget VOID, structurally
899
+ // independent of the decision and of this function's return value: it cannot
900
+ // block, delay, or rewrite the message. Stays route-side (NOT in
901
+ // evaluateOutbound) — there is nothing for it to catch on the proactive-digest
902
+ // path, and keeping it here preserves byte-identical behavior for callers.
903
+ void observeSelfViolation(text, options.topicId).catch(() => {
904
+ /* @silent-fallback-ok — observe-only; a detector error never affects delivery */
905
+ });
906
+ // ── Principal-Coherence Signal (OBSERVE-ONLY) ─────────────────────
907
+ // Same fire-and-forget VOID contract for the operator-role / principal
908
+ // identity-bleed ("Caroline") case. Route-side for the same reason.
909
+ void observePrincipalCoherence(text, options.topicId).catch(() => {
910
+ /* @silent-fallback-ok — observe-only; a detector error never affects delivery */
911
+ });
912
+ const decision = await evaluateOutbound(text, channel, options);
913
+ if (!decision.ok) {
914
+ res.status(decision.status).json(decision.body);
915
+ return true;
916
+ }
932
917
  return false;
933
918
  }
919
+ /**
920
+ * Shared guarded delivery to the Agent Updates topic — the proactive growth
921
+ * digest publisher's `send`. Resolves the Updates topic server-side (never a
922
+ * fallback topic, mirroring the /telegram/post-update 400), runs the SAME
923
+ * `evaluateOutbound` chokepoint as the route, then sends. Returns a flat
924
+ * DeliveryResult and never throws — a guard block is a normal, non-error
925
+ * outcome that the publisher records and never re-acts on.
926
+ */
927
+ async function postToUpdatesTopic(text, opts) {
928
+ if (!ctx.telegram)
929
+ return { ok: false, reason: 'telegram-not-configured' };
930
+ const updatesTopicId = ctx.state.get('agent-updates-topic');
931
+ if (!updatesTopicId || typeof updatesTopicId !== 'number') {
932
+ return { ok: false, reason: 'no-updates-topic' };
933
+ }
934
+ if (typeof text !== 'string' || text.length === 0)
935
+ return { ok: false, reason: 'empty-text' };
936
+ if (text.length > 4096)
937
+ return { ok: false, reason: 'too-long' };
938
+ const decision = await evaluateOutbound(text, 'telegram', {
939
+ topicId: updatesTopicId,
940
+ allowDebugText: opts?.allowDebugText === true,
941
+ allowDuplicate: opts?.allowDuplicate === true,
942
+ });
943
+ if (!decision.ok)
944
+ return { ok: false, reason: decision.reason };
945
+ try {
946
+ await ctx.telegram.sendToTopic(updatesTopicId, text);
947
+ return { ok: true };
948
+ }
949
+ catch (err) {
950
+ return { ok: false, reason: err instanceof Error ? err.message : 'send-error' };
951
+ }
952
+ }
953
+ // Attach the shared guarded sender to the growth-digest publisher (if wired).
954
+ // Runs once at route registration — the publisher was constructed + started in
955
+ // AgentServer; this is the §3.3 single-funnel hookup, so the publisher can never
956
+ // reach sendToTopic without passing through the identical evaluateOutbound.
957
+ ctx.growthDigestPublisher?.attachSender((text) => postToUpdatesTopic(text));
934
958
  /**
935
959
  * Self-Violation Signal — OBSERVE-ONLY (Correction & Preference Learning
936
960
  * Sentinel extension).