@openwop/openwop-conformance 1.141.0 → 1.144.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.
- package/README.md +2 -2
- package/dist/lib/spec-coherence.js +36 -9
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/capabilities.schema.json +4 -0
- package/src/lib/spec-coherence-registry.test.ts +22 -1
- package/src/lib/spec-coherence.ts +37 -9
- package/src/scenarios/auth-subject-link.test.ts +152 -0
- package/src/scenarios/tool-hooks-failure-honesty.test.ts +62 -0
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
94
94
|
|
|
95
95
|
## What's Covered
|
|
96
96
|
|
|
97
|
-
The current suite has
|
|
97
|
+
The current suite has 467 scenario files under `src/scenarios/`.
|
|
98
98
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
99
99
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
100
100
|
- 2026-08-18 (suite `1.136.7 → 1.136.8`, SP-04): NEW `spec-section-citations.test.ts` — server-free; a `<doc>.md §"<Section>"` citation of a checked doc MUST resolve to a heading that exists. `storage-adapters.md §"Claim acquisition"` was cited by four artifacts, including a normative MUST in `production-profile.md` §Durability, and the section did not exist for the life of RFC 0009. Scoped to the docs whose section citations carry the durability contract; a corpus-wide sweep finds ~260 unresolved citations that need triage before they can gate.
|
|
@@ -438,7 +438,7 @@ Server-required (added in 1.7.0):
|
|
|
438
438
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
439
439
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
440
440
|
|
|
441
|
-
Current source tree:
|
|
441
|
+
Current source tree: 467 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
442
442
|
|
|
443
443
|
## Remaining Gaps
|
|
444
444
|
|
|
@@ -55,16 +55,29 @@
|
|
|
55
55
|
* run and fails when the two disagree, which is the only thing that makes a
|
|
56
56
|
* hand-maintained set trustworthy.
|
|
57
57
|
*
|
|
58
|
-
* ## What is deliberately NOT here
|
|
58
|
+
* ## What is deliberately NOT here — and why it is a Set, not a sentence
|
|
59
59
|
*
|
|
60
|
-
* Seven scenarios gate on `V1_DIR` **and** drive the host
|
|
61
|
-
*
|
|
62
|
-
* `
|
|
63
|
-
* `
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
60
|
+
* Seven scenarios gate on `V1_DIR` **and** drive the host. They assert
|
|
61
|
+
* advertised host behaviour that could not be exercised because a dependency
|
|
62
|
+
* was unavailable — `blocked`, exactly as §A defines it. Classifying them
|
|
63
|
+
* `inapplicable` would tell a host "this does not apply to you" about a
|
|
64
|
+
* requirement that does.
|
|
65
|
+
*
|
|
66
|
+
* They are listed in `SPEC_COHERENCE_EXCLUDED` below rather than named in this
|
|
67
|
+
* prose, because naming them here made this file lie to a reasonable reader.
|
|
68
|
+
* A peer checking membership with `grep -c "<scenario>" spec-coherence.ts` got
|
|
69
|
+
* **1 hit for all three** of the ones the prose named — from this very
|
|
70
|
+
* paragraph — and was one step from reporting that host-behaviour rows had
|
|
71
|
+
* been downgraded to "does not apply to you" as a credit. They caught it only
|
|
72
|
+
* because two counts disagreed: 28 members and those 7 included cannot both be
|
|
73
|
+
* true.
|
|
74
|
+
*
|
|
75
|
+
* **A text search over this file still matches both sets** — that is inherent
|
|
76
|
+
* to any file that names what it excludes. So the exclusions are now an
|
|
77
|
+
* exported Set with the same standing as the inclusions: membership has a
|
|
78
|
+
* programmatic answer, `spec-coherence-registry.test.ts` asserts the two are
|
|
79
|
+
* disjoint and jointly exhaustive over the `V1_DIR`-gated files, and a comment
|
|
80
|
+
* is no longer the only place the exclusion reason lives.
|
|
68
81
|
*/
|
|
69
82
|
/** Scenarios whose subject is the corpus. Kept honest by `spec-coherence-registry.test.ts`. */
|
|
70
83
|
export const SPEC_COHERENCE_SCENARIOS = new Set([
|
|
@@ -97,6 +110,20 @@ export const SPEC_COHERENCE_SCENARIOS = new Set([
|
|
|
97
110
|
'workflow-chain-internal-flag.test.ts',
|
|
98
111
|
'workload-identity-profile.test.ts',
|
|
99
112
|
]);
|
|
113
|
+
/**
|
|
114
|
+
* Scenarios that gate on `V1_DIR` **and** drive the host, so their `blocked` is
|
|
115
|
+
* honest: advertised behaviour a missing dependency prevented exercising.
|
|
116
|
+
* Exported so membership is checkable in code rather than inferred from prose.
|
|
117
|
+
*/
|
|
118
|
+
export const SPEC_COHERENCE_EXCLUDED = new Set([
|
|
119
|
+
'artifact-type-registration-source.test.ts',
|
|
120
|
+
'artifact-type-store-emission.test.ts',
|
|
121
|
+
'data-residency-admission.test.ts',
|
|
122
|
+
'profile-discovery-core-alias.test.ts',
|
|
123
|
+
'replay-side-effect-suppression.test.ts',
|
|
124
|
+
'workflow-chain-deferred-parameters.test.ts',
|
|
125
|
+
'workflow-variable-format.test.ts',
|
|
126
|
+
]);
|
|
100
127
|
/** The reason recorded on such a row, written for the host operator reading it. */
|
|
101
128
|
export const SPEC_COHERENCE_DETAIL = 'inapplicable to any host: this scenario reads spec/v1/ to check the SPEC corpus is internally coherent and asserts nothing about a host. '
|
|
102
129
|
+ 'The published tarball does not bundle spec/v1/ (see lib/paths.ts), so it does not run here. '
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.144.0",
|
|
4
|
+
"corpusCommit": "1aca8bb8ebdfaf23b33283f9c313aacfebfd4f07"
|
|
5
5
|
}
|
|
@@ -3691,6 +3691,10 @@
|
|
|
3691
3691
|
"uniqueItems": true,
|
|
3692
3692
|
"description": "Auth profiles the host claims. Canonical ids: `openwop-audit-log-integrity` (auth-profiles.md \u00a7Audit-log integrity), `openwop-auth-api-key-rotation`, `openwop-auth-oauth2-client-credentials`, `openwop-auth-oidc-user-bearer`, `openwop-auth-mtls`, `openwop-auth-saml` + `openwop-auth-scim` + `openwop-auth-ldap` (RFC 0050 enterprise identity). Clients SHOULD tolerate unknown profile ids."
|
|
3693
3693
|
},
|
|
3694
|
+
"subjectLinking": {
|
|
3695
|
+
"type": "boolean",
|
|
3696
|
+
"description": "RFC 0159 (auth-profiles.md \u00a7Subject linking). When true, this host advertises BOTH `openwop-auth-saml` and `openwop-auth-scim` AND honours the cross-profile subject-linking obligation: it links the SAML and SCIM principals of one human on an opaque, IdP-stable subject id (SCIM `externalId` \u2194 persistent SAML `NameID`), same-tenant only, and on SCIM deactivation fail-closes the linked SAML identity too (composing RFC 0049 \u00a7C) \u2014 so a provisioned leaver cannot still SSO in. The link key MUST NOT be a mutable/PII attribute (email, userName). Optional; absent/false means the SAML and SCIM lanes are independent and the combined leaver guarantee is NOT claimed. A host MUST NOT set this true unless both profiles are advertised and the obligation holds."
|
|
3697
|
+
},
|
|
3694
3698
|
"rotation": {
|
|
3695
3699
|
"type": "object",
|
|
3696
3700
|
"description": "API-key rotation advertisement (auth-profiles.md \u00a7`openwop-auth-api-key-rotation`).",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { describe, expect, it } from 'vitest';
|
|
15
15
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
-
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
17
|
+
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL, SPEC_COHERENCE_EXCLUDED } from './spec-coherence.js';
|
|
18
18
|
import { resolveFileRecord } from './scenario-disposition.js';
|
|
19
19
|
|
|
20
20
|
const SCENARIOS = new URL('../scenarios/', import.meta.url).pathname;
|
|
@@ -41,6 +41,27 @@ describe('SPEC_COHERENCE_SCENARIOS is derivable, not asserted', () => {
|
|
|
41
41
|
expect(listed.filter((f) => !pure.includes(f)), 'in the registry but no longer qualifies — it now drives a host, or stopped reading spec/v1').toEqual([]);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
it('the EXCLUDED set is exactly the host-touching ones — the exclusions are checked, not asserted in prose', () => {
|
|
45
|
+
// These used to live only in a docblock sentence. A peer grepping this file
|
|
46
|
+
// for membership matched that sentence and read all three named scenarios
|
|
47
|
+
// as members — the opposite of the truth, and the dangerous direction: it
|
|
48
|
+
// would mean host-behaviour rows downgraded to "does not apply to you" as a
|
|
49
|
+
// credit. Naming them in prose made the file lie to a reasonable reader.
|
|
50
|
+
const { hostTouching } = derive();
|
|
51
|
+
expect([...SPEC_COHERENCE_EXCLUDED].sort()).toEqual(hostTouching);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('the two sets are disjoint and jointly exhaustive over the V1_DIR-gated files', () => {
|
|
55
|
+
// Disjoint: no scenario can be both "does not apply to any host" and
|
|
56
|
+
// "applies but was unwitnessable". Exhaustive: every V1_DIR-gated file has
|
|
57
|
+
// a decided disposition, so none falls back to the unclassified marker.
|
|
58
|
+
const { pure, hostTouching } = derive();
|
|
59
|
+
const overlap = [...SPEC_COHERENCE_SCENARIOS].filter((f) => SPEC_COHERENCE_EXCLUDED.has(f));
|
|
60
|
+
expect(overlap, 'a scenario cannot be both inapplicable-to-all-hosts and blocked-for-this-host').toEqual([]);
|
|
61
|
+
const union = new Set([...SPEC_COHERENCE_SCENARIOS, ...SPEC_COHERENCE_EXCLUDED]);
|
|
62
|
+
expect([...union].sort()).toEqual([...pure, ...hostTouching].sort());
|
|
63
|
+
});
|
|
64
|
+
|
|
44
65
|
it('excludes the host-touching ones, which are honestly `blocked`', () => {
|
|
45
66
|
// These assert ADVERTISED behaviour that a missing dependency prevented
|
|
46
67
|
// exercising — RFC 0148 §A's definition of `blocked`, verbatim. Calling
|
|
@@ -55,16 +55,29 @@
|
|
|
55
55
|
* run and fails when the two disagree, which is the only thing that makes a
|
|
56
56
|
* hand-maintained set trustworthy.
|
|
57
57
|
*
|
|
58
|
-
* ## What is deliberately NOT here
|
|
58
|
+
* ## What is deliberately NOT here — and why it is a Set, not a sentence
|
|
59
59
|
*
|
|
60
|
-
* Seven scenarios gate on `V1_DIR` **and** drive the host
|
|
61
|
-
*
|
|
62
|
-
* `
|
|
63
|
-
* `
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
60
|
+
* Seven scenarios gate on `V1_DIR` **and** drive the host. They assert
|
|
61
|
+
* advertised host behaviour that could not be exercised because a dependency
|
|
62
|
+
* was unavailable — `blocked`, exactly as §A defines it. Classifying them
|
|
63
|
+
* `inapplicable` would tell a host "this does not apply to you" about a
|
|
64
|
+
* requirement that does.
|
|
65
|
+
*
|
|
66
|
+
* They are listed in `SPEC_COHERENCE_EXCLUDED` below rather than named in this
|
|
67
|
+
* prose, because naming them here made this file lie to a reasonable reader.
|
|
68
|
+
* A peer checking membership with `grep -c "<scenario>" spec-coherence.ts` got
|
|
69
|
+
* **1 hit for all three** of the ones the prose named — from this very
|
|
70
|
+
* paragraph — and was one step from reporting that host-behaviour rows had
|
|
71
|
+
* been downgraded to "does not apply to you" as a credit. They caught it only
|
|
72
|
+
* because two counts disagreed: 28 members and those 7 included cannot both be
|
|
73
|
+
* true.
|
|
74
|
+
*
|
|
75
|
+
* **A text search over this file still matches both sets** — that is inherent
|
|
76
|
+
* to any file that names what it excludes. So the exclusions are now an
|
|
77
|
+
* exported Set with the same standing as the inclusions: membership has a
|
|
78
|
+
* programmatic answer, `spec-coherence-registry.test.ts` asserts the two are
|
|
79
|
+
* disjoint and jointly exhaustive over the `V1_DIR`-gated files, and a comment
|
|
80
|
+
* is no longer the only place the exclusion reason lives.
|
|
68
81
|
*/
|
|
69
82
|
|
|
70
83
|
/** Scenarios whose subject is the corpus. Kept honest by `spec-coherence-registry.test.ts`. */
|
|
@@ -99,6 +112,21 @@ export const SPEC_COHERENCE_SCENARIOS: ReadonlySet<string> = new Set([
|
|
|
99
112
|
'workload-identity-profile.test.ts',
|
|
100
113
|
]);
|
|
101
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Scenarios that gate on `V1_DIR` **and** drive the host, so their `blocked` is
|
|
117
|
+
* honest: advertised behaviour a missing dependency prevented exercising.
|
|
118
|
+
* Exported so membership is checkable in code rather than inferred from prose.
|
|
119
|
+
*/
|
|
120
|
+
export const SPEC_COHERENCE_EXCLUDED: ReadonlySet<string> = new Set([
|
|
121
|
+
'artifact-type-registration-source.test.ts',
|
|
122
|
+
'artifact-type-store-emission.test.ts',
|
|
123
|
+
'data-residency-admission.test.ts',
|
|
124
|
+
'profile-discovery-core-alias.test.ts',
|
|
125
|
+
'replay-side-effect-suppression.test.ts',
|
|
126
|
+
'workflow-chain-deferred-parameters.test.ts',
|
|
127
|
+
'workflow-variable-format.test.ts',
|
|
128
|
+
]);
|
|
129
|
+
|
|
102
130
|
/** The reason recorded on such a row, written for the host operator reading it. */
|
|
103
131
|
export const SPEC_COHERENCE_DETAIL =
|
|
104
132
|
'inapplicable to any host: this scenario reads spec/v1/ to check the SPEC corpus is internally coherent and asserts nothing about a host. '
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-subject-link — RFC 0159: SCIM ⟷ SAML subject linking (the combined
|
|
3
|
+
* leaver contract).
|
|
4
|
+
*
|
|
5
|
+
* Status: DRAFT. RFC 0159 is `Active` (amends RFC 0050). The obligation is
|
|
6
|
+
* documented in `auth-profiles.md` §"Subject linking (SAML ⟷ SCIM)" and is
|
|
7
|
+
* discoverable via `capabilities.auth.subjectLinking`.
|
|
8
|
+
*
|
|
9
|
+
* Advertisement shape runs unconditionally when the flag is set. The
|
|
10
|
+
* cross-lane behavioral legs (a SCIM deactivation fail-closing the linked SAML
|
|
11
|
+
* identity; a mutable-key link never producing a cross-lane pass) are opt-in
|
|
12
|
+
* via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL` (operator-supplied
|
|
13
|
+
* endpoints), following the `auth-scim-profile.test.ts` opt-in precedent.
|
|
14
|
+
* Soft-skips otherwise; soft-skips `blocked` until the host wires the seam.
|
|
15
|
+
*
|
|
16
|
+
* @see RFCS/0159-scim-saml-subject-linking.md
|
|
17
|
+
* @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
24
|
+
|
|
25
|
+
const SAML_PROFILE = 'openwop-auth-saml';
|
|
26
|
+
const SCIM_PROFILE = 'openwop-auth-scim';
|
|
27
|
+
|
|
28
|
+
interface DiscoveryAuth {
|
|
29
|
+
profiles?: string[];
|
|
30
|
+
subjectLinking?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DiscoveryDoc {
|
|
34
|
+
capabilities?: { auth?: DiscoveryAuth };
|
|
35
|
+
extensions?: { auth?: DiscoveryAuth };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readAuth(): Promise<DiscoveryAuth | null> {
|
|
39
|
+
const res = await driver.get('/.well-known/openwop');
|
|
40
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
41
|
+
return capabilityFamily<DiscoveryAuth>(body, 'auth') ?? body?.extensions?.auth ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('auth-subject-link: advertisement shape (RFC 0159 §B)', () => {
|
|
45
|
+
it('subjectLinking:true is only claimed alongside both SAML and SCIM profiles', async () => {
|
|
46
|
+
const auth = await readAuth();
|
|
47
|
+
if (auth === null || auth.subjectLinking !== true) {
|
|
48
|
+
return softSkip('inapplicable', 'auth.subjectLinking not claimed');
|
|
49
|
+
}
|
|
50
|
+
const profiles = auth.profiles ?? [];
|
|
51
|
+
expect(
|
|
52
|
+
profiles.includes(SAML_PROFILE) && profiles.includes(SCIM_PROFILE),
|
|
53
|
+
driver.describe(
|
|
54
|
+
'auth-profiles.md §Subject linking',
|
|
55
|
+
'a host MUST NOT set capabilities.auth.subjectLinking:true unless BOTH openwop-auth-saml and openwop-auth-scim are advertised',
|
|
56
|
+
),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('auth-subject-link: cross-lane deactivation (RFC 0159 §A.3 — opt-in)', () => {
|
|
62
|
+
const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
|
|
63
|
+
const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
|
|
64
|
+
|
|
65
|
+
it('a SCIM deactivation fail-closes the linked SAML identity', async () => {
|
|
66
|
+
const auth = await readAuth();
|
|
67
|
+
if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
|
|
68
|
+
if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
|
|
69
|
+
|
|
70
|
+
// 1. Provision a SCIM user carrying an opaque, IdP-stable externalId.
|
|
71
|
+
const externalId = 'idp-op-8f3a';
|
|
72
|
+
const provision = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
73
|
+
scimUrl,
|
|
74
|
+
op: 'create-user',
|
|
75
|
+
externalId,
|
|
76
|
+
userName: 'r.smith',
|
|
77
|
+
});
|
|
78
|
+
if (provision.status === 404) return softSkip('blocked', 'seam unwired');
|
|
79
|
+
expect(provision.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM provisioning MUST succeed')).toBeLessThan(400);
|
|
80
|
+
|
|
81
|
+
// 2. A valid SAML assertion whose persistent NameID equals the externalId
|
|
82
|
+
// links to the same subject and authenticates.
|
|
83
|
+
const before = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: externalId });
|
|
84
|
+
if (before.status === 404) return softSkip('blocked', 'seam unwired');
|
|
85
|
+
expect(
|
|
86
|
+
(before.json as { authenticated?: boolean } | undefined)?.authenticated,
|
|
87
|
+
driver.describe('auth-profiles.md §Subject linking', 'a valid linked SAML assertion authenticates before deactivation'),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
|
|
90
|
+
// 3. SCIM-deactivate the provisioned user.
|
|
91
|
+
const deactivate = await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'deactivate-user', externalId });
|
|
92
|
+
expect(deactivate.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM deactivation MUST succeed')).toBeLessThan(400);
|
|
93
|
+
|
|
94
|
+
// 4. THE CONTRACT: a subsequent SAML assertion for the linked subject MUST
|
|
95
|
+
// NOT yield an authorized decision (fail-closed across the link).
|
|
96
|
+
const after = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: externalId });
|
|
97
|
+
expect(
|
|
98
|
+
(after.json as { authenticated?: boolean } | undefined)?.authenticated === true,
|
|
99
|
+
driver.describe(
|
|
100
|
+
'auth-profiles.md §Subject linking',
|
|
101
|
+
'RFC 0159 §A.3: after a SCIM deactivation the LINKED SAML identity MUST be denied (a provisioned leaver cannot still SSO in)',
|
|
102
|
+
),
|
|
103
|
+
).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('auth-subject-link: link-key hygiene (RFC 0159 §A.2 — opt-in)', () => {
|
|
108
|
+
const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
|
|
109
|
+
const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
|
|
110
|
+
|
|
111
|
+
it('a mutable/PII link key (email) never produces a cross-lane pass', async () => {
|
|
112
|
+
const auth = await readAuth();
|
|
113
|
+
if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
|
|
114
|
+
if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
|
|
115
|
+
|
|
116
|
+
// Attempt to form the cross-lane link on email (a mutable/PII attribute).
|
|
117
|
+
// §A.2 forbids it: the host MUST reject the mutable-key link (4xx) OR
|
|
118
|
+
// decline to apply any cross-lane effect from it. Either way, an email
|
|
119
|
+
// "link" MUST NOT yield an authorized cross-lane pass after a deactivation.
|
|
120
|
+
const link = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
121
|
+
scimUrl,
|
|
122
|
+
op: 'link',
|
|
123
|
+
linkKey: 'email',
|
|
124
|
+
email: 'r.smith@example.test',
|
|
125
|
+
});
|
|
126
|
+
if (link.status === 404) return softSkip('blocked', 'seam unwired');
|
|
127
|
+
|
|
128
|
+
if (link.status >= 400) {
|
|
129
|
+
// Conforming: the host rejected a mutable-key link outright.
|
|
130
|
+
expect(
|
|
131
|
+
link.status,
|
|
132
|
+
driver.describe('auth-profiles.md §Subject linking', 'RFC 0159 §A.2: a link on a mutable/PII key (email) MUST be rejected'),
|
|
133
|
+
).toBeGreaterThanOrEqual(400);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// If the host accepted the request, it MUST NOT have formed a cross-lane
|
|
138
|
+
// link on email: a deactivation via the email "link" MUST NOT deny (or
|
|
139
|
+
// otherwise act on) an unrelated SAML subject, and MUST NOT let a mutable
|
|
140
|
+
// key authorize one. Probe: deactivate by email, then a SAML assertion for
|
|
141
|
+
// a DIFFERENT opaque subject MUST be unaffected by the email operation.
|
|
142
|
+
await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'deactivate-user', email: 'r.smith@example.test' });
|
|
143
|
+
const other = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: 'idp-op-DIFFERENT' });
|
|
144
|
+
expect(
|
|
145
|
+
(other.json as { linkedDenied?: boolean } | undefined)?.linkedDenied === true,
|
|
146
|
+
driver.describe(
|
|
147
|
+
'auth-profiles.md §Subject linking',
|
|
148
|
+
'RFC 0159 §A.2: a mutable-key (email) operation MUST NOT drive a cross-lane deny on any opaque subject',
|
|
149
|
+
),
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-hooks-failure-honesty — RFC 0064 §F. A tool failure MUST be
|
|
3
|
+
* self-describing on the wire: a non-success `agent.toolReturned` MUST carry a
|
|
4
|
+
* failure discriminator — `error` populated (the `_errorObject`) and, when the
|
|
5
|
+
* host advertises `prePostEvents`, `status: 'error'` — and MUST NOT be
|
|
6
|
+
* represented as a bare/empty success. Closes the gap where a failed tool is
|
|
7
|
+
* indistinguishable from one that succeeded-empty (host-program WFAU-4).
|
|
8
|
+
*
|
|
9
|
+
* Gated on `capabilities.toolHooks.supported` + the tool-hooks seam. Drives the
|
|
10
|
+
* seam's `simulateToolError` arm; a host that advertises tool-hooks but has not
|
|
11
|
+
* wired that arm soft-skips (`blocked`) rather than failing — §F and the seam
|
|
12
|
+
* arm land together, so hosts wire it on adoption (the same soft-skip-until-wired
|
|
13
|
+
* discipline the other tool-hooks scenarios use).
|
|
14
|
+
*
|
|
15
|
+
* @see RFCS/0064-tool-invocation-hooks-and-authorization.md §F
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
20
|
+
import { driver } from '../lib/driver.js';
|
|
21
|
+
import { readToolHooksCap, invokeToolHook } from '../lib/toolHooks.js';
|
|
22
|
+
|
|
23
|
+
describe('tool-hooks-failure-honesty (RFC 0064 §F)', () => {
|
|
24
|
+
it('a failing tool yields a populated error, never a bare success', async () => {
|
|
25
|
+
const cap = await readToolHooksCap();
|
|
26
|
+
if (cap?.supported !== true) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `cap?.supported !== true` returned early');
|
|
27
|
+
const res = await invokeToolHook({ principal: 'core.system', toolName: 'web.search', simulateToolError: true });
|
|
28
|
+
if (res === null) return softSkip('blocked', 'tool-hooks seam absent — soft-skip');
|
|
29
|
+
|
|
30
|
+
const returned = res.toolReturned ?? {};
|
|
31
|
+
const errRaw = returned['error'];
|
|
32
|
+
const err = errRaw != null && typeof errRaw === 'object' ? (errRaw as Record<string, unknown>) : undefined;
|
|
33
|
+
const isErrorStatus = returned['status'] === 'error';
|
|
34
|
+
|
|
35
|
+
// A host that has not wired the §F simulateToolError arm answers with a
|
|
36
|
+
// success (status 'ok' / an outcome). Soft-skip rather than red-light an
|
|
37
|
+
// honest pre-§F host — the prose MUST and this seam arm adopt together.
|
|
38
|
+
if (err === undefined && !isErrorStatus) {
|
|
39
|
+
return softSkip('blocked', 'host has not wired the RFC 0064 §F simulateToolError arm — soft-skip until adopted');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// §F: the failure carries a populated `_errorObject { code, message }`.
|
|
43
|
+
expect(
|
|
44
|
+
err !== undefined && typeof err['code'] === 'string' && (err['code'] as string).length > 0 && typeof err['message'] === 'string',
|
|
45
|
+
driver.describe('RFC 0064 §F', 'a failed tool MUST populate error {code, message} on agent.toolReturned'),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
|
|
48
|
+
// §F: `error` and `outcome` are mutually exclusive.
|
|
49
|
+
expect(
|
|
50
|
+
returned['outcome'] === undefined,
|
|
51
|
+
driver.describe('RFC 0064 §F', 'error and outcome are mutually exclusive on a failed tool-return'),
|
|
52
|
+
).toBe(true);
|
|
53
|
+
|
|
54
|
+
// §F: a host advertising `prePostEvents` MUST also set `status: 'error'`.
|
|
55
|
+
if (cap['prePostEvents'] === true) {
|
|
56
|
+
expect(
|
|
57
|
+
returned['status'] === 'error',
|
|
58
|
+
driver.describe('RFC 0064 §F', 'a host advertising prePostEvents MUST set status:"error" on a tool failure'),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|