@sema-agent/client-core 0.79.0 → 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 {
@@ -447,11 +484,20 @@ export function isToolApprovalFrame(ev) {
447
484
  typeof e.approvalId === 'string' &&
448
485
  e.approvalId.length > 0);
449
486
  }
450
- const FS_WRITE_GATE_ASK_PATTERN = /^approve write to "([^"]+)"\?$/;
487
+ const FS_WRITE_GATE_ASK_PREFIX = 'approve write to "';
488
+ const FS_WRITE_GATE_ASK_PATH_CLOSE = '"?';
451
489
  function pathFromGateMessage(message) {
452
490
  if (typeof message !== 'string')
453
491
  return undefined;
454
- return FS_WRITE_GATE_ASK_PATTERN.exec(message)?.[1];
492
+ if (!message.startsWith(FS_WRITE_GATE_ASK_PREFIX))
493
+ return undefined;
494
+ const body = message.slice(FS_WRITE_GATE_ASK_PREFIX.length);
495
+ const close = body.indexOf(FS_WRITE_GATE_ASK_PATH_CLOSE);
496
+ if (close <= 0)
497
+ return undefined;
498
+ if (body.indexOf(FS_WRITE_GATE_ASK_PATH_CLOSE, close + 1) !== -1)
499
+ return undefined;
500
+ return body.slice(0, close);
455
501
  }
456
502
  function isNonNegativeFinite(v) {
457
503
  return typeof v === 'number' && Number.isFinite(v) && v >= 0;
@@ -638,6 +684,8 @@ function subagentBadgeFor(frame) {
638
684
  }
639
685
  const MAX_RESPOND_NOTE_CHARS = 2048;
640
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;
641
689
  const toolName = typeof frame.toolName === 'string' ? frame.toolName : 'Write';
642
690
  let args = streamArgs !== undefined && streamArgs !== null
643
691
  ? streamArgs
@@ -648,7 +696,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
648
696
  if (typeof args !== 'object' || args === null) {
649
697
  if (frame.argsOmitted === true) {
650
698
  wireNote = 'tool arguments exceeded the wire cap and were omitted — the diff below is reconstructed from the gate message, not the full payload';
651
- 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`);
652
700
  }
653
701
  if (lane?.argsUnavailable === true) {
654
702
  wireNote =
@@ -664,9 +712,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
664
712
  const card = await surfaceApprovalCard({
665
713
  toolName,
666
714
  args: args,
667
- callKey: liveFrameCallKey(frame.approvalId),
668
- ...(typeof frame.toolCallId === 'string' && frame.toolCallId !== ''
669
- ? { toolCallId: frame.toolCallId }
715
+ callKey: liveFrameCallKey(approvalId),
716
+ ...(frameToolCallId !== undefined
717
+ ? { toolCallId: frameToolCallId }
670
718
  : {}),
671
719
  ...(signal ? { signal } : {}),
672
720
  ...(isFromSubagent(frame) ? { workerBadge: subagentBadgeFor(frame) } : {}),
@@ -704,32 +752,36 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
704
752
  ...(typeof frame.origin === 'string' && frame.origin !== '' ? { origin: frame.origin } : {}),
705
753
  });
706
754
  if (lane?.argsUnavailable === true && card.kind === 'allow' && card.updatedInput !== undefined) {
707
- 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)`);
708
756
  surfaceEditRefusedOnBlindAsk();
709
757
  return { decision: 'unresolved', editRefused: true };
710
758
  }
711
759
  if (card.kind === RETRACTED_CARD_DECISION_KIND) {
712
- 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`);
713
761
  return { decision: 'unresolved', retracted: true };
714
762
  }
715
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;
716
768
  if (card.kind === 'failed') {
717
- 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}`);
718
770
  }
719
771
  let note;
720
- if (card.kind === 'deny' && typeof card.reason === 'string' && card.reason.trim() !== '') {
772
+ if (denyReasonSnapshot !== undefined && denyReasonSnapshot.trim() !== '') {
721
773
  if (lane?.approvalDecisionNoteCapable !== true) {
722
- 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 ` +
723
775
  'approvalDecisionNote capability is not confirmed for this engine — note not sent (an unknown key would be ' +
724
776
  'silently swallowed by an older server while still acking 200)');
725
777
  }
726
- else if (card.reason.length > MAX_RESPOND_NOTE_CHARS) {
727
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} deny reason exceeds the decision_note cap ` +
728
- `(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 ` +
729
781
  'respond with 400 over an oversize note, and a silently halved audit reason is worse than none)');
730
782
  }
731
783
  else {
732
- note = card.reason;
784
+ note = denyReasonSnapshot;
733
785
  }
734
786
  }
