@sema-agent/client-core 0.79.1 → 0.80.0

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.
@@ -1,5 +1,6 @@
1
1
  import { type GateCurrentPending, type HitlFailureStage, type HitlSafetyCode, type HitlClientLike } from './hitlBridge.js';
2
2
  import { type RuleStoreUnreadableKind } from '../gateVocabulary.js';
3
+ import { type DenyAttributionRefusalDetail } from '../wireRefusalCopy.js';
3
4
  import { type DecideReceiptView } from '../decideReceipt.js';
4
5
  import { RULE_OFFER_MATCHES, RULE_OFFER_BATCH_MEMBER_KINDS, RULE_OFFER_UNCOVERED_REASONS, RULE_OFFERS_ABSENCE_REASONS, DENIAL_LIMIT_KINDS } from '@sema-agent/sdk';
5
6
  import type { RuleSuggestion, ToolApprovalRespondAck, PersistedRuleAnchor as SdkPersistedRuleAnchor, RuleOfferMatch as SdkRuleOfferMatch, RuleOfferBatchMember as SdkRuleOfferBatchMember, RuleOfferUncoveredDetail as SdkRuleOfferUncoveredDetail, RuleOfferUncoveredReason as SdkRuleOfferUncoveredReason, AskOrigin as SdkAskOrigin, RuleOffersAbsence as SdkRuleOffersAbsence, DenialLimitKind as SdkDenialLimitKind, DenialLimitFallback as SdkDenialLimitFallback } from '@sema-agent/sdk';
