instar 1.3.776 → 1.3.778
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/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +10 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +10 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/IntelligenceRouter.d.ts +17 -0
- package/dist/core/IntelligenceRouter.d.ts.map +1 -1
- package/dist/core/IntelligenceRouter.js +22 -1
- package/dist/core/IntelligenceRouter.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +11 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +111 -1
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/StopGateDb.d.ts +31 -0
- package/dist/core/StopGateDb.d.ts.map +1 -1
- package/dist/core/StopGateDb.js +103 -5
- package/dist/core/StopGateDb.js.map +1 -1
- package/dist/core/UnjustifiedStopGate.d.ts +30 -1
- package/dist/core/UnjustifiedStopGate.d.ts.map +1 -1
- package/dist/core/UnjustifiedStopGate.js +97 -3
- package/dist/core/UnjustifiedStopGate.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/stopGateTranscriptTail.d.ts +48 -0
- package/dist/core/stopGateTranscriptTail.d.ts.map +1 -0
- package/dist/core/stopGateTranscriptTail.js +118 -0
- package/dist/core/stopGateTranscriptTail.js.map +1 -0
- package/dist/core/types.d.ts +23 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/data/llmBenchCoverage.d.ts +28 -0
- package/dist/data/llmBenchCoverage.d.ts.map +1 -1
- package/dist/data/llmBenchCoverage.js +94 -0
- package/dist/data/llmBenchCoverage.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +65 -0
- package/dist/server/routes.js.map +1 -1
- package/dist/server/stopGate.d.ts +8 -0
- package/dist/server/stopGate.d.ts.map +1 -1
- package/dist/server/stopGate.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/data/llmBenchCoverage.ts +174 -0
- package/upgrades/1.3.777.md +28 -0
- package/upgrades/1.3.778.md +47 -0
- package/upgrades/side-effects/nature-axis-routing-fd5b-injection-exposure.md +62 -0
- package/upgrades/side-effects/self-deferral-guard-shadow.eli16.md +48 -0
|
@@ -685,6 +685,180 @@ export const LLM_ROUTING_NATURE: Readonly<Record<string, RoutingNature>> = {
|
|
|
685
685
|
'correction-learning': { nature: 'D', chain: 'SORT' },
|
|
686
686
|
};
|
|
687
687
|
|
|
688
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
689
|
+
// S4 FD5b — INJECTION-EXPOSURE static map (docs/specs/nature-axis-routing.md
|
|
690
|
+
// FD5(b) §283-294, semantic-drift row detail §370-384).
|
|
691
|
+
//
|
|
692
|
+
// The `injectionExposure` axis of the per-component routing classification — the
|
|
693
|
+
// PARALLEL exhaustive map the FD5 door-availability walk consults to decide
|
|
694
|
+
// whether a candidate position on a NON-injection-safe door (a door whose
|
|
695
|
+
// ChainPosition carries `injectionSafe: false`, e.g. groq-api/gpt-oss-120B) is
|
|
696
|
+
// eligible for THIS component. It is enforced STATICALLY (not a per-call caller
|
|
697
|
+
// flag) exactly like the nature map: one forgotten callsite must not be able to
|
|
698
|
+
// silently route an injection-exposed call onto a non-injection door.
|
|
699
|
+
//
|
|
700
|
+
// POLARITY — FAIL-SAFE (spec §286): the map DEFAULTS to `exposed: true`. A
|
|
701
|
+
// component is `exposed: false` ONLY when explicitly audited as carrying NO
|
|
702
|
+
// untrusted / injection-bearing content, and that argument is required in the row
|
|
703
|
+
// (`reason`, pinned shrink-only in the ratchet). `resolveInjectionExposure`
|
|
704
|
+
// treats a missing/unknown component as EXPOSED (fail-closed skip) — the safe
|
|
705
|
+
// direction. A per-call `attribution.injectionExposed: true` may only TIGHTEN
|
|
706
|
+
// (mark an otherwise-trusted call exposed), never relax a statically-exposed
|
|
707
|
+
// component (composed in IntelligenceRouter.isComponentInjectionExposed).
|
|
708
|
+
//
|
|
709
|
+
// THE CLASSIFICATION IS THE SAME PREDICATE AS `LLM_UNTRUSTED_INPUT` above — a
|
|
710
|
+
// component carries injection-bearing content iff it judges untrusted content —
|
|
711
|
+
// so this map's `exposed` is authored to MIRROR that reviewed axis, and the
|
|
712
|
+
// ratchet (tests/unit/nature-routing-injection-exposure-ratchet.test.ts)
|
|
713
|
+
// CROSS-CHECKS the two so they can never silently diverge (a callsite that
|
|
714
|
+
// becomes `untrustedInput: true` must also become `exposed: true`, or CI fails).
|
|
715
|
+
// This is the FD5b arm of the FD7 semantic-drift guard that is honoured TODAY;
|
|
716
|
+
// the prompt-anchor fingerprint LINT (spec §376-384) is the separate FD7
|
|
717
|
+
// semantic-drift increment.
|
|
718
|
+
//
|
|
719
|
+
// R8 (spec §308-310): the input-classifier-nature components (InputClassifier,
|
|
720
|
+
// MessageSentinel, TaskClassifier) are injection-exposed and MUST be
|
|
721
|
+
// `exposed: true` — the ratchet pins this so a future edit can't relax them.
|
|
722
|
+
//
|
|
723
|
+
// EACH ROW carries the spec's INPUT-SHAPE DECLARATION (§371: "can user / model /
|
|
724
|
+
// tool content enter this call?"). It is load-bearing here: the ratchet enforces
|
|
725
|
+
// `exposed ⟺ (userContent || modelContent || toolContent)` — an exposed:false row
|
|
726
|
+
// declares all three false (nothing untrusted can enter), an exposed:true row
|
|
727
|
+
// declares at least the channel(s) through which untrusted content arrives.
|
|
728
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
/** Can content of each provenance enter this LLM call (spec §371 input-shape declaration)?
|
|
731
|
+
* - userContent → the prompt embeds human/user-authored content (inbound messages, conversation);
|
|
732
|
+
* - modelContent → the prompt embeds model/agent-generated content (assistant turns, session output, summaries);
|
|
733
|
+
* - toolContent → the prompt embeds tool output / file / peer-agent / process content. */
|
|
734
|
+
export interface InjectionInputShape {
|
|
735
|
+
readonly userContent: boolean;
|
|
736
|
+
readonly modelContent: boolean;
|
|
737
|
+
readonly toolContent: boolean;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** One component's static injection-exposure classification (FD5b).
|
|
741
|
+
* `exposed` DEFAULTS true (fail-safe); `exposed:false` REQUIRES an argued `reason`
|
|
742
|
+
* and an all-false `inputShape` (audited: no untrusted content can enter). */
|
|
743
|
+
export interface InjectionExposure {
|
|
744
|
+
readonly exposed: boolean;
|
|
745
|
+
readonly inputShape: InjectionInputShape;
|
|
746
|
+
/** Required argument WHY the component carries no untrusted content (exposed:false only). */
|
|
747
|
+
readonly reason?: string;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const EXPOSED_USER: InjectionInputShape = { userContent: true, modelContent: false, toolContent: false };
|
|
751
|
+
const EXPOSED_MODEL: InjectionInputShape = { userContent: false, modelContent: true, toolContent: false };
|
|
752
|
+
const EXPOSED_TOOL: InjectionInputShape = { userContent: false, modelContent: false, toolContent: true };
|
|
753
|
+
const EXPOSED_USER_MODEL: InjectionInputShape = { userContent: true, modelContent: true, toolContent: false };
|
|
754
|
+
const EXPOSED_USER_TOOL: InjectionInputShape = { userContent: true, modelContent: false, toolContent: true };
|
|
755
|
+
const EXPOSED_MODEL_TOOL: InjectionInputShape = { userContent: false, modelContent: true, toolContent: true };
|
|
756
|
+
const EXPOSED_ALL: InjectionInputShape = { userContent: true, modelContent: true, toolContent: true };
|
|
757
|
+
const NOT_EXPOSED: InjectionInputShape = { userContent: false, modelContent: false, toolContent: false };
|
|
758
|
+
|
|
759
|
+
/** Sugar for the fail-safe default: an exposed row carrying its untrusted input-shape. */
|
|
760
|
+
const exposed = (inputShape: InjectionInputShape): InjectionExposure => ({ exposed: true, inputShape });
|
|
761
|
+
/** Sugar for the audited safe row: not exposed, all channels closed, with the argued reason. */
|
|
762
|
+
const notExposed = (reason: string): InjectionExposure => ({ exposed: false, inputShape: NOT_EXPOSED, reason });
|
|
763
|
+
|
|
764
|
+
export const LLM_ROUTING_INJECTION_EXPOSURE: Readonly<Record<string, InjectionExposure>> = {
|
|
765
|
+
// ── Sentinels ──
|
|
766
|
+
ExternalHogClassifier: exposed(EXPOSED_TOOL), // attacker-controllable process name + argv
|
|
767
|
+
InputGuard: exposed(EXPOSED_USER),
|
|
768
|
+
SessionActivitySentinel: exposed(EXPOSED_MODEL), // session tmux output
|
|
769
|
+
StallTriageNurse: exposed(EXPOSED_MODEL), // session output
|
|
770
|
+
CommitmentSentinel: exposed(EXPOSED_USER_MODEL), // both sides of a conversation
|
|
771
|
+
PresenceProxy: exposed(EXPOSED_MODEL), // session-stall over session output
|
|
772
|
+
MessageSentinel: exposed(EXPOSED_USER), // inbound user message (R8 input-classifier — must stay exposed)
|
|
773
|
+
ProjectDriftChecker: exposed(EXPOSED_MODEL_TOOL), // session work + files
|
|
774
|
+
TemporalCoherenceChecker: exposed(EXPOSED_USER_MODEL),
|
|
775
|
+
CompletionEvaluator: exposed(EXPOSED_MODEL_TOOL), // asserted work over session output/transcript
|
|
776
|
+
SessionWatchdog: exposed(EXPOSED_MODEL), // stuck-judge over session output
|
|
777
|
+
ResumeQueueDrainer: exposed(EXPOSED_MODEL_TOOL), // session state before a resume
|
|
778
|
+
TopicIntentArcCheck: exposed(EXPOSED_USER_MODEL),
|
|
779
|
+
SlackAdapter: exposed(EXPOSED_MODEL), // stall-confirm over session output
|
|
780
|
+
InputClassifier: exposed(EXPOSED_USER), // inbound user message (R8 input-classifier — must stay exposed)
|
|
781
|
+
SessionSummarySentinel: exposed(EXPOSED_MODEL), // summarizes tmux output
|
|
782
|
+
TelegramAdapter: exposed(EXPOSED_MODEL), // stall-confirm over session output
|
|
783
|
+
|
|
784
|
+
// ── Gates ──
|
|
785
|
+
PromptGate: exposed(EXPOSED_USER_TOOL), // injection detection over inbound content
|
|
786
|
+
ExternalOperationGate: exposed(EXPOSED_USER_TOOL), // in-content "user already approved" claims
|
|
787
|
+
WarrantsReplyGate: exposed(EXPOSED_USER),
|
|
788
|
+
UnjustifiedStopGate: exposed(EXPOSED_MODEL), // stop justification over session state
|
|
789
|
+
MessagingToneGate: exposed(EXPOSED_ALL), // reviews an outbound draft quoting user/tool content
|
|
790
|
+
MoveIntentClassifier: exposed(EXPOSED_USER), // inbound user message + conversation
|
|
791
|
+
HubIntentClassifier: exposed(EXPOSED_USER), // inbound hub message
|
|
792
|
+
ProfileIntentClassifier: exposed(EXPOSED_USER), // inbound user message + conversation
|
|
793
|
+
CoherenceReviewer: exposed(EXPOSED_ALL), // reviews outbound coherence (draft + context)
|
|
794
|
+
LLMSanitizer: exposed(EXPOSED_USER_TOOL), // definitionally judges untrusted inbound content
|
|
795
|
+
OverrideDetector: exposed(EXPOSED_USER),
|
|
796
|
+
TaskClassifier: exposed(EXPOSED_USER), // classifies a user task (R8 input-classifier — must stay exposed)
|
|
797
|
+
|
|
798
|
+
// ── Reflectors ──
|
|
799
|
+
JobReflector: exposed(EXPOSED_MODEL_TOOL),
|
|
800
|
+
crossModelReviewer: exposed(EXPOSED_TOOL), // reviews a spec document (file)
|
|
801
|
+
SelfKnowledgeTree: exposed(EXPOSED_MODEL_TOOL), // extracts over transcripts
|
|
802
|
+
TreeTriage: exposed(EXPOSED_TOOL),
|
|
803
|
+
TopicSummarizer: exposed(EXPOSED_USER_MODEL),
|
|
804
|
+
ContextualEvaluator: exposed(EXPOSED_USER_MODEL),
|
|
805
|
+
RelationshipManager: exposed(EXPOSED_USER_MODEL), // extracts relationship facts from conversation
|
|
806
|
+
StandardsConformanceReviewer: exposed(EXPOSED_TOOL), // artifact-vs-standard over files
|
|
807
|
+
DiscoveryEvaluator: exposed(EXPOSED_MODEL_TOOL), // subagent output
|
|
808
|
+
Usher: exposed(EXPOSED_USER), // routes an inbound turn
|
|
809
|
+
TopicIntentExtractor: exposed(EXPOSED_USER),
|
|
810
|
+
PreCompactionFlush: exposed(EXPOSED_MODEL_TOOL), // durable facts from a transcript
|
|
811
|
+
TreeSynthesis: exposed(EXPOSED_TOOL),
|
|
812
|
+
LLMConflictResolver: exposed(EXPOSED_TOOL), // divergent peer state = untrusted peer data
|
|
813
|
+
openConversationBrief: exposed(EXPOSED_TOOL), // A2A peer content
|
|
814
|
+
'a2a-checkin': exposed(EXPOSED_TOOL), // A2A peer-authored threads
|
|
815
|
+
'correction-learning': exposed(EXPOSED_USER_MODEL),
|
|
816
|
+
'mentor-stage-b': exposed(EXPOSED_MODEL_TOOL), // mentor signals over mentee output
|
|
817
|
+
ResumeValidator: exposed(EXPOSED_TOOL), // matches a resume UUID against topic/session state
|
|
818
|
+
|
|
819
|
+
// ── Jobs ──
|
|
820
|
+
PipeSessionSpawner: exposed(EXPOSED_USER_TOOL), // spawns from (possibly user-authored) task descriptions
|
|
821
|
+
CartographerSweep: exposed(EXPOSED_TOOL), // authors summaries over untrusted CODE
|
|
822
|
+
StandardsCoverageEnrichment: exposed(EXPOSED_TOOL),
|
|
823
|
+
|
|
824
|
+
// ── Argued NOT-EXPOSED (pinned shrink-only) — no live LLM callsite carrying untrusted content.
|
|
825
|
+
// This set mirrors LLM_UNTRUSTED_INPUT's argued-false set exactly (cross-checked by the ratchet). ──
|
|
826
|
+
PromiseBeacon: notExposed(
|
|
827
|
+
'no live LLM prompt — generateStatusLine/classifyProgress hooks are unwired at the construction site; no untrusted content can enter (matches its untrustedInput/bench-coverage exemption)',
|
|
828
|
+
),
|
|
829
|
+
InteractivePoolCanaryJudge: notExposed(
|
|
830
|
+
'judges a FIXED known-answer canary probe — the input is a constant, not external content; a planted instruction cannot reach it',
|
|
831
|
+
),
|
|
832
|
+
AutoApprover: notExposed(
|
|
833
|
+
'mechanical key injection + audit logging, no LLM prompt of its own; the upstream untrusted-judging callsite is InputClassifier.classify()',
|
|
834
|
+
),
|
|
835
|
+
IntegrationGate: notExposed(
|
|
836
|
+
'no LLM prompt of its own — delegates to JobReflector.reflect(); zero LLM-provider callsites of its own that see untrusted content',
|
|
837
|
+
),
|
|
838
|
+
CoherenceGate: notExposed(
|
|
839
|
+
'no callsite carries attribution CoherenceGate — all LLM calls flow through CoherenceReviewer.callApi(), classified exposed there',
|
|
840
|
+
),
|
|
841
|
+
InputDetector: notExposed(
|
|
842
|
+
'attribution-manifest alias only (a legacy prompt-pattern matcher); the live matcher calls with attribution PromptGate, classified exposed there',
|
|
843
|
+
),
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* FD5b — resolve a component's STATIC injection exposure (fail-safe). Strips a
|
|
848
|
+
* trailing "/segment" operation suffix + a leading "server:" prefix (mirroring
|
|
849
|
+
* componentCategories.categoryForComponent) then looks the base up in the
|
|
850
|
+
* exhaustive map. A missing/unknown component resolves EXPOSED (fail-closed): the
|
|
851
|
+
* safe direction — an unclassified call is treated as if it could carry an
|
|
852
|
+
* injection, so it is never routed onto a non-injection-safe door. Pure.
|
|
853
|
+
*/
|
|
854
|
+
export function resolveInjectionExposure(component: string | undefined): boolean {
|
|
855
|
+
if (!component) return true; // fail-closed: no attribution ⇒ assume exposed
|
|
856
|
+
const base = component.split('/')[0].replace(/^server:/, '').trim();
|
|
857
|
+
const row = LLM_ROUTING_INJECTION_EXPOSURE[base];
|
|
858
|
+
if (!row) return true; // unknown component ⇒ exposed (fail-safe skip)
|
|
859
|
+
return row.exposed;
|
|
860
|
+
}
|
|
861
|
+
|
|
688
862
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
689
863
|
* S4 Increment A2 — nature-axis routing: door taxonomy, label registry, chains.
|
|
690
864
|
*
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
Builds FD5b of the (still-dark) nature-axis router (spec: docs/specs/nature-axis-routing.md): the injection-exposure classification and the route gate that keeps an injection-exposed internal check off a non-injection-safe model door.
|
|
9
|
+
|
|
10
|
+
- **`LLM_ROUTING_INJECTION_EXPOSURE`** (src/data/llmBenchCoverage.ts): a new exhaustive static map — one row per known LLM component, each declaring `exposed` (defaults true / fail-safe) plus an input-shape declaration (can user / model / tool content enter). A component is `exposed:false` only when explicitly audited as carrying no untrusted content, with a written reason. `resolveInjectionExposure()` returns EXPOSED for any unknown component (fail-closed).
|
|
11
|
+
- **The route gate** (src/core/IntelligenceRouter.ts): resolveRoute now skips any door marked `injectionSafe:false` when the component is exposed. A tightening-only per-call `attribution.injectionExposed` flag can raise exposure but can never relax a statically-exposed component.
|
|
12
|
+
- A new ratchet test enforces the map is exhaustive over the component registry (both directions), fail-safe, cross-checked against the reviewed untrusted-input classification, and pins the input-classifier components as exposed. A new resolver-test block covers the gate and the fresh-per-call injection-cache isolation.
|
|
13
|
+
|
|
14
|
+
NO-OP in this increment: the only non-injection door in the shipped chains is also a metered door, which is already skipped until the later paid-door increment — so over the real chains the gate changes nothing. Dev-gated / dark: the whole nature block runs only when sessions.natureRouting is enabled; unset/off is byte-identical to before (asserted). This is the classification + gate only — NOT the metered-door Increment B, and NOT the go-live flip.
|
|
15
|
+
|
|
16
|
+
## What to Tell Your User
|
|
17
|
+
|
|
18
|
+
This is internal plumbing for how I choose which model runs my own background checks — nothing to turn on, and nothing about our conversations changes. In plain terms: I gave each of my internal checks a label saying whether it reads content that could contain hidden instructions from an outside source, and I added a rule so a check that does can never be sent to a cheaper model that a test showed is easier to trick. The routing feature is still off by default, so today this is invisible — it just means the unsafe path is sealed shut in advance, and it defaults to the cautious choice whenever it isn't sure.
|
|
19
|
+
|
|
20
|
+
## Summary of New Capabilities
|
|
21
|
+
|
|
22
|
+
- **Injection-exposure map**: a build-enforced, exhaustive table classifying each internal AI check by whether it handles untrusted content, defaulting to the cautious "exposed" when unsure.
|
|
23
|
+
- **Route injection gate**: the model router refuses to place an untrusted-content check onto a model door proven easier to fool, evaluated fresh on every call.
|
|
24
|
+
|
|
25
|
+
## Evidence
|
|
26
|
+
|
|
27
|
+
- tests/unit/nature-routing-injection-exposure-ratchet.test.ts (11 tests) and the new FD5b block in tests/unit/nature-routing-resolver.test.ts — green, covering exhaustiveness both directions, fail-safe resolve, the pinned not-exposed set, input-shape coherence, the untrusted-input cross-check, R8, the gate (exposed skips / trusted keeps / critical-gate fail-closed), and injection-cache isolation.
|
|
28
|
+
- `npx tsc --noEmit` clean; the FD4 nature-chains build-lint clean; the sibling untrusted-input / bench-coverage / routing-nature ratchets green; the pre-existing byte-identical-when-off block and A1's degrade-clamp assertion untouched and green.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
Added the observe-only ("shadow") phase of the Turn-End Self-Deferral Guard: a meaning-based judge on
|
|
9
|
+
the turn-end surface that records when a turn-ending message hands the operator a decision about work
|
|
10
|
+
the agent could do within its own means (the "you deferred again" pattern that slipped every existing
|
|
11
|
+
guard on 2026-07-04). It folds one new allow-class rule (`U_SELF_DEFERRAL`) plus four fields into the
|
|
12
|
+
SINGLE existing `UnjustifiedStopGate` turn-end call — no new LLM call, no new hook, no new route. The
|
|
13
|
+
judge receives the recent user turns (a bounded, fail-open transcript tail-read) so it can distinguish a
|
|
14
|
+
genuine operator decision from a self-deferral. Verdicts are recorded to the existing `StopGateDb`
|
|
15
|
+
(new columns + a real `ALTER TABLE` migration and age-based retention, both of which did not previously
|
|
16
|
+
exist). It BLOCKS NOTHING — it only records, to produce the telemetry needed to later decide whether the
|
|
17
|
+
blocking phase (deferred) is worth building. Dev-agent-gated (on for a development agent, dark on the
|
|
18
|
+
fleet).
|
|
19
|
+
|
|
20
|
+
## What to Tell Your User
|
|
21
|
+
|
|
22
|
+
Nothing proactive — this is an internal, observe-only telemetry feature that changes no behavior. If a
|
|
23
|
+
user asks whether the agent has a check for handing work back to them that it could do itself, the
|
|
24
|
+
answer is: yes, a quiet watch-only version now records those moments so we can measure how often it
|
|
25
|
+
happens and how accurately it is detected, before ever deciding whether the agent should actively step
|
|
26
|
+
in. It never blocks or delays a message.
|
|
27
|
+
|
|
28
|
+
## Summary of New Capabilities
|
|
29
|
+
|
|
30
|
+
- A turn-end judge that classifies self-deferral (agent hands the operator agent-ownable work) and
|
|
31
|
+
records it, with recent-user-turn context, on every finished turn.
|
|
32
|
+
- Recording rides the existing stop-gate call (zero new LLM calls); it blocks nothing and changes no
|
|
33
|
+
message.
|
|
34
|
+
- New `StopGateDb` columns with a built idempotent column migration (existing agents gain the columns on
|
|
35
|
+
update) and real age-based retention.
|
|
36
|
+
- Dev-agent gated (`monitoring.selfDeferralGuard`); off-state is byte-for-byte the prior behavior.
|
|
37
|
+
- The enforce/blocking phase is deliberately DEFERRED behind separately-gated preconditions (spec §10).
|
|
38
|
+
|
|
39
|
+
## Evidence
|
|
40
|
+
|
|
41
|
+
- Spec: `docs/specs/turn-end-self-deferral-guard.md` (v4, Phase A CONVERGED — 4 review rounds; operator
|
|
42
|
+
approved the observe-only build, topic 29836, 2026-07-05).
|
|
43
|
+
- 55 tests across 5 new suites + updated rule-count assertions: `tests/unit/self-deferral-guard.test.ts`
|
|
44
|
+
(14), `tests/unit/stopGateDb-self-deferral-migration.test.ts` (6),
|
|
45
|
+
`tests/unit/stopGateTranscriptTail.test.ts` (13), `tests/integration/self-deferral-guard-route.test.ts`
|
|
46
|
+
(2), `tests/e2e/self-deferral-guard-feature-alive.test.ts` (2), `tests/unit/UnjustifiedStopGate.test.ts`
|
|
47
|
+
(regression). `tsc --noEmit` clean.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Side-Effects Review — FD5b: injection-exposure static map + route-resolution injection gate
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `nature-axis-routing-fd5b-injection-exposure`
|
|
4
|
+
**Date:** `2026-07-05`
|
|
5
|
+
**Author:** `echo (build hand, topic 29723 routing follow-through)`
|
|
6
|
+
**Second-pass reviewer:** `not required (Tier 1 — deterministic static classification + a pure candidate-eligibility filter, dark/dev-gated, byte-identical no-op in Increment A; same surface + precedent as A2.1 FD4-lints #1388)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Builds FD5b of `docs/specs/nature-axis-routing.md` (§283-294): the exhaustive, fail-safe **injection-exposure** classification the nature router consults, plus the resolve-time gate that keeps an injection-exposed component off a non-injection-safe door. Files touched:
|
|
11
|
+
|
|
12
|
+
- `src/data/llmBenchCoverage.ts` — new `LLM_ROUTING_INJECTION_EXPOSURE` map (exhaustive over `COMPONENT_CATEGORY`, `exposed` defaults true / fail-safe, each row carries the spec §371 input-shape declaration), `InjectionExposure`/`InjectionInputShape` types, and the pure `resolveInjectionExposure(component)` fail-closed resolver.
|
|
13
|
+
- `src/core/IntelligenceRouter.ts` — a new `isInjectionExposed` dep threaded into `resolveRoute`'s availability walk (skips a `injectionSafe:false` position when the component is exposed, reason `injectionUnsafe`), and the exported `isComponentInjectionExposed(component, perCallExposed?)` composer (static OR tighten-only per-call flag), wired at the class caller.
|
|
14
|
+
- `src/core/types.ts` — an OPT-IN, tightening-only `attribution.injectionExposed?: boolean`.
|
|
15
|
+
- Tests — NEW `tests/unit/nature-routing-injection-exposure-ratchet.test.ts` (exhaustiveness both directions, fail-safe resolve, pinned NOT-EXPOSED set + reasons, input-shape coherence, cross-check vs `LLM_UNTRUSTED_INPUT`, R8 pin) and a NEW FD5b block appended to `tests/unit/nature-routing-resolver.test.ts` (the gate + injection-cache isolation). The pre-existing byte-identical block (`nature-routing-resolver.test.ts:261-296`) and A1's clamp assertion (`:161-162`) are UNTOUCHED and green.
|
|
16
|
+
|
|
17
|
+
## Decision-point inventory
|
|
18
|
+
|
|
19
|
+
- `FD5b injection gate (resolveRoute availability walk)` — add — a PURE candidate-eligibility filter: an injection-exposed component skips a `injectionSafe:false` door. NO-OP in Increment A (the only such door, `groq-api`, is also metered → already skipped one line above).
|
|
20
|
+
- `LLM_ROUTING_INJECTION_EXPOSURE static classification` — add — build-time DATA (a signal), ratchet-enforced exhaustive + fail-safe; the router consults it, it holds no runtime authority of its own.
|
|
21
|
+
- `attribution.injectionExposed per-call flag` — add — tightening-only; can raise exposure, can NEVER relax a statically-exposed component.
|
|
22
|
+
- `sessions.natureRouting enable/dryRun gate` — pass-through — the whole nature block (and therefore the injection gate) still runs ONLY when enabled.
|
|
23
|
+
|
|
24
|
+
## 1. Over-block
|
|
25
|
+
|
|
26
|
+
**What legitimate inputs does this reject that it shouldn't?** In Increment A: none observable. The gate only removes a `injectionSafe:false` position for an exposed component, and the sole such position in the shipped chains (`groq-api/gpt-oss-120B`) is already unconditionally skipped as a metered door — so the resolved route is byte-identical (asserted: "the real default chains are UNAFFECTED"). The fail-safe default (unknown → exposed) could in principle skip a non-injection door for an unclassified component, but the exhaustiveness ratchet guarantees every real component is classified, so "unknown" never occurs for a shipped callsite; the fail-safe only governs a genuinely unregistered label, where skipping the non-injection door is the intended safe direction.
|
|
27
|
+
|
|
28
|
+
## 2. Under-block
|
|
29
|
+
|
|
30
|
+
**What failure modes does this still miss?** The classification is static build-time judgment — a callsite whose prompt SILENTLY starts carrying untrusted content while its row stays `exposed:false` is a semantic-drift miss. FD5b mitigates this TODAY via the ratchet cross-check against `LLM_UNTRUSTED_INPUT` (a component that becomes `untrustedInput:true` must also become `exposed:true`, or CI fails) and the input-shape coherence invariant. The full FD7 semantic-drift guard — the prompt-anchor fingerprint LINT (spec §376-384) that re-touches a row when its prompt source changes — is a SEPARATE increment the spec itself defers ("deferred to its own A/B-gated increments"); it is NOT built here. The FD4.2 R-rule lints (R4/R5/R7 injection-exposed-JUDGE bans, R8 Flash-Lite pin as a LINT) are the next increment and are not built here either.
|
|
31
|
+
|
|
32
|
+
## 3. Level-of-abstraction fit
|
|
33
|
+
|
|
34
|
+
Correct altitude. The classification lives beside its sibling axes (`LLM_UNTRUSTED_INPUT`, `LLM_ROUTING_NATURE`) in the same data module and rides the same exhaustive-ratchet pattern. The gate is a pure predicate consulted by the existing `resolveRoute` fold (the spec's own framing: "the safety/injection/allowlist/R-rule checks are candidate-eligibility FILTERS … each a pure predicate"), injected as a dep so exposure is evaluated fresh per call — not a new engine, not a new config surface. A smarter gate does not already own this; injection-exposure is intrinsic to routing and has no other home.
|
|
35
|
+
|
|
36
|
+
## 4. Signal vs authority compliance
|
|
37
|
+
|
|
38
|
+
Compliant. The injection-exposure map is deterministic build-time DATA (a signal, like the nature map), enforced by an exhaustive ratchet — it is not a brittle runtime check with blocking authority. The gate DOES exercise authority (it removes a candidate door), but only ever in the SAFE direction (toward a more-careful door / fail-closed for a critical gate), on a MODEL-ROUTING decision — it never blocks a user message, never reads or credits a principal identity, never grants anything. The per-call flag can only tighten, never relax, so a forgotten callsite cannot open a non-injection door. `docs/signal-vs-authority.md` satisfied.
|
|
39
|
+
|
|
40
|
+
## 5. Interactions
|
|
41
|
+
|
|
42
|
+
Interacts with `resolveRoute` (one skip predicate in the availability loop, placed AFTER the metered skip so the metered `groq-api` is already excluded → guaranteed no-op) and the class `evaluate()` caller (composes the per-call tighten). It does NOT shadow the FD4 harness-door clamp (orthogonal: FD4 governs the claude-code door, FD5b governs `injectionSafe:false` doors) and does not double-fire. Existing resolver tests (all exposed:true real components on default chains) are unaffected because no non-metered default door is a non-injection door. The A1 `clampClaudeCliSwapModel` degrade clamp is untouched (its assertion stays green).
|
|
43
|
+
|
|
44
|
+
## 6. External surfaces
|
|
45
|
+
|
|
46
|
+
No external surface. No new HTTP route, CLI command, MCP tool, Telegram/Slack path, or network egress. `attribution.injectionExposed` is an additive optional field — every existing caller is byte-identical (omitted ⇒ static map authoritative).
|
|
47
|
+
|
|
48
|
+
## 6b. Operator-surface quality
|
|
49
|
+
|
|
50
|
+
No operator-facing surface added or changed. The `injectionUnsafe` skip reason is an internal code comment/marker for the future FD5 structured-reason-code surface; nothing is emitted to a user yet. No secrets, tokens, or file paths surface anywhere.
|
|
51
|
+
|
|
52
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
53
|
+
|
|
54
|
+
Machine-local by design, and correctly so. The injection-exposure map is static source data compiled into every machine identically; `resolveInjectionExposure` is a pure per-call function over that data with no persisted or replicated state. Each machine resolves the identical verdict for the same component independently — there is no cross-machine state to strand, replicate, or coalesce. A per-call `attribution.injectionExposed` is scoped to the single call on the machine serving it. No lease, ledger, or notice interaction.
|
|
55
|
+
|
|
56
|
+
## 8. Rollback cost
|
|
57
|
+
|
|
58
|
+
Low and clean. Revert the PR: the map + resolver + gate predicate + the optional type field drop out; `resolveRoute` returns to the pre-FD5b availability walk (which, in Increment A, produced identical routes). No migration, no persisted state, no config schema change, no data-format change. Because the gate only runs when `natureRouting.enabled` AND is a no-op over the shipped chains, a rollback is invisible to every fleet agent (feature dark) and to any dev agent in dryRun.
|
|
59
|
+
|
|
60
|
+
## Conclusion
|
|
61
|
+
|
|
62
|
+
FD5b ships the fail-safe injection-exposure classification and the resolve-time gate that structurally seals an injection-exposed call off a non-injection door — a no-op in Increment A (the one non-injection door is also metered), byte-identical when the feature is off, and dark/dev-gated. The classification is exhaustive and cross-checked against the reviewed `LLM_UNTRUSTED_INPUT` axis so it cannot silently diverge; the prompt-anchor fingerprint LINT (FD7 semantic-drift) and the FD4.2 R-rule lints are the tracked next increments, not built here.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ELI16 — a quiet watcher that notices when I try to hand you back work I could do myself
|
|
2
|
+
|
|
3
|
+
## The problem this fixes
|
|
4
|
+
|
|
5
|
+
There's a specific way I let you down that nothing was catching. At the end of a turn I'd sometimes
|
|
6
|
+
write something like "I've stopped here — want me to line that up, or would you rather steer me
|
|
7
|
+
elsewhere?" — handing YOU a decision about work that I could actually just do myself. It sounds polite
|
|
8
|
+
and reasonable, so none of the existing guards flagged it: it wasn't a routed message, it didn't match
|
|
9
|
+
any banned phrase, and it ended with a real-sounding question that the meaning-based rules are told to
|
|
10
|
+
let through. The one check smart enough to catch it (a meaning-based judge) wasn't watching the
|
|
11
|
+
turn-end surface at all, and outside an autonomous run there was no always-on "could you do this
|
|
12
|
+
yourself?" check anywhere.
|
|
13
|
+
|
|
14
|
+
## What this change adds (and, just as important, what it does NOT do)
|
|
15
|
+
|
|
16
|
+
It puts a meaning-based judge on the turn-end surface that, on every finished turn, quietly asks: "did
|
|
17
|
+
this message hand the operator a decision about work the agent could do within its own means?" It reads
|
|
18
|
+
a little recent conversation (your last few messages) so it can tell a genuine "your call" (taste,
|
|
19
|
+
priority, a credential only you have) apart from me punting work I should just do. It writes down what
|
|
20
|
+
it saw.
|
|
21
|
+
|
|
22
|
+
Crucially, in this phase it **blocks nothing**. It never stops a message, never delays a turn, never
|
|
23
|
+
changes what I say. It only records — so we can measure how often it fires and how accurate it is
|
|
24
|
+
before ever deciding whether to let it actively intervene. That "actively stop me" version is a
|
|
25
|
+
separate, deliberately-deferred piece of work with its own hard design questions (chiefly: how to make
|
|
26
|
+
it impossible for me to quietly switch off or game, and how to guarantee it never wrongly blocks you).
|
|
27
|
+
|
|
28
|
+
## Why it's safe
|
|
29
|
+
|
|
30
|
+
- **It cannot wedge a turn.** It rides the single check that already runs at every turn-end (no new
|
|
31
|
+
model call, no second judge), it only adds fields on the "allow" path, and it never touches the
|
|
32
|
+
blocking logic. Every message still ends exactly as it would have.
|
|
33
|
+
- **The context read can't hang me.** Reading your recent turns is a bounded, fail-open tail-read: it
|
|
34
|
+
scans at most a small slice from the end of the transcript, caps each turn's length, and on anything
|
|
35
|
+
weird (missing file, giant line, bad data) it simply proceeds with no context instead of erroring.
|
|
36
|
+
- **It's off on the fleet by default.** It only turns on for a development agent (me), so it soaks on
|
|
37
|
+
one agent first. When off, nothing is recorded and the base behavior is byte-for-byte unchanged —
|
|
38
|
+
I even proved the shared judge prompt is untouched when the feature is off.
|
|
39
|
+
- **It's reversible.** No blocking, and the only new state is extra columns in an existing local
|
|
40
|
+
telemetry database (with a real prune so it can't grow forever) — turning the flag off stops it cold.
|
|
41
|
+
|
|
42
|
+
## How we know it works
|
|
43
|
+
|
|
44
|
+
Fifty-five tests across five new suites: the classifier catches a real self-deferral and correctly lets
|
|
45
|
+
a genuine "your call" through; the tricky case (a design-question shape that's actually me punting
|
|
46
|
+
doable work) resolves the right way; the existing rules' verdicts are proven unchanged by the prompt
|
|
47
|
+
edit; the database migration adds its columns idempotently on an old database and prunes old rows; and
|
|
48
|
+
the transcript reader stays bounded and never throws on bad input.
|