735
787
  try {
@@ -742,7 +794,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
742
794
  const wantsTextArm = card.kind === 'allow' && typeof card.persistRule === 'string' && card.persistRule !== '';
743
795
  const wantsBatchArm = card.kind === 'allow' && card.persistRuleBatchOfferIndex !== undefined;
744
796
  if (wantsTextArm && wantsBatchArm) {
745
- 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 ` +
746
798
  'text and a batchOfferIndex; the server rejects that combination with a 400 that would take the decision down ' +
747
799
  'with it, and picking one arm here would be deciding on the user\'s behalf (the decision itself still goes through)');
748
800
  ruleArmDroppedForCheck = true;
@@ -760,14 +812,14 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
760
812
  batchArmTarget = target;
761
813
  }
762
814
  else {
763
- hostLog('debug', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${idx}) for ${frame.approvalId} — the ` +
815
+ hostLog('debug', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${idx}) for ${approvalId} — the ` +
764
816
  'respondBatchRuleOffers capability is not confirmed for this engine, so the batch arm is not sent at all ' +
765
817
  '(the decision itself still goes through unchanged; the offer stays on the card for local/display use)');
766
818
  ruleArmDroppedForCaps = true;
767
819
  }
768
820
  }
769
821
  else {
770
- hostLog('error', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${String(idx)}) for ${frame.approvalId} — that wire ` +
822
+ hostLog('error', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${String(idx)}) for ${approvalId} — that wire ` +
771
823
  'index is not a batch offer on this frame (out of range, a single offer, or not a non-negative integer); ' +
772
824
  'never redeeming an index the engine did not offer as a batch');
773
825
  ruleArmDroppedForCheck = true;
@@ -781,7 +833,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
781
833
  persistRuleEdited = true;
782
834
  }
783
835
  else {
784
- 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 ` +
785
837
  'respondFreeFormRules capability is not confirmed for this engine, so the free-form arm is not sent at all ' +
786
838
  '(the decision itself still goes through unchanged)');
787
839
  ruleArmDroppedForCaps = true;
@@ -791,22 +843,40 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
791
843
  persistRule = card.persistRule;
792
844
  }
793
845
  else {
794
- 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`);
795
847
  ruleArmDroppedForCheck = true;
796
848
  }
797
849
  }
798
- const raw = await respond(frame.approvalId, decision, {
850
+ const respondOpts = {
799
851
  ...(signal && !signal.aborted ? { signal } : {}),
800
852
  ...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
801
853
  ...(persistRule !== undefined ? { persistRule } : {}),
802
854
  ...(persistRuleEdited !== undefined ? { persistRuleEdited } : {}),
803
855
  ...(persistRuleBatchOfferIndex !== undefined ? { persistRuleBatchOfferIndex } : {}),
804
856
  ...(note !== undefined ? { note } : {}),
805
- });
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
+ }
806
876
  const parsed = readToolApprovalRespondAck(raw);
807
877
  let ack = parsed;
808
- if (parsed !== undefined && (parsed.approvalId !== frame.approvalId || parsed.decision !== decision)) {
809
- 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 ` +
810
880
  `(ack.approvalId=${parsed.approvalId} ack.decision=${parsed.decision}, sent decision=${decision}); ` +
811
881
  'treating as "no ack" (unknown) — never surfacing a safety notice off an unrelated receipt');
812
882
  ack = undefined;
@@ -837,7 +907,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
837
907
  strayEchoes.push('persistedRuleAnchors');
