[AAASM-5529] ✅ (tests): Add enforcement-truth negative controls to the quick-start - #352
Merged
Chisanan232 merged 9 commits intoAug 6, 2026
Conversation
Real, externally-observable side effects (a file on disk, a live loopback HTTP listener) plus a policy-driven GatewayClient that records the identity triple each decision was made against. Existing deny tests assert over vi.fn() spies, which prove the SDK did not call a reference it holds — not that the effect the tool exists to produce was prevented. Refs AAASM-5529, Epic AAASM-5526
Pairs a positive control (allow -> the file exists with the written content) with the negative control (deny -> nothing on disk) and a falsification case running the same write ungoverned. The side-effect assertion runs before the error assertion so removing the deny fails the suite on the absence check, not on "no error was thrown". Refs AAASM-5529, Epic AAASM-5526
A real loopback listener records every request it receives, so the deny is asserted as zero deliveries rather than as a raised exception. The positive control on the same fixture establishes the listener was reachable, which is what makes the empty request log evidence of prevention. Refs AAASM-5529, Epic AAASM-5526
AAASM-5529 requires deny evidence to be attributable: the fixture gateway records the identity triple it decided against, and the control checks the recorded agent id, tool name, action and run id alongside the absent side effect. An anonymous refusal is not usable audit evidence. Refs AAASM-5529, Epic AAASM-5526
A tool exposing neither execute nor invoke has no seam for withAssembly to wrap (AAASM-4847). The control checks both halves of that: the SDK warns on stderr, and the tool's side effect really does occur under a deny policy with no decision ever recorded — so the warning is load-bearing, not cosmetic. Refs AAASM-5529, Epic AAASM-5526
The README quickstart config (no mode, no enforcementMode, langchain.tools) refuses to init rather than registering under an allow-all check — assert that, plus the observe opt-out really passing the tool body through. Without the second, the refusal is indistinguishable from "this path never works", and a reader cannot tell an advisory posture from an enforcing one. Refs AAASM-5529, Epic AAASM-5526, AAASM-4991
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The audit-evidence control asserted `toBeInstanceOf(PolicyViolationError)` before its `effect.occurred()` check. A failed assertion aborts the test, so under a mutation that neuters the deny this control failed on the missing exception and its load-bearing absence assertion was never exercised — the control had never been shown to bite. The two sibling controls in this file already asserted absence first; this one was missed. Refs AAASM-5529, Epic AAASM-5526
…real gap `expect(decision.agentId).toBe(AGENT_ID)` could not fail. The fixture set that field from its own constructor argument, so it compared the test's constant with itself — a probe passing withAssembly `agentId: "TOTALLY-DIFFERENT-AGENT"` still observed `"FIXTURE-AGENT"`. The SDK supplies no agent identity at all on the check path: `WithAssemblyOptions.agentId` is declared and never read (the only `options.` reads in with-assembly.ts are gatewayClient, approvalTimeoutMs and opControl), and `GatewayCheckRequest` has no field to carry one. The outbound request keys are exactly action/args/runId/toolName. Drop the fixture's `agentId` option so it can no longer echo back a value the SDK never sent, record the verbatim outbound requests instead, and pin today's real behaviour over them. The pin fails if an agent identity is ever added, and says in its failure message that it must then be rewritten rather than deleted. Refs AAASM-5529, Epic AAASM-5526
The afterEach drained the queue and awaited each entry in a bare loop, so the first throwing cleanup aborted the iteration and skipped every remaining one. The leaked temp dirs are merely untidy, but the leaked loopback HTTP listeners keep open handles that hang the vitest worker — turning one cleanup failure into a stalled run. Settle them all, then rethrow the first error. Refs AAASM-5529, Epic AAASM-5526
|
Chisanan232
deleted the
v0.0.1-rc.7/AAASM-5529/enforcement_negative_controls
branch
August 6, 2026 12:34
28 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Target
Task summary:
Add enforcement-truth negative controls to the documented Node quick-start (AAASM-5529, Epic AAASM-5526, Goal CBLPCRLM-13).
A negative control must prove a denial prevented a side effect, not that a
PolicyViolationErrorwas thrown. All 62 existingnot.toHaveBeenCalled()assertions intests/prove the SDK did not call a reference it holds — not that the effect the tool exists to produce was prevented, and not that the tool was capable of producing one at all.Each control drives
withAssembly— the enforcement point the quick-start documents (docs/02-quick-start/index.md:105-114) — over a tool with a real, externally-observable effect (a file on disk, an HTTP request delivered to a live loopback listener), and asserts the deny as the absence of that effect, paired with a positive control that observes it. The side-effect assertion runs before the error assertion so that removing enforcement fails the suite on the absence check, not on "no error was thrown".Task tickets:
Key point change:
Falsification evidence
Produced by disabling the deny in the SDK (not in the tests) and re-running.
vitestresolvessrc/, notdist/, so the mutation bites without a rebuild:Before the fix — note the third control's failure line and location:
After the fix — the same mutation, all three now failing on the side-effect assertion, with a materially different message and location for the third:
All four controls that assert an absence now check it before any error assertion (
:119,:168,:200,:277); a sweep of the file confirms no other site had the inverted order. Source restored inside the same uninterruptible apply→run→restore sequence, verified by a positive grep that the real guard body and itsthroware back — not merely that the mutation marker is gone — withgit status --porcelainempty.The agent-identity assertion was tautological — removed, not quietly repaired
Review also found that
expect(decision?.agentId).toBe(AGENT_ID)could not fail. The fixture populatedRecordedCheck.agentIdfrom its own constructor argument, so the assertion compared the constant the test passed with itself. An executable probe confirms it: with the fixture built asFIXTURE-AGENTandwithAssemblyhandedagentId: "TOTALLY-DIFFERENT-AGENT", the recorded value was still"FIXTURE-AGENT", and the verbatim outbound request carried no agent field at all:The SDK does not attribute a policy check to an agent.
WithAssemblyOptionsdeclaresagentId(src/wrappers/with-assembly.ts:25) and no code path reads it — the onlyoptions.reads in that file aregatewayClient,approvalTimeoutMsandopControl— andGatewayCheckRequest(src/types/gateway-governance.ts:1-6) has no field to carry one. The documented quick-start passesagentId: "langchain-js-example-agent"(docs/02-quick-start/index.md:140) and the SDK discards it.Sending
agentIdinGatewayCheckRequestwould change an outbound wire contract, which is an owner decision and not something to fold into a test-only PR. So the assertion is deleted, and the real behaviour pinned in its place: the fixture no longer accepts anagentIdit could only echo back, it records the verbatim outboundGatewayCheckRequests, and the test asserts their exact key set. That pin fails the moment an agent identity is added, and its failure message says the gap is closed and the test must be rewritten, not deleted. Verified by mutating the SDK to send one:The underlying gap — no agent attribution on the check path, and this ticket's audit-evidence AC being satisfied against the outbound
CheckRequestrather than against audit evidence — is real and unfixed, and is being filed separately for the Epic owner. It is not addressed here.Findings — the AAASM-4991 defect is narrower than stated, and the README quick-start is broken
Derived from source and confirmed by an executable probe on this branch. Each answer carries a known-present positive control.
1. The documented README quick-start (
README.md:89-105) does not silently allow — it does not start. With nomodeand noenforcementMode,resolveFailClosed(undefined) === true(src/types/enforcement-mode.ts:36), the resolved mode is"auto"≠CHECK_CAPABLE_MODE(src/core/init-assembly.ts:144), nogatewayClientis supplied, andlangchain.toolsis non-empty — so the AAASM-4735 guard atsrc/core/init-assembly.ts:590-606throwsConfigurationError. Probe, running the README snippet verbatim:The README quick-start is therefore non-functional as written — a separate defect from AAASM-4991, and arguably more user-visible. Not fixed here (docs are another lane's scope); pinned by a test instead.
2. Configurations that reach
createNoopGatewayClientand get{denied:false}with no throw: explicitenforcementMode: "observe"or"disabled", in any non-napi-inprocessmode. Probe:These are documented advisory postures, so passing through is correct — but nothing at init or call time says "no policy decision can block here". The
autoDetectedToolsRouteThroughNoopwarning is gated onresolveFailClosed(src/core/init-assembly.ts:427-434) and so is silent under exactly these postures.3. The two no-ops are genuinely distinct, and the distinction matters.
createNoopGatewayClient(src/gateway/client.ts:34-46(allow-allcheckat:40)) — the default gateway client for every mode exceptnapi-inprocess(src/core/init-assembly.ts:226). Itscheck()is the allow-all on the tool path.buildStubClient(src/native/client.ts:286-296) — a binding-load-failure fallback, returned only whenloadNativeBinding()throws and the mode is notnapi-inprocess(src/native/client.ts:447-452). Undernapi-inprocessa load failure throwsNativeConnectError(:442). Probe:napi-inprocess -> THREW NativeConnectError ; grpc-sidecar -> canRegister=false queryPolicy={"denied":false,"pending":false}.Because
createClientonly builds a native-backed gateway client fornapi-inprocess, and that mode never yields the stub, the stub's allow-allqueryPolicyis not reachable from the tool-check path — it serves registration/events. Conflating the two overstates the defect.4. Auto-detected frameworks still warn without throwing — and this remains the dangerous path. Bare
initAssembly()withai/@openai/agentspresent:So:
process.stderr.write, notconsole.warn; loud; and no throw by design (AAASM-1847 / AAASM-4769). A policy DENY cannot block an auto-detected framework's tool on the default path.Additional defect found while probing:
activeAdaptersreports["langchain-js","vercel-ai-sdk","openai-agents","langgraph-js"]even though the Vercel patch demonstrably failed (frozen-ESM, warned in the same run).buildActiveAdapters(src/core/init-assembly.ts:540-555) unionsadapters.map(a => a.id)— every detected framework — with the successful patch flags, so detection alone is enough to appear "active". That is a programmatic surface reporting protection that does not exist, i.e. exactly the AAASM-5526 attestation problem. Not in this ticket's scope; flagged for the Epic owner.Net for this ticket: the negative controls pass on the enforcement path the quick-start documents (
withAssembly+ a caller-suppliedgatewayClient). No control was weakened, skipped, or marked expected-to-fail. The paths that cannot satisfy a negative control (observe/disabled, auto-detected frameworks) are named and pinned rather than tuned away.Effecting Scope
Test-only. No
src/file is modified.Description
tests/helpers/negative-control.ts— reusable fixture: a real filesystem effect, a real loopback HTTP listener that records deliveries, and a policy-drivenGatewayClientthat records both the verbatim outboundGatewayCheckRequests and the resulting decisions. It deliberately accepts noagentId: the SDK sends none, so a fixture that took one could only hand it straight back.tests/quickstart-negative-control.test.ts— 10 tests (see the table below). ItsafterEachalso had a real leak, fixed ina1e9bc0: it drained the cleanup queue and awaited each entry in a bare loop, so the first throwing cleanup skipped every remaining one — leaking temp dirs and, worse, live loopback HTTP listeners whose open handles hang the vitest worker. Every cleanup is now settled before the first error is rethrown.toolName,actionand arun_-prefixed run id — and no agent id, pinned as the exact outboundGatewayCheckRequestkey set so the gap surfaces the moment it is closedexecute/invokeis warned about (will NOT be governed) and its effect really occurs under a deny policy with no decision recorded — the warning is load-bearing, not cosmeticConfigurationError, message containsallow-all no-op);enforcementMode: "observe"inits and does run the tool bodyAcceptance-criteria mapping: allow/deny side effects end to end (filesystem, network); deny asserts the body did not execute rather than that an error was logged (side-effect assertion, placed first); tool identity in the deny record (deny attribution group) — agent identity is NOT satisfied: the SDK attributes no agent to a check, so that AC is pinned as an open gap rather than claimed, and filed separately; no-op / failed-adapter paths cannot display a protected state (ungoverned seam + zero-config boundary); callback-only vs wrapper paths (the wrapper is the only enforcement point exercised — the callback layer stays audit-only per AAASM-4799). Not covered and reported rather than dropped: the clean-environment CI job running the quick-start, and drift-gating the doc snippets against these fixtures.
Validation
pnpm exec vitest run tests/quickstart-negative-control.test.tspnpm testpnpm lintpnpm typecheck(tsc --noEmit)pnpm exec prettier --check(the two files this PR touches)Lint, typecheck and the target suite were re-run at every commit, so the branch is bisectable.
One honesty note on the format gate: repo-wide
pnpm exec prettier --check .exits 1 on this branch, but that is pre-existing and unrelated — it flags 95 files includingsrc/index.ts,src/runtime.tsand ~48 test files this PR never touches, while both files it does touch pass individually. Noformat:checkscript exists and no workflow runsprettier --check(onlyformat: prettier --write .), so reformatting 95 files was left out of scope rather than folded into a test-only PR.