@@ -19,6 +20,7 @@ export type FsApprovalOutcome = {
19
20
  denied?: boolean | undefined;
20
21
  denySettledBy?: ApprovalDenySettledBy | undefined;
21
22
  denyReason?: string | undefined;
23
+ denyAttributionDropped?: true;
22
24
  receipt?: DecideReceiptView | undefined;
23
25
  } | {
24
26
  kind: 'aborted';
@@ -36,6 +38,7 @@ export type FsApprovalOutcome = {
36
38
  safetyCode?: HitlSafetyCode | undefined;
37
39
  retryExhausted?: true;
38
40
  currentPending?: GateCurrentPending | undefined;
41
+ denyAttributionRefusal?: DenyAttributionRefusalDetail | undefined;
39
42
  };
40
43
  export interface FsApprovalWireDeps {
41
44
  client: HitlClientLike;
@@ -202,6 +205,7 @@ export interface RespondToolApprovalOpts {
202
205
  persistRuleEdited?: true;
203
206
  persistRuleBatchOfferIndex?: number;
204
207
  note?: string;
208
+ settledBy?: 'policy';
205
209
  }
206
210
  export type RespondToolApprovalFn = (approvalId: string, decision: ToolApprovalRespondDecision, opts?: RespondToolApprovalOpts) => Promise<ToolApprovalRespondAck | void>;
207
211
  export type ToolApprovalRespondAckView = ToolApprovalRespondAck;
@@ -215,6 +219,8 @@ export interface ToolApprovalFrameOutcome {
215
219
  retracted?: true;
216
220
  denySettledBy?: ApprovalDenySettledBy;
217
221
  denyReason?: string;
222
+ denyAttributionRefusal?: DenyAttributionRefusalDetail;
223
+ denyAttributionDropped?: true;
218
224
  }
219
225
  interface ToolApprovalRespondRefusalFields {
220
226
  status?: number;
@@ -6,6 +6,7 @@ import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
6
6
  import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded, surfaceEditRefusedOnBlindAsk, surfaceRuleArmNotSent, surfaceRuleArmRejected } from './hitlHostSurface.js';
7
7
  import { approvalCallKey, liveFrameCallKey } from './gateIdentity.js';
8
8
  import { isRuleStoreUnreadableKind } from '../gateVocabulary.js';
9
+ import { denyAttributionRefusalFromError } from '../wireRefusalCopy.js';
9
10
  import { readDecideReceipt } from '../decideReceipt.js';
10
11
  import { RULE_OFFER_MATCHES, RULE_OFFER_BATCH_MEMBER_KINDS, RULE_OFFER_UNCOVERED_REASONS, RULE_OFFERS_ABSENCE_REASONS, DENIAL_LIMIT_KINDS, } from '@sema-agent/sdk';
11
12
  export function isFsApprovalGate(gate) {
@@ -205,18 +206,44 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
205
206
  ...(safetyCode !== undefined ? { safetyCode } : {}),
206
207
  };
207
208
  }
208
- case 'deny':
209
+ case 'deny': {
210
+ const denySettledByForWire = readApprovalDenySettledBy(card);
211
+ const denyReasonSnapshot = typeof card.reason === 'string' ? card.reason : undefined;
212
+ let lastSendCarriedAttribution = false;
209
213
  try {
210
- const raw = await bridge.decideTool({ decision: 'deny', reason: denyReasonForWire(card.reason, `task ${taskId}`) ?? DEFAULT_DENY_REASON }, gatedCallId, signal ? { signal } : undefined, pending);
214
+ const denyBody = {
215
+ decision: 'deny',
216
+ reason: denyReasonForWire(denyReasonSnapshot, `task ${taskId}`) ?? DEFAULT_DENY_REASON,
217
+ ...(denySettledByForWire === 'policy' ? { settledBy: 'policy' } : {}),
218
+ };
219
+ let attributionDropped = false;
220
+ let raw;
221
+ lastSendCarriedAttribution = denySettledByForWire === 'policy';
222
+ try {
223
+ raw = await bridge.decideTool(denyBody, gatedCallId, signal ? { signal } : undefined, pending);
224
+ }
225
+ catch (first) {
226
+ if (!shouldResendWithoutAttribution(first, lastSendCarriedAttribution, signal))
227
+ throw first;
228
+ const { settledBy: _droppedKey, ...withoutAttribution } = denyBody;
229
+ void _droppedKey;
230
+ lastSendCarriedAttribution = false;
231
+ hostLog('debug', `liveToolApprovalWire: decide(deny) for task ${taskId} was refused because the settlement note is not part of ` +
232
+ 'what this deployment signs — re-sending the SAME decision exactly once with that one key removed ' +
233
+ '(the refusal happened before the approval was judged, so nothing was consumed and this is a re-delivery, not a second act)');
234
+ raw = await bridge.decideTool(withoutAttribution, gatedCallId, signal ? { signal } : undefined, pending);
235
+ attributionDropped = true;
236
+ }
211
237
  const receipt = readDecideReceipt(raw);
212
- const denySettledBy = readApprovalDenySettledBy(card);
213
- const denyReason = typeof card.reason === 'string' && card.reason.trim() !== '' ? card.reason : undefined;
238
+ const denySettledBy = denySettledByForWire;
239
+ const denyReason = denyReasonSnapshot !== undefined && denyReasonSnapshot.trim() !== '' ? denyReasonSnapshot : undefined;
214
240
  return {
215
241
  kind: 'decided',
216
242
  gatedCallId,
217
243
  denied: true,
218
244
  ...(denySettledBy !== undefined ? { denySettledBy } : {}),
219
245
  ...(denyReason !== undefined ? { denyReason } : {}),
246
+ ...(attributionDropped ? { denyAttributionDropped: true } : {}),
220
247
  ...(receipt !== undefined ? { receipt } : {}),
221
248
  };
222
249
  }
@@ -224,6 +251,7 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
224
251
  const currentPending = readDecideCurrentPending(e);
225
252
  const wireCode = readWireErrorCode(e);
226
253
  const safetyCode = e instanceof HitlSafetyError ? e.code : undefined;
254
+ const attributionRefusal = denyAttributionRefusalFromError(e, lastSendCarriedAttribution);
227
255
  return {
228
256
  kind: 'failed',
229
257
  stage: 'decide',
@@ -233,8 +261,10 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
233
261
  ...(currentPending !== undefined ? { currentPending } : {}),
234
262
  ...(wireCode !== undefined ? { errorCode: wireCode } : {}),
235
263
  ...(safetyCode !== undefined ? { safetyCode } : {}),
264
+ ...(attributionRefusal !== null ? { denyAttributionRefusal: attributionRefusal } : {}),
236
265
  };
237
266
  }
267
+ }
238
268
  }
239
269
  }
240
270
  function readReadRootCandidate(v) {
@@ -392,6 +422,13 @@ function readPersistedRuleAnchors(v) {
392
422
  function isNonNegativeSafeInt(v) {
393
423
  return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
394
424
  }
425
+ function shouldResendWithoutAttribution(e, sentSettledBy, signal) {
426
+ if (!sentSettledBy)
427
+ return false;
428
+ if (signal?.aborted === true)
429
+ return false;
430
+ return denyAttributionRefusalFromError(e, true)?.kind === 'not_in_proof';
431
+ }
395
432
  export function readToolApprovalRespondRefusal(err) {
396
433
  const pick = (key) => {
397
434
  try {
@@ -647,6 +684,8 @@ function subagentBadgeFor(frame) {
647
684
  }
648
685
  const MAX_RESPOND_NOTE_CHARS = 2048;
649
686
  export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamArgs, signal, lane) {
687
+ const approvalId = frame.approvalId;
688
+ const frameToolCallId = typeof frame.toolCallId === 'string' && frame.toolCallId !== '' ? frame.toolCallId : undefined;
650
689
  const toolName = typeof frame.toolName === 'string' ? frame.toolName : 'Write';
651
690
  let args = streamArgs !== undefined && streamArgs !== null
652
691
  ? streamArgs
@@ -657,7 +696,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
657
696
  if (typeof args !== 'object' || args === null) {
658
697
  if (frame.argsOmitted === true) {
659
698
  wireNote = 'tool arguments exceeded the wire cap and were omitted — the diff below is reconstructed from the gate message, not the full payload';
660
- hostLog('debug', `liveToolApprovalWire: frame ${frame.approvalId} args omitted (>16KiB wire cap) — card falls back to message-derived path`);
699
+ hostLog('debug', `liveToolApprovalWire: frame ${approvalId} args omitted (>16KiB wire cap) — card falls back to message-derived path`);
661
700
  }
662
701
  if (lane?.argsUnavailable === true) {
663
702
  wireNote =
@@ -673,9 +712,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
673
712
  const card = await surfaceApprovalCard({
674
713
  toolName,
675
714
  args: args,
676
- callKey: liveFrameCallKey(frame.approvalId),
677
- ...(typeof frame.toolCallId === 'string' && frame.toolCallId !== ''
678
- ? { toolCallId: frame.toolCallId }
715
+ callKey: liveFrameCallKey(approvalId),
716
+ ...(frameToolCallId !== undefined
717
+ ? { toolCallId: frameToolCallId }
679
718
  : {}),
680
719
  ...(signal ? { signal } : {}),
681
720
  ...(isFromSubagent(frame) ? { workerBadge: subagentBadgeFor(frame) } : {}),
@@ -713,32 +752,36 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
713
752
  ...(typeof frame.origin === 'string' && frame.origin !== '' ? { origin: frame.origin } : {}),
714
753
  });
715
754
  if (lane?.argsUnavailable === true && card.kind === 'allow' && card.updatedInput !== undefined) {
716
- hostLog('error', `liveToolApprovalWire: ${frame.approvalId} card returned an edited approval on an args-unavailable ask — nothing sent (the ask stays pending)`);
755
+ hostLog('error', `liveToolApprovalWire: ${approvalId} card returned an edited approval on an args-unavailable ask — nothing sent (the ask stays pending)`);
717
756
  surfaceEditRefusedOnBlindAsk();
718
757
  return { decision: 'unresolved', editRefused: true };
719
758
  }
720
759
  if (card.kind === RETRACTED_CARD_DECISION_KIND) {
721
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} card retracted without a decision${card.reason ? ` (${card.reason})` : ''} — nothing sent`);
760
+ hostLog('debug', `liveToolApprovalWire: ${approvalId} card retracted without a decision${card.reason ? ` (${card.reason})` : ''} — nothing sent`);
722
761
  return { decision: 'unresolved', retracted: true };
723
762
  }
724
763
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
764
+ const denySettledByForWire = card.kind === 'deny' ? readApprovalDenySettledBy(card) : undefined;
765
+ const sentSettledBy = decision === 'deny' && denySettledByForWire === 'policy';
766
+ let lastSendCarriedAttribution = sentSettledBy;
767
+ const denyReasonSnapshot = card.kind === 'deny' && typeof card.reason === 'string' ? card.reason : undefined;
725
768
  if (card.kind === 'failed') {
726
- hostLog('debug', `liveToolApprovalWire: approval card unavailable (${card.reason}) — fail-closed deny for ${frame.approvalId}`);
769
+ hostLog('debug', `liveToolApprovalWire: approval card unavailable (${card.reason}) — fail-closed deny for ${approvalId}`);
727
770
  }
728
771
  let note;
729
- if (card.kind === 'deny' && typeof card.reason === 'string' && card.reason.trim() !== '') {
772
+ if (denyReasonSnapshot !== undefined && denyReasonSnapshot.trim() !== '') {
730
773
  if (lane?.approvalDecisionNoteCapable !== true) {
731
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} card supplied a deny reason (len=${card.reason.length}) but the ` +
774
+ hostLog('debug', `liveToolApprovalWire: ${approvalId} card supplied a deny reason (len=${denyReasonSnapshot.length}) but the ` +
732
775
  'approvalDecisionNote capability is not confirmed for this engine — note not sent (an unknown key would be ' +
733
776
  'silently swallowed by an older server while still acking 200)');
734
777
  }
735
- else if (card.reason.length > MAX_RESPOND_NOTE_CHARS) {
736
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} deny reason exceeds the decision_note cap ` +
737
- `(len=${card.reason.length} > ${MAX_RESPOND_NOTE_CHARS}) — note not sent at all (the server rejects the WHOLE ` +
778
+ else if (denyReasonSnapshot.length > MAX_RESPOND_NOTE_CHARS) {
779
+ hostLog('debug', `liveToolApprovalWire: ${approvalId} deny reason exceeds the decision_note cap ` +
780
+ `(len=${denyReasonSnapshot.length} > ${MAX_RESPOND_NOTE_CHARS}) — note not sent at all (the server rejects the WHOLE ` +
738
781
  'respond with 400 over an oversize note, and a silently halved audit reason is worse than none)');
739
782
  }
740
783
  else {
741
- note = card.reason;
784
+ note = denyReasonSnapshot;
742
785
  }
743
786
  }
744
787
  try {
@@ -751,7 +794,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
751
794
  const wantsTextArm = card.kind === 'allow' && typeof card.persistRule === 'string' && card.persistRule !== '';
752
795
  const wantsBatchArm = card.kind === 'allow' && card.persistRuleBatchOfferIndex !== undefined;
753
796
  if (wantsTextArm && wantsBatchArm) {
754
- hostLog('error', `liveToolApprovalWire: DROPPING the whole persistRule arm for ${frame.approvalId} — the card returned BOTH a rule ` +
797
+ hostLog('error', `liveToolApprovalWire: DROPPING the whole persistRule arm for ${approvalId} — the card returned BOTH a rule ` +
755
798
  'text and a batchOfferIndex; the server rejects that combination with a 400 that would take the decision down ' +
756
799
  'with it, and picking one arm here would be deciding on the user\'s behalf (the decision itself still goes through)');
757
800
  ruleArmDroppedForCheck = true;
@@ -769,14 +812,14 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
769
812
  batchArmTarget = target;
770
813
  }
771
814
  else {
772
- hostLog('debug', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${idx}) for ${frame.approvalId} — the ` +
815
+ hostLog('debug', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${idx}) for ${approvalId} — the ` +
773
816
  'respondBatchRuleOffers capability is not confirmed for this engine, so the batch arm is not sent at all ' +
774
817
  '(the decision itself still goes through unchanged; the offer stays on the card for local/display use)');
775
818
  ruleArmDroppedForCaps = true;
776
819
  }
777
820
  }
778
821
  else {
779
- hostLog('error', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${String(idx)}) for ${frame.approvalId} — that wire ` +
822
+ hostLog('error', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${String(idx)}) for ${approvalId} — that wire ` +
780
823
  'index is not a batch offer on this frame (out of range, a single offer, or not a non-negative integer); ' +
781
824
  'never redeeming an index the engine did not offer as a batch');
782
825
  ruleArmDroppedForCheck = true;
@@ -790,7 +833,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
790
833
  persistRuleEdited = true;
791
834
  }
792
835
  else {
793
- hostLog('debug', `liveToolApprovalWire: DROPPING edited persistRule for ${frame.approvalId} (len=${card.persistRule.length}) — the ` +
836
+ hostLog('debug', `liveToolApprovalWire: DROPPING edited persistRule for ${approvalId} (len=${card.persistRule.length}) — the ` +
794
837
  'respondFreeFormRules capability is not confirmed for this engine, so the free-form arm is not sent at all ' +
795
838
  '(the decision itself still goes through unchanged)');
796
839
  ruleArmDroppedForCaps = true;
@@ -800,22 +843,40 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
800
843
  persistRule = card.persistRule;
801
844
  }
802
845
  else {
803
- hostLog('error', `liveToolApprovalWire: DROPPING persistRule for ${frame.approvalId} — the card returned a rule that is not among the frame's SINGLE offers (a batch member's text is not a selectable candidate either: a conjunction batch is redeemed by index, all-or-nothing) — never sending un-offered text to the rule store`);
846
+ hostLog('error', `liveToolApprovalWire: DROPPING persistRule for ${approvalId} — the card returned a rule that is not among the frame's SINGLE offers (a batch member's text is not a selectable candidate either: a conjunction batch is redeemed by index, all-or-nothing) — never sending un-offered text to the rule store`);
804
847
  ruleArmDroppedForCheck = true;
805
848
  }
806
849
  }
807
- const raw = await respond(frame.approvalId, decision, {
850
+ const respondOpts = {
808
851
  ...(signal && !signal.aborted ? { signal } : {}),
809
852
  ...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
810
853
  ...(persistRule !== undefined ? { persistRule } : {}),
811
854
  ...(persistRuleEdited !== undefined ? { persistRuleEdited } : {}),
812
855
  ...(persistRuleBatchOfferIndex !== undefined ? { persistRuleBatchOfferIndex } : {}),
813
856
  ...(note !== undefined ? { note } : {}),
814
- });
857
+ ...(sentSettledBy ? { settledBy: 'policy' } : {}),
858
+ };
859
+ let attributionDropped = false;
860
+ let raw;
861
+ try {
862
+ raw = await respond(approvalId, decision, respondOpts);
863
+ }
864
+ catch (first) {
865
+ if (!shouldResendWithoutAttribution(first, lastSendCarriedAttribution, signal))
866
+ throw first;
867
+ const { settledBy: _droppedKey, ...withoutAttribution } = respondOpts;
868
+ void _droppedKey;
869
+ lastSendCarriedAttribution = false;
870
+ hostLog('debug', `liveToolApprovalWire: respond(deny) for ${approvalId} was refused because the settlement note is not part of ` +
871
+ 'what this deployment signs — re-sending the SAME decision exactly once with that one key removed ' +
872
+ '(the refusal happened before the approval was judged, so nothing was consumed and this is a re-delivery, not a second act)');
873
+ raw = await respond(approvalId, decision, withoutAttribution);
874
+ attributionDropped = true;
875
+ }
815
876
  const parsed = readToolApprovalRespondAck(raw);
816
877
  let ack = parsed;
817
- if (parsed !== undefined && (parsed.approvalId !== frame.approvalId || parsed.decision !== decision)) {
818
- hostLog('error', `liveToolApprovalWire: DISCARDING respond ack for ${frame.approvalId} — it does not correlate ` +
878
+ if (parsed !== undefined && (parsed.approvalId !== approvalId || parsed.decision !== decision)) {
879
+ hostLog('error', `liveToolApprovalWire: DISCARDING respond ack for ${approvalId} — it does not correlate ` +
819
880
  `(ack.approvalId=${parsed.approvalId} ack.decision=${parsed.decision}, sent decision=${decision}); ` +
820
881
  'treating as "no ack" (unknown) — never surfacing a safety notice off an unrelated receipt');
821
882
  ack = undefined;
@@ -846,7 +907,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
846
907
  strayEchoes.push('persistedRuleAnchors');
847
908
  if (strayEchoes.length > 0) {
848
909
  hostLog('error', `liveToolApprovalWire: DISCARDING persisted-rule echo(es) [${strayEchoes.join(', ')}] on the ack for ` +
849
- `${frame.approvalId} — they do not correlate with the persistence arm actually sent ` +
910
+ `${approvalId} — they do not correlate with the persistence arm actually sent ` +
850
911
  `(editedArm=${String(sentEditedArm)} batchArmMembers=${String(expectedBatchCount ?? 'none')} ` +
851
912
  `sentOfferIndex=${String(persistRuleBatchOfferIndex ?? 'none')} ` +
852
913
  `echoedRules=${String(ack.persistedRules?.length ?? 'none')} ` +
@@ -869,31 +930,39 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
869
930
  if (ruleArmDroppedForCheck)
870
931
  surfaceRuleArmRejected();
871
932
  if (decision === 'allow_session' && ack?.rememberApplied === false) {
872
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} allow_session ack rememberApplied=false — grant not stored, surfacing honest notice`);
933
+ hostLog('debug', `liveToolApprovalWire: ${approvalId} allow_session ack rememberApplied=false — grant not stored, surfacing honest notice`);
873
934
  surfaceRememberNotApplied();
874
935
  }
875
- if (card.kind === 'allow' && card.updatedInput !== undefined && ack?.updatedInputForwarded === false) {
876
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} edited args were NOT forwarded (ack.updatedInputForwarded=false) — the tool runs on the ORIGINAL input`);
936
+ if (respondOpts.updatedInput !== undefined && ack?.updatedInputForwarded === false) {
937
+ hostLog('debug', `liveToolApprovalWire: ${approvalId} edited args were NOT forwarded (ack.updatedInputForwarded=false) — the tool runs on the ORIGINAL input`);
877
938
  surfaceEditNotForwarded();
878
939
  }
879
940
  if (note !== undefined && ack !== undefined && ack.noteRecorded !== true) {
880
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} decision note was sent but not persisted ` +
941
+ hostLog('debug', `liveToolApprovalWire: ${approvalId} decision note was sent but not persisted ` +
881
942
  `(ack.noteRecorded=${String(ack.noteRecorded)}) — decision stood; the audit note did not land on the ask row`);
882
943
  }
883
- const denyAttribution = card.kind === 'deny'
944
+ const denyAttribution = decision === 'deny'
884
945
  ? {
885
- ...((o) => (o !== undefined ? { denySettledBy: o } : {}))(readApprovalDenySettledBy(card)),
886
- ...(typeof card.reason === 'string' && card.reason.trim() !== '' ? { denyReason: card.reason } : {}),
946
+ ...(attributionDropped ? { denyAttributionDropped: true } : {}),
947
+ ...(denySettledByForWire !== undefined ? { denySettledBy: denySettledByForWire } : {}),
948
+ ...(denyReasonSnapshot !== undefined && denyReasonSnapshot.trim() !== ''
949
+ ? { denyReason: denyReasonSnapshot }
950
+ : {}),
887
951
  }
888
952
  : {};
889
953
  return ack !== undefined ? { decision, ack, ...denyAttribution } : { decision, ...denyAttribution };
890
954
  }
891
955
  catch (e) {
892
956
  const respondRefusal = readToolApprovalRespondRefusal(e);
893
- hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${frame.approvalId} ` +
957
+ const attributionRefusal = denyAttributionRefusalFromError(e, lastSendCarriedAttribution);
958
+ hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${approvalId} ` +
894
959
  `(status=${respondRefusal?.status ?? 'none'} errorCode=${logSafeErrorCode(respondRefusal?.errorCode)} ` +
895
960
  `messageLen=${respondRefusal?.message?.length ?? 0}) — engine self-settles (TTL/abort); ` +
896
961
  'the refusal text is handed back on outcome.respondRefusal for the host to surface');
897
- return respondRefusal !== undefined ? { decision: 'unresolved', respondRefusal } : { decision: 'unresolved' };
962
+ return {
963
+ decision: 'unresolved',
964
+ ...(respondRefusal !== undefined ? { respondRefusal } : {}),
965
+ ...(attributionRefusal !== null ? { denyAttributionRefusal: attributionRefusal } : {}),
966
+ };
898
967
  }
899
968
  }
package/dist/index.d.ts CHANGED
@@ -40,6 +40,7 @@ export * from './argvFlagValue.js';
40
40
  export * from './readFacePosture.js';
41
41
  export * from './mcpPanel.js';
42
42
  export * from './mcpLiveness.js';
43
+ export * from './mcpEngineLeg.js';
43
44
  export * from './mcpProbeCapability.js';
44
45
  export * from './mcpProbeWire.js';
45
46
  export * from './effectiveFacts.js';
@@ -174,3 +175,4 @@ export * from './env/localeGeo.js';
174
175
  export * from './env/localeTag.js';
175
176
  export * from './env/uiLanguage.js';
176
177
  export type { PendingCheckpoint, RuleSuggestion } from '@sema-agent/sdk';
178
+ export * from './ruleRemovalConsequence.js';
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ export * from './argvFlagValue.js';
40
40
  export * from './readFacePosture.js';
41
41
  export * from './mcpPanel.js';
42
42
  export * from './mcpLiveness.js';
43
+ export * from './mcpEngineLeg.js';
43
44
  export * from './mcpProbeCapability.js';
44
45
  export * from './mcpProbeWire.js';
45
46
  export * from './effectiveFacts.js';
@@ -171,3 +172,4 @@ export * from './websearch/searchProviderPresets.js';
171
172
  export * from './env/localeGeo.js';
172
173
  export * from './env/localeTag.js';
173
174
  export * from './env/uiLanguage.js';
175
+ export * from './ruleRemovalConsequence.js';
@@ -0,0 +1,23 @@
1
+ import type { WiringManifestMcpEntryView } from './adapter/downstream/eventToSdkMessage.js';
2
+ export type McpEngineLegLiveness = {
3
+ kind: 'unobserved';
4
+ } | {
5
+ kind: 'not-listed';
6
+ } | {
7
+ kind: 'ambiguous';
8
+ rows: number;
9
+ } | {
10
+ kind: 'unreadable';
11
+ } | {
12
+ kind: 'absent';
13
+ } | {
14
+ kind: 'observed';
15
+ state: string;
16
+ };
17
+ export declare function mcpEngineLegLivenessOf(serverName: string, rows: readonly WiringManifestMcpEntryView[] | null | undefined): McpEngineLegLiveness;
18
+ export interface McpDetailLegNoteInput {
19
+ localClientFailed: boolean;
20
+ engineHostedToolCount: number | undefined;
21
+ liveness: McpEngineLegLiveness;
22
+ }
23
+ export declare function mcpDetailLegNote(input: McpDetailLegNoteInput): string | undefined;
@@ -0,0 +1,135 @@
1
+ import { isMcpLivenessState } from './mcpLiveness.js';
2
+ void true;
3
+ void true;
4
+ void true;
5
+ const MCP_LEG_ROSTER_SCAN_LIMIT = 4096;
6
+ const ABSENT_CELL = Symbol('mcpEngineLeg.absent');
7
+ const UNREADABLE_CELL = Symbol('mcpEngineLeg.unreadable');
8
+ const readKey = (o, k) => {
9
+ if (o === null || typeof o !== 'object')
10
+ return ABSENT_CELL;
11
+ try {
12
+ if (!Object.hasOwn(o, k))
13
+ return ABSENT_CELL;
14
+ return o[k];
15
+ }
16
+ catch {
17
+ return UNREADABLE_CELL;
18
+ }
19
+ };
20
+ export function mcpEngineLegLivenessOf(serverName, rows) {
21
+ let isArray = false;
22
+ try {
23
+ isArray = Array.isArray(rows);
24
+ }
25
+ catch {
26
+ return { kind: 'unobserved' };
27
+ }
28
+ if (!isArray)
29
+ return { kind: 'unobserved' };
30
+ const len = readKey(rows, 'length');
31
+ if (typeof len !== 'number' || !Number.isInteger(len) || len < 0)
32
+ return { kind: 'unobserved' };
33
+ if (len > MCP_LEG_ROSTER_SCAN_LIMIT)
34
+ return { kind: 'unobserved' };
35
+ let first;
36
+ let matches = 0;
37
+ let incomplete = false;
38
+ for (let i = 0; i < len; i++) {
39
+ const r = readKey(rows, i);
40
+ if (r === UNREADABLE_CELL) {
41
+ incomplete = true;
42
+ continue;
43
+ }
44
+ if (r === null || typeof r !== 'object')
45
+ continue;
46
+ const nm = readKey(r, 'name');
47
+ if (nm === UNREADABLE_CELL) {
48
+ incomplete = true;
49
+ continue;
50
+ }
51
+ if (nm !== serverName)
52
+ continue;
53
+ matches++;
54
+ if (matches === 1)
55
+ first = r;
56
+ }
57
+ if (incomplete)
58
+ return { kind: 'unobserved' };
59
+ if (matches >= 2)
60
+ return { kind: 'ambiguous', rows: matches };
61
+ if (matches === 0)
62
+ return { kind: 'not-listed' };
63
+ const flag = readKey(first, 'livenessUnreadable');
64
+ if (flag === UNREADABLE_CELL || flag === true)
65
+ return { kind: 'unreadable' };
66
+ const cell = readKey(first, 'liveness');
67
+ if (cell === ABSENT_CELL)
68
+ return { kind: 'absent' };
69
+ if (cell === UNREADABLE_CELL || cell === null || typeof cell !== 'object')
70
+ return { kind: 'unreadable' };
71
+ const state = readKey(cell, 'state');
72
+ if (typeof state !== 'string' || state === '')
73
+ return { kind: 'unreadable' };
74
+ return { kind: 'observed', state };
75
+ }
76
+ const rosterReadingOf = (v) => {
77
+ if (v === UNREADABLE_CELL)
78
+ return 'unreadable';
79
+ if (v === ABSENT_CELL || v === undefined)
80
+ return 'silent';
81
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 0)
82
+ return 'unreadable';
83
+ return v > 0 ? 'listed' : 'zero';
84
+ };
85
+ const NOTE_ENGINE_REACHES = "This client's own connection is down; the engine still reaches it.";
86
+ const NOTE_ENGINE_UNREACHABLE = "This client's own connection is down, and the engine could not reach it when it last looked.";
87
+ const NOTE_ENGINE_CANNOT_TELL = "This client's own connection is down; the engine could not tell whether it reaches this server.";
88
+ const NOTE_ENGINE_WORD_UNRECOGNISED = "This client's own connection is down; the engine reported a liveness state this client does not recognise.";
89
+ const NOTE_LIVENESS_UNREADABLE = "This client's own connection is down; the engine sent a liveness record for this server that this client could not read.";
90
+ const NOTE_ROSTER_LISTS_TOOLS = "This client's own connection is down; the engine listed this server's tools for the last run.";
91
+ const NOTE_ENGINE_SILENT = "This status is this client's own connection. This client has neither a liveness reading nor a tool count from the engine for this server.";
92
+ const NOTE_ROSTER_ZERO = "This status is this client's own connection. The engine reported no tools from this server either.";
93
+ const NOTE_ROSTER_UNREADABLE = "This status is this client's own connection. The engine's tool roster for this server could not be read.";
94
+ export function mcpDetailLegNote(input) {
95
+ const raw = input;
96
+ if (raw === null || typeof raw !== 'object')
97
+ return undefined;
98
+ if (readKey(raw, 'localClientFailed') !== true)
99
+ return undefined;
100
+ const lv = readKey(raw, 'liveness');
101
+ const kind = lv !== null && typeof lv === 'object' ? readKey(lv, 'kind') : undefined;
102
+ if (lv !== null && typeof lv === 'object' && kind === 'observed') {
103
+ const state = readKey(lv, 'state');
104
+ if (isMcpLivenessState(state)) {
105
+ switch (state) {
106
+ case 'reachable':
107
+ return NOTE_ENGINE_REACHES;
108
+ case 'unreachable':
109
+ return NOTE_ENGINE_UNREACHABLE;
110
+ case 'unknown':
111
+ return NOTE_ENGINE_CANNOT_TELL;
112
+ default: {
113
+ const exhaustive = state;
114
+ void exhaustive;
115
+ return NOTE_ENGINE_WORD_UNRECOGNISED;
116
+ }
117
+ }
118
+ }
119
+ if (typeof state === 'string' && state !== '')
120
+ return NOTE_ENGINE_WORD_UNRECOGNISED;
121
+ return NOTE_LIVENESS_UNREADABLE;
122
+ }
123
+ if (kind === 'unreadable')
124
+ return NOTE_LIVENESS_UNREADABLE;
125
+ switch (rosterReadingOf(readKey(raw, 'engineHostedToolCount'))) {
126
+ case 'listed':
127
+ return NOTE_ROSTER_LISTS_TOOLS;
128
+ case 'zero':
129
+ return NOTE_ROSTER_ZERO;
130
+ case 'unreadable':
131
+ return NOTE_ROSTER_UNREADABLE;
132
+ default:
133
+ return NOTE_ENGINE_SILENT;
134
+ }
135
+ }
@@ -1,5 +1,6 @@
1
1
  export declare const MCP_LIVENESS_STATES: readonly ["reachable", "unreachable", "unknown"];
2
2
  export type McpLivenessState = (typeof MCP_LIVENESS_STATES)[number];
3
+ export declare const isMcpLivenessState: (v: unknown) => v is McpLivenessState;
3
4
  export interface McpLivenessView {
4
5
  state: McpLivenessState;
5
6
  observedAt: number;
@@ -2,7 +2,8 @@ export const MCP_LIVENESS_STATES = Object.freeze(['reachable', 'unreachable', 'u
2
2
  void MCP_LIVENESS_STATES;
3
3
  void true;
4
4
  void true;
5
- const isLivenessState = (v) => typeof v === 'string' && MCP_LIVENESS_STATES.includes(v);
5
+ export const isMcpLivenessState = (v) => typeof v === 'string' && MCP_LIVENESS_STATES.includes(v);
6
+ const isLivenessState = isMcpLivenessState;
6
7
  const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
7
8
  export function readMcpLiveness(row) {
8
9
  if (!isRecord(row))
@@ -0,0 +1,3 @@
1
+ export type RuleRemovalBehavior = 'allow' | 'deny' | 'ask' | undefined;
2
+ export declare function removalConsequenceLineForBehavior(behavior: RuleRemovalBehavior): string;
3
+ export declare function ruleRemovalBehaviorOf(raw: unknown): RuleRemovalBehavior;
@@ -0,0 +1,15 @@
1
+ export function removalConsequenceLineForBehavior(behavior) {
2
+ switch (behavior) {
3
+ case 'allow':
4
+ return 'Commands it covers will be asked about again.';
5
+ case 'deny':
6
+ return 'Commands it covers will no longer be refused by this rule — removing it widens what can run; it does not tighten anything.';
7
+ case 'ask':
8
+ return 'Commands it covers will no longer be held for approval by this rule.';
9
+ default:
10
+ return 'Whatever this rule does for the commands it covers will stop applying.';
11
+ }
12
+ }
13
+ export function ruleRemovalBehaviorOf(raw) {
14
+ return raw === 'allow' || raw === 'deny' || raw === 'ask' ? raw : undefined;
15
+ }
@@ -14,3 +14,12 @@ export interface UsageWindowExhaustedDetail {
14
14
  }
15
15
  export declare function usageWindowExhaustedFromError(err: unknown): UsageWindowExhaustedDetail | null;
16
16
  export declare function usageWindowExhaustedContent(detail: UsageWindowExhaustedDetail): string;
17
+ export type DenyAttributionRefusalKind = 'not_in_proof' | 'decision_conflict' | 'attribution_rejected';
18
+ export interface DenyAttributionRefusalDetail {
19
+ kind: DenyAttributionRefusalKind;
20
+ code: string;
21
+ status: number;
22
+ field?: string;
23
+ }
24
+ export declare function denyAttributionRefusalFromError(err: unknown, sentSettledBy: boolean): DenyAttributionRefusalDetail | null;
25
+ export declare function denyAttributionRefusalContent(detail: Pick<DenyAttributionRefusalDetail, 'kind' | 'field'>): string;