838
908
  if (strayEchoes.length > 0) {
839
909
  hostLog('error', `liveToolApprovalWire: DISCARDING persisted-rule echo(es) [${strayEchoes.join(', ')}] on the ack for ` +
840
- `${frame.approvalId} — they do not correlate with the persistence arm actually sent ` +
910
+ `${approvalId} — they do not correlate with the persistence arm actually sent ` +
841
911
  `(editedArm=${String(sentEditedArm)} batchArmMembers=${String(expectedBatchCount ?? 'none')} ` +
842
912
  `sentOfferIndex=${String(persistRuleBatchOfferIndex ?? 'none')} ` +
843
913
  `echoedRules=${String(ack.persistedRules?.length ?? 'none')} ` +
@@ -860,31 +930,39 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
860
930
  if (ruleArmDroppedForCheck)
861
931
  surfaceRuleArmRejected();
862
932
  if (decision === 'allow_session' && ack?.rememberApplied === false) {
863
- 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`);
864
934
  surfaceRememberNotApplied();
865
935
  }
866
- if (card.kind === 'allow' && card.updatedInput !== undefined && ack?.updatedInputForwarded === false) {
867
- 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`);
868
938
  surfaceEditNotForwarded();
869
939
  }
870
940
  if (note !== undefined && ack !== undefined && ack.noteRecorded !== true) {
871
- hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} decision note was sent but not persisted ` +
941
+ hostLog('debug', `liveToolApprovalWire: ${approvalId} decision note was sent but not persisted ` +
872
942
  `(ack.noteRecorded=${String(ack.noteRecorded)}) — decision stood; the audit note did not land on the ask row`);
873
943
  }
874
- const denyAttribution = card.kind === 'deny'
944
+ const denyAttribution = decision === 'deny'
875
945
  ? {
876
- ...((o) => (o !== undefined ? { denySettledBy: o } : {}))(readApprovalDenySettledBy(card)),
877
- ...(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
+ : {}),
878
951
  }
879
952
  : {};
880
953
  return ack !== undefined ? { decision, ack, ...denyAttribution } : { decision, ...denyAttribution };
881
954
  }
882
955
  catch (e) {
883
956
  const respondRefusal = readToolApprovalRespondRefusal(e);
884
- hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${frame.approvalId} ` +
957
+ const attributionRefusal = denyAttributionRefusalFromError(e, lastSendCarriedAttribution);
958
+ hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${approvalId} ` +
885
959
  `(status=${respondRefusal?.status ?? 'none'} errorCode=${logSafeErrorCode(respondRefusal?.errorCode)} ` +
886
960
  `messageLen=${respondRefusal?.message?.length ?? 0}) — engine self-settles (TTL/abort); ` +
887
961
  'the refusal text is handed back on outcome.respondRefusal for the host to surface');
888
- return respondRefusal !== undefined ? { decision: 'unresolved', respondRefusal } : { decision: 'unresolved' };
962
+ return {
963
+ decision: 'unresolved',
964
+ ...(respondRefusal !== undefined ? { respondRefusal } : {}),
965
+ ...(attributionRefusal !== null ? { denyAttributionRefusal: attributionRefusal } : {}),
966
+ };
889
967
  }
890
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))
@@ -27,7 +27,7 @@ export const REQUEST_FIELD_MATRIX = [
27
27
  { field: 'limits', lanes: ['print', 'utility'], live: true, why: 'P2-3-b:`-p` 的预算护栏(--max-* flag 族),交互 REPL 由人随时 Esc', whyUtilityLane: '有座:side-channel 必须带预算护栏(墙钟 / 输出 token / 轮数)—— 它同样没有人看着,而且没有后续回合可以中断' },
28
28
  { field: 'interactiveTools', lanes: ['print', 'utility'], live: true, why: '[909]B 件3:无人值守 stamp false,从 roster 源头灭掉 AskUserQuestion/plan 门。交互车道 stamp false 等于自废武功', whyUtilityLane: '有座:side-channel 绝不该弹交互问答 / plan 门(没有人在看这条提交)。它与 excludeAllTools 是**两条独立**的收紧声明、在这条车道上**并存**(本车道强制带卸载声明,见 `refuseUtilityWithoutToolUnload`;这一位是它旁边那条「连问都别问」的声明,不是它的降级替身)' },
29
29
  { field: 'oneShot', lanes: ['print', 'utility'], live: true, why: 'SDK TaskRequest.oneShot(server ≥7.12.0 消费):`-p` 提交是一次性的——没有后续回合接住异步后台通知,core 据此把「结束回合等通知」指引换成 block-wait;交互车道有后续回合,stamp true 就是谎报。老 worker 静默忽略(开集提交体),故发送侧不设能力位前置门(oneShotWireCaps 头注)', whyUtilityLane: '有座:side-channel 就是一次性提交 —— 没有后续回合接住异步后台通知,引擎据此把「结束回合等通知」换成 block-wait' },
30
- { field: 'outputSchema', lanes: ['print', 'utility'], live: true, why: '结构化输出约束(sdk `TaskRequest.outputSchema`:最终答案必须匹配的 JSON Schema)。上游只验形状与序列化尺寸(坏形 400 响亮拒)、深层合法性归引擎 ⇒ 本层原样透传、不预铸第二判官。来源只在无人值守车道(`--json-schema` 一类旗);交互面今天没有入口 ⇒ 缺席是**没有来源**,不是漏。⚠️ 本行只管**上行**这一位:结果一侧的结构化产出落不落 CC 形 result 帧是下行投影的事,不在本表', whyUtilityLane: '有座:side-channel 的典型形正是「要一个可机读的小答案」(分类 / 摘要 / 抽取),结构化输出约束是它最直接的位,而 schema 由壳自己给 ⇒ 有来源' },
30
+ { field: 'outputSchema', lanes: ['print', 'utility'], live: true, why: '结构化输出约束(sdk `TaskRequest.outputSchema`:最终答案必须匹配的 JSON Schema)。上游只验形状与序列化尺寸(坏形 400 响亮拒)、深层合法性归引擎 ⇒ 本层原样透传、不预铸第二判官。来源只可能在无人值守车道(schema 由壳的无人值守入口给;哪个壳今天真的接了那条入口是壳侧的事,本表只说座位);交互面今天没有入口 ⇒ 缺席是**没有来源**,不是漏。⚠️ 本行只管**上行**这一位:结果一侧的结构化产出落不落 CC 形 result 帧是下行投影的事,不在本表', whyUtilityLane: '有座:side-channel 的典型形正是「要一个可机读的小答案」(分类 / 摘要 / 抽取),结构化输出约束是它最直接的位,而 schema 由壳自己给 ⇒ 有来源' },
31
31
  { field: 'outputRetries', lanes: ['print', 'utility'], live: true, why: '结构化输出的重试轮数(sdk `TaskRequest.outputRetries`;与 `outputSchema` 是**成对旋钮** —— 没有 schema 时引擎忽略它)。🔴 座位集与 `outputSchema` 那一行**逐字相同**:一半有座一半没座 = 端调得动重试轮数却调不动它所服务的约束(同座孪生分开定座,正是「主轴改了、孪生漏了」那一族)。上游接受域很窄(有限、≥1、向下取整、封顶 10),**表外一律整键省略**并回落引擎缺省 ⇒ 本层对坏值**响亮拒**(见 `refuseBadOutputRetries`),但**不预夹** [1,10]:夹是上游的事,本层不铸第二判官', whyUtilityLane: '有座:与 outputSchema 同座孪生 —— side-channel 要机读小答案时,重试轮数是同一个旋钮的另一半' },
32
32
  { field: 'maxCostUsd', lanes: ['print', 'utility'], live: true, why: '花费上限声明(sdk `TaskRequest.maxCostUsd`,请求体**顶层**位;上游向下夹到部署上限 —— 只许要得更少)。与 `limits` 同一条理由只在无人值守车道:交互面由人随时中断。🔴 坏值(非数 / 非有限 / 非正)在本层**响亮拒**:上游对这一位的坏值是安静忽略并回落部署上限,不是 4xx(见 `refuseBadMaxCostUsd`)', whyUtilityLane: '有座:小预算是本车道的定义之一(与 limits 同一条理由);上游向下夹到部署上限,只许要得更少' },
33
33
  { field: 'maxTokens', lanes: ['print', 'utility'], live: true, why: '整任务 token 预算上限声明(sdk `TaskRequest.maxTokens`,请求体**顶层**位,与 `maxCostUsd` 同属上游那一族「调用方要求的上限」:向下夹到部署上限 —— 只许要得更少)。🔴 座位集与 `maxCostUsd` 那一行**逐字相同**:同一条预算轴的两种计价单位,分开定座就是让其中一条静默漂。🔴 坏值(非数 / 非有限 / 非正 / 非整)在本层**响亮拒**:上游对这一位的坏值是安静按「调用方没给」处理并回落部署上限,不是 4xx —— 部署没设上限时这一次运行就没有 token 闸,而声明人不会知道(见 `refuseBadMaxTokens`)', whyUtilityLane: '有座:与 maxCostUsd 同轴孪生 —— 小预算是本车道的定义之一,两种计价单位都该能声明' },
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export { RegistryClient } from '@sema-agent/sdk/registry';
2
+ export { getMeConfig, getScopeConfigDraft, getEffective } from '@sema-agent/sdk/registry';
3
+ export { probeRegistryHealth, postFeedback } from '@sema-agent/sdk/registry';
4
+ export { REGISTRY_AUTH_PATHS, skillContentAddress } from '@sema-agent/sdk/registry';
5
+ export { RegistryApiError, OAuthFlowError } from '@sema-agent/sdk/registry';
6
+ export type { RegistryTokens, TokenProvider, } from '@sema-agent/sdk/registry';
@@ -0,0 +1,5 @@
1
+ export { RegistryClient } from '@sema-agent/sdk/registry';
2
+ export { getMeConfig, getScopeConfigDraft, getEffective } from '@sema-agent/sdk/registry';
3
+ export { probeRegistryHealth, postFeedback } from '@sema-agent/sdk/registry';
4
+ export { REGISTRY_AUTH_PATHS, skillContentAddress } from '@sema-agent/sdk/registry';
5
+ export { RegistryApiError, OAuthFlowError } from '@sema-agent/sdk/registry';