Skip to content

Enforce the declared SAP target on the RFC transport - #9

Merged
KexiHe merged 1 commit into
mainfrom
fix/rfc-target-expectation-guard
Aug 6, 2026
Merged

Enforce the declared SAP target on the RFC transport#9
KexiHe merged 1 commit into
mainfrom
fix/rfc-target-expectation-guard

Conversation

@KexiHe

@KexiHe KexiHe commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Why

d7942b5 / #7 gave the GUI transport a target guard (GUI_TARGET: stamp + AssertSapGuiTarget hard refusal on SAPDEV_EXPECT_SYSTEM/_CLIENT mismatch), and #8 wired the expectation into every write block. The RFC transport had no equivalent: Connect-SapRfc silently connected to whatever profile its resolution chain (pin → GUI-active → default → sole-profile) landed on, and nothing in its output said which system that was.

Live incident 2026-08-06 (second cross-system contamination, RFC transport this time): the m365-copilot adaptive driver spawned a headless Claude session; the child's CLAUDE_CODE_SESSION_ID resolved a fresh pinless AI session, Connect-SapRfc fell through to the saved DEFAULT profile (S4G = S4H/400), and the session faithfully read Z_EXCEPTION_1 from S4H/400 while the driver's pin, syntax check and verdict all pointed at S4D/100. The staged "fix" was the wrong system's source and still passed the S4D syntax gate. The m365 side now hands the child the driver's session id via SAPDEV_AI_SESSION_ID, which fixes the resolution — this PR makes any future drift refuse loudly instead of contaminating silently.

What

  • sap_rfc_lib.ps1Connect-SapRfc mirrors AssertSapGuiTarget:
    • Expectation set + mismatch ⇒ hard refusal (ERROR naming both systems, "continuing would read/write TWO DIFFERENT SAP SYSTEMS in one run", $null return) — before logon when the identity is known pre-connect, so the wrong system is never even logged onto with the profile's saved password.
    • Every successful connect stamps RFC_TARGET: system=<SID> client=<NNN> user=<U> endpoint=<...> via=<pin|gui-active|default|single-profile|explicit-params> — the sibling of GUI_TARGET:, so transcripts carry provenance for both transports. Unset expectation = legacy behaviour, still stamped.
    • Identity comes from the resolved profile's system_name/client — never RfcDestination.SystemID (configured R3NAME, blank on direct -Server/-Sysnr connections; the d7942b5 trap). Caller-supplied endpoints are attributed by exact endpoint match against the saved connection store (keeps the post-activate verifiers checkable pre-logon), then by the live SystemAttributes logon identity; expectation set + no identity readable ⇒ unverified-target refusal, mirroring the GUI rule.
  • sap_connection_lib.ps1Get-SapCurrentConnectionProfile gains an optional [ref] -ResolvedVia out-param (which resolution step won); additive, all callers use named params. Set-SapGuiTargetExpectation docs/messages now state both transports enforce the pair (children inherit the env, so one export covers the whole process tree).
  • Deliberate cross-system legs (/sap-compare, /sap-transport-sequencer, /sap-cc-* source reads) set no expectation and are unaffected; under an inherited expectation they must Set-SapGuiTargetExpectation -Clear first (documented).
  • Docs: gotcha 5 in contributing/parallel_safe_session_attach.md grows the RFC side; CLAUDE.md rows updated; attach-lib header cross-references the mirror.

Testing

  • Offline (Windows PS 5.1, _Load-SapNco stubbed — no logon possible): pre-logon refusal on system and on client mismatch (via=gui-active provenance), endpoint-match attribution refusing an explicit -Server call under a bogus expectation, matching expectations passing, no-expectation legacy path untouched.
  • Live (32-bit PS, read-only connect+ping on S4D/100): RFC_TARGET: system=S4D client=100 user=... endpoint=sap1.vicp.cc:70 via=gui-active; SystemAttributes.SystemID reads 'S4D' on a direct connection; return value remains a clean RfcDestination (stamp is Write-Host, not pipeline output). Full log: temp/testReport/rfc_target_guard_20260806.md (local).
  • npm run validate green: OK: 4 plugins, 123 skills, all manifests aligned at version 0.8.1, Tier 3 attach contract clean, screen-baseline coverage 136/136.

🤖 Generated with Claude Code

d7942b5 gave the GUI transport a target guard: every attach stamps
GUI_TARGET: and AssertSapGuiTarget hard-refuses (exit 2) when
SAPDEV_EXPECT_SYSTEM / SAPDEV_EXPECT_CLIENT are set and mismatch;
01efb00 wired the expectation into every write block. The RFC transport
had no equivalent: Connect-SapRfc silently connected to whatever profile
its resolution chain (pin -> GUI-active -> default -> sole-profile)
landed on, and nothing in its output said which system that was.

Live incident 2026-08-06 (second cross-system contamination, RFC
transport this time): the m365-copilot adaptive driver spawned a
headless Claude session; the child's CLAUDE_CODE_SESSION_ID made its
skills resolve a fresh pinless AI session, Connect-SapRfc fell through
to the saved DEFAULT profile (S4G = S4H/400), and the session faithfully
read Z_EXCEPTION_1 from S4H/400 while the driver's pin, syntax check and
verdict all pointed at S4D/100. The staged "fix" was the wrong system's
source and still passed the S4D syntax gate (it compiles on both). The
m365 side now hands the child the driver's session id via
SAPDEV_AI_SESSION_ID (which Get-SapAiSessionId already honors), fixing
the resolution -- but nothing ENFORCED the expectation on this
transport, so a future resolution drift would again be silent.

Fixes:
- sap_rfc_lib.ps1: Connect-SapRfc mirrors AssertSapGuiTarget. When the
  expectation is exported and the resolved target mismatches, it refuses
  hard (ERROR naming both systems, "continuing would read/write TWO
  DIFFERENT SAP SYSTEMS in one run", + $null return) -- BEFORE logon
  when the identity is known pre-connect, so the wrong system is never
  even logged onto with the profile's saved password. Every successful
  connect stamps
    RFC_TARGET: system=<SID> client=<NNN> user=<U> endpoint=<..> via=<v>
  (via = pin | gui-active | default | single-profile | explicit-params),
  the sibling of GUI_TARGET:, so transcripts carry provenance for both
  transports. Unset expectation = legacy behaviour, still stamped.
- Identity comparison uses the resolved profile's system_name/client,
  never RfcDestination.SystemID -- that is the configured R3NAME and is
  blank on a direct -Server/-Sysnr connection (the d7942b5 trap). A
  caller-supplied endpoint is attributed by exact endpoint match against
  the saved connection store (so the post-activate verifiers, which
  resolve the pinned profile themselves and pass -Server explicitly,
  stay checkable before logon), then by the live logon identity in
  RfcDestination.SystemAttributes (best-effort, the same read
  sap_rfc_read_source.ps1 ships); expectation set + no identity readable
  = refusal, mirroring the GUI side's unverified-target rule. Deliberate
  cross-system legs (/sap-compare, /sap-transport-sequencer, /sap-cc-*
  source reads) set no expectation and are unaffected; under an
  inherited expectation they must clear the pair first
  (Set-SapGuiTargetExpectation -Clear).
- sap_connection_lib.ps1: Get-SapCurrentConnectionProfile gains an
  optional [ref] -ResolvedVia out-param reporting which resolution step
  won -- the stamp's via= is exactly what the incident forensics were
  missing (a child resolving via=default while the run believed it was
  pinned). Additive; all existing callers use named parameters.
  Set-SapGuiTargetExpectation docs + console lines now state that BOTH
  transports enforce the pair (children inherit the environment, so one
  export covers every RFC call in the same process tree).

Verified offline (NCo stubbed, no logon possible): pre-logon refusal on
system and on client mismatch, via=gui-active provenance through the
[ref], endpoint-match attribution refusing an explicit -Server call
under a bogus expectation, matching expectations passing, and the
no-expectation legacy path untouched. Verified live (32-bit PS,
read-only connect+ping on S4D/100): RFC_TARGET stamps
system=S4D client=100 endpoint=sap1.vicp.cc:70 via=gui-active,
SystemAttributes.SystemID reads 'S4D' on a direct connection, and the
function still returns a clean RfcDestination (the stamp is Write-Host,
not pipeline output). Full log: temp/testReport/
rfc_target_guard_20260806.md (local). npm run validate green.

Docs: gotcha 5 in parallel_safe_session_attach.md grows the RFC side;
CLAUDE.md rows for sap_rfc_lib.ps1 / sap_attach_lib.vbs updated; the
attach-lib header cross-references the RFC mirror.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@KexiHe
KexiHe merged commit 0e0242f into main Aug 6, 2026
1 check passed
KexiHe added a commit that referenced this pull request Aug 6, 2026
…te assert

Get-SapCurrentConnectionProfile's new out-parameter was declared
[ref]$ResolvedVia = $null (PR #9). Windows PowerShell 5.1 throws
ParameterArgumentTransformationError converting the $null default for every
caller that omits the argument; pwsh 7 accepts it -- the fourth instance of
the works-in-pwsh-7 portability class. Connect-SapRfc passes the argument
and kept working, so read paths looked healthy while every write skill's
Rule 0 assert under 32-bit PS 5.1 (the production driver shape) threw,
was caught, and reported a false "no pin, no default, no single-profile
bootstrap" refusal against a valid pin.

The parameter is now untyped (callers still pass ([ref]$var)) and all four
assignments guard on -is [ref]. Verified live on PS 5.1 and pwsh 7 with a
pinned S4D/100 profile: assert returns SAFETY: ALLOW on both.

The bug failed closed -- it refused work it should have allowed, never the
reverse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant