From bd2538bc89d13b691bed52d9fd2501bc9b7a6e69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:17:47 +0000 Subject: [PATCH 1/3] feat(lint): connector-credential-literal advisory over a connector's header/env maps Claude-Session: https://claude.ai/code/session_01ADHkmPZY72rbsdkcLx2ZoH Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- .../docs/deployment/validating-metadata.mdx | 2 +- .../build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- packages/lint/src/authoring-rules.ts | 26 +++ packages/lint/src/index.ts | 3 + .../src/lint-connector-credential-literals.ts | 168 ++++++++++++++++++ packages/lint/src/rule-explanations.ts | 35 ++++ packages/lint/src/runtime-gate.ts | 8 + 9 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 packages/lint/src/lint-connector-credential-literals.ts diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 2a62db5b983..0a73ed21e0f 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (60)... + → Running author-time rules (61)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index ea8a79c3dee..bbdbe99145a 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -759,7 +759,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (60)... + → Running author-time rules (61)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 72149b6088c..05d789ed946 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -310,7 +310,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (60)... + → Running author-time rules (61)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index c2b3cb0f278..664b648789c 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -404,7 +404,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (60)... + → Running author-time rules (61)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9f774754e77..d16a599e16f 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -150,6 +150,7 @@ import { validateRuleSchemaFormats } from './validate-rule-schema-formats.js'; import { validateActionLocations } from './validate-action-locations.js'; import { lintFlowPatterns } from './lint-flow-patterns.js'; import { lintFlowCredentialLiterals } from './lint-flow-credential-literals.js'; +import { lintConnectorCredentialLiterals } from './lint-connector-credential-literals.js'; import { lintLivenessProperties } from './lint-liveness-properties.js'; import { lintAutonumberFormats } from './lint-autonumber-formats.js'; import { lintViewRefs } from './lint-view-refs.js'; @@ -1951,6 +1952,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ hint: f.hint, })), }, + // The same predicate over a declarative connector's four header/env maps + // (`providerConfig.headers`, `defaultHeaders`, `transport.headers`, + // `transport.env`) — the maps the connector read path withholds whole, which + // still keep a literal in metadata (ruling 6566912252, letter ①A). Advisory + // for the same reason as the flow entry above, and on the fourth door for + // `connector`: `PUT /meta/connector/:name` is the door a Studio / REST / MCP + // author of a connector has. + { + name: 'lintConnectorCredentialLiterals', + tier: 'advisory', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/lint-connector-credential-literals.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['connector'], + run: (stack) => + lintConnectorCredentialLiterals(stack).map((f) => ({ + severity: f.severity, + rule: f.rule, + where: f.where, + path: f.path, + message: f.message, + hint: f.hint, + })), + }, // The spec-liveness loop on the author side: a property the ledger marks // dead, live-elsewhere or experimental is set hopefully and does nothing // here. Ledger-driven (those verdicts warn on their own; any other row only diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index de575fbc3ad..397856accd4 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -1011,6 +1011,9 @@ export { export { lintFlowCredentialLiterals, FLOW_CREDENTIAL_LITERAL } from './lint-flow-credential-literals.js'; export type { FlowCredentialLiteralFinding } from './lint-flow-credential-literals.js'; export { isCredentialShapedLiteral } from './credential-literal.js'; +// The same predicate over a declarative connector's four header/env maps. +export { lintConnectorCredentialLiterals, CONNECTOR_CREDENTIAL_LITERAL } from './lint-connector-credential-literals.js'; +export type { ConnectorCredentialLiteralFinding } from './lint-connector-credential-literals.js'; export { lintLivenessProperties } from './lint-liveness-properties.js'; // #11624 — the ledger's warn set, as a decision procedure. `os lint` runs the diff --git a/packages/lint/src/lint-connector-credential-literals.ts b/packages/lint/src/lint-connector-credential-literals.ts new file mode 100644 index 00000000000..ddbae112fc9 --- /dev/null +++ b/packages/lint/src/lint-connector-credential-literals.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `connector-credential-literal` — a credential typed into a declarative + * connector's header or environment map as a LITERAL. + * + * ## What it reports + * + * One advisory per credential-shaped literal ({@link isCredentialShapedLiteral}, + * `credential-literal.ts` — the one predicate; this file holds no name list) in + * each of the four maps a provider sends upstream as written: + * + * - `providerConfig.headers`; + * - `providerConfig.defaultHeaders` (`rest`); + * - `providerConfig.transport.headers` (`mcp` over http); + * - `providerConfig.transport.env` (`mcp` over stdio). + * + * They are the four maps the connector read path withholds whole + * (`CONNECTOR_CREDENTIAL_MAP_PATHS`, `@objectstack/spec`'s connector redactor), + * because a map's entries cannot be told apart there. Withholding answers the + * READ; it does not move the value out of metadata. A literal in one of these + * maps is still stored in `sys_metadata`, carried by every build artifact and + * every source checkout of the stack, and rotated only by editing metadata. + * + * ## Why an advisory, and why it names the connector route + * + * The supported home for an outbound credential is the connector's own + * `auth: { type, credentialRef }`, whose reference is resolved through the + * secrets layer when the instance materializes and is never stored in metadata + * (ADR-0097 §3). The `rest` provider and an `mcp` http transport both send the + * resolved credential themselves, so a header literal moves there by shape. + * + * ⛔ A stdio MCP server takes NO `auth`: the provider folds a resolved + * credential into an http transport's headers only, and a stdio server's + * credentials ride `transport.env` (the connector guide says so). So no text + * here may promise that `credentialRef` reaches a stdio server — an author + * cannot follow it. The env finding names the route with its condition: an + * http transport. + * + * The predicate is a heuristic (a key named `token` can hold something that is + * not a secret), so the finding is a `warning`, always: a false positive costs + * the author a line of reading, never a blocked save. The registry entry is + * `tier: 'advisory'`, which `authoring-rule-wiring.test.ts` verifies by reading + * this function's body for an `error` severity. Making it a refusal is a + * triage decision, not an edit here. + * + * ## What a finding carries — and what it must not + * + * The name the value sits under and where it sits; ⛔ never the value. The + * finding travels into CI logs, the runtime gate's server log line and the save + * response, so echoing the literal would copy the credential into three more + * places while telling the author to take it out of one. + */ + +import { isCredentialShapedLiteral } from './credential-literal.js'; +import { recordsOf } from './object-graph.js'; + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +/** The rule id every finding of this rule carries. */ +export const CONNECTOR_CREDENTIAL_LITERAL = 'connector-credential-literal'; + +export interface ConnectorCredentialLiteralFinding { + /** Always a warning — see the module note. The type keeps it that way. */ + severity: 'warning'; + rule: typeof CONNECTOR_CREDENTIAL_LITERAL; + /** `connector 'x'`. */ + where: string; + /** Config path of the entry, e.g. `connectors[0].providerConfig.defaultHeaders.Authorization`. */ + path: string; + message: string; + hint: string; +} + +/** + * Why a literal here is worth a line: the read path withholds the map, but the + * value is still metadata. Why each map is withheld is `os explain + * connector-credential-literal`. + */ +const KEPT = 'kept in metadata instead of the secrets layer'; + +/** The route, short, in the VERDICT as well as in the hint (see the flow rule's note). */ +const STEER_HEADER = "move it to the connector's `auth.credentialRef`"; +const STEER_ENV = 'a stdio server takes no `auth`, so `auth.credentialRef` reaches it only over an http transport'; + +const HEADER_ROUTE = + "Give the entry `auth: { type: 'bearer', credentialRef }` (or `{ type: 'api-key', headerName, credentialRef }`) " + + 'and drop the header: the provider sends the resolved credential itself'; + +const ENV_ROUTE = + "Reach the MCP server over `transport: { kind: 'http', url }` with `auth: { type: 'bearer', credentialRef }`; " + + "a stdio server's credentials ride `transport.env` only"; + +/** One judged map: where it sits under the entry, what an entry of it is called, and its route. */ +interface JudgedMap { + path: readonly string[]; + entry: 'header' | 'env'; +} + +/** + * The four maps, in the order the redactor names them. ⚠️ A mirror of the + * spec's `CONNECTOR_CREDENTIAL_MAP_PATHS`, which the spec does not export; + * `lint-connector-credential-literals.test.ts` holds the two equal by reading + * that list through the redactor's own answer. + */ +const JUDGED_MAPS: readonly JudgedMap[] = [ + { path: ['providerConfig', 'headers'], entry: 'header' }, + { path: ['providerConfig', 'defaultHeaders'], entry: 'header' }, + { path: ['providerConfig', 'transport', 'headers'], entry: 'header' }, + { path: ['providerConfig', 'transport', 'env'], entry: 'env' }, +]; + +/** The judged map paths, dotted, for a test or a caller that names them. */ +export const CONNECTOR_CREDENTIAL_LITERAL_MAPS: readonly string[] = Object.freeze( + JUDGED_MAPS.map((m) => m.path.join('.')), +); + +/** A config-path segment for a map key: `.key` when it is an identifier, `["x-key"]` otherwise. */ +function keySegment(key: string): string { + return /^[A-Za-z_$][\w$]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`; +} + +/** The value at `path` under `entry`, when every step is a record. */ +function mapAt(entry: AnyRec, path: readonly string[]): AnyRec | undefined { + let at: unknown = entry; + for (const step of path) { + if (!isRec(at)) return undefined; + at = at[step]; + } + return isRec(at) ? at : undefined; +} + +/** + * Every credential-shaped literal a connector entry carries in one of its four + * header/env maps, one advisory each. Pure; never throws on a malformed stack + * (a non-record connector or map is skipped, not dereferenced). + */ +export function lintConnectorCredentialLiterals(stack: AnyRec): ConnectorCredentialLiteralFinding[] { + const findings: ConnectorCredentialLiteralFinding[] = []; + + recordsOf(stack.connectors).forEach((connector, index) => { + const name = typeof connector.name === 'string' && connector.name ? connector.name : `#${index}`; + for (const judged of JUDGED_MAPS) { + const map = mapAt(connector, judged.path); + if (!map) continue; + const label = judged.path.join('.'); + for (const [key, value] of Object.entries(map)) { + if (!isCredentialShapedLiteral(key, value)) continue; + const steer = judged.entry === 'env' ? STEER_ENV : STEER_HEADER; + findings.push({ + severity: 'warning', + rule: CONNECTOR_CREDENTIAL_LITERAL, + where: `connector '${name}'`, + path: `connectors[${index}].${label}${keySegment(key)}`, + // One verdict sentence; why the map is withheld on read but still + // worth moving, and the auth variants, are + // `os explain connector-credential-literal`. + message: `${judged.entry} '${key}' in ${label} holds a credential-shaped literal, ${KEPT}; ${steer}`, + hint: judged.entry === 'env' ? ENV_ROUTE : HEADER_ROUTE, + }); + } + } + }); + + return findings; +} diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index eaa9868844a..04f48b6bf02 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -4837,6 +4837,40 @@ const FLOW_CREDENTIAL_LITERAL_EXPLANATION: RuleExplanation = { ], }; +// ── Connector credential literals (`lint-connector-credential-literals.ts`) ── + +const CONNECTOR_CREDENTIAL_LITERAL_EXPLANATION: RuleExplanation = { + rule: 'connector-credential-literal', + covers: 'which connector maps are judged, and where a credential goes', + paragraphs: [ + // The four maps are held to the rule and to the connector redactor by the rule's test. + 'A declarative connector entry carries four maps a provider sends upstream as written: ' + + '`providerConfig.headers`, `providerConfig.defaultHeaders` (the `rest` provider), ' + + '`providerConfig.transport.headers` (an `mcp` http transport) and `providerConfig.transport.env` ' + + '(an `mcp` stdio transport). The metadata read path withholds each of them whole, because one ' + + 'entry of a map cannot be told from another, but withholding answers the read only: a literal ' + + 'credential in one of them is still stored in metadata, carried by every build artifact and ' + + 'source checkout of the stack, and rotated only by editing metadata.', + 'The supported home for an outbound credential is the connector\'s own `auth`, whose ' + + '`credentialRef` names a secrets-layer reference resolved when the instance materializes (an ' + + 'environment variable in the open tier) and never stored in metadata (ADR-0097 §3). The `rest` ' + + 'provider and an `mcp` http transport send the resolved credential themselves, so a header ' + + 'literal moves by shape: an `Authorization` bearer value to `auth: { type: \'bearer\', credentialRef }`, ' + + 'a key header to `{ type: \'api-key\', headerName, credentialRef }`, a Basic pair to ' + + '`{ type: \'basic\', username, credentialRef }`.', + 'A stdio MCP server takes no `auth`: the provider folds a resolved credential into an http ' + + 'transport\'s headers only, and a stdio server\'s credentials ride `transport.env`. So the env ' + + 'finding names `auth.credentialRef` with its condition, an http transport, and promises nothing ' + + 'for stdio. The connector guide is content/docs/automation/connectors.mdx.', + 'Always a warning, and nothing is refused: the predicate is a heuristic (a value under a name ' + + 'that reads as a credential, or a value that opens with an auth scheme), so a key named ' + + '`token` can hold something that is not a secret, and a false positive costs a line of ' + + 'reading, never a blocked save. A `{variable}` template is not a literal and draws nothing. ' + + 'The finding names where the value sits and never the value itself, because it travels into ' + + 'CI logs, the runtime gate\'s server log line and the save response.', + ], +}; + // ── AI agent ↔ skill ↔ tool references (`validate-ai-surface-affinity.ts`, `validate-ai-tool-references.ts`) ── const AI_SKILL_SURFACE_MISMATCH_EXPLANATION: RuleExplanation = { @@ -5604,6 +5638,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [PAGE_SOURCE_CLASSNAME_TAILWIND_EXPLANATION.rule]: PAGE_SOURCE_CLASSNAME_TAILWIND_EXPLANATION, [PAGE_VISUALIZATION_WITHOUT_BINDING_EXPLANATION.rule]: PAGE_VISUALIZATION_WITHOUT_BINDING_EXPLANATION, [FLOW_CREDENTIAL_LITERAL_EXPLANATION.rule]: FLOW_CREDENTIAL_LITERAL_EXPLANATION, + [CONNECTOR_CREDENTIAL_LITERAL_EXPLANATION.rule]: CONNECTOR_CREDENTIAL_LITERAL_EXPLANATION, [AI_SKILL_SURFACE_MISMATCH_EXPLANATION.rule]: AI_SKILL_SURFACE_MISMATCH_EXPLANATION, [AI_SKILL_TOOL_UNRESOLVED_EXPLANATION.rule]: AI_SKILL_TOOL_UNRESOLVED_EXPLANATION, [MANAGED_API_METHOD_UNAFFORDABLE_EXPLANATION.rule]: MANAGED_API_METHOD_UNAFFORDABLE_EXPLANATION, diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index cdb47fb1296..fb3f08909b0 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -265,6 +265,14 @@ const TYPE_TO_STACK_KEY: Readonly> = { // own, resolves nothing into a sibling, and a write's own collection already // holds exactly one member — its own item. datasource: 'datasources', + // [#29341] `connector` — `PUT /meta/connector/:name` publishes a declarative + // connector entry, and `lintConnectorCredentialLiterals` crosses the wall with + // this row. ⛔ NOT a mapping ahead of its rule: that rule opens with + // `recordsOf(stack.connectors)`, the key `ObjectStackDefinitionSchema` gives + // the collection, and the door case in its test builds the snapshot at + // `stackKeyForType('connector')` itself. Not a context row either, for the + // `position` / `app` reason above: the rule judges each entry on its own. + connector: 'connectors', }; /** From 4ab44b97c982843c39ee80fc4da1ed2a9c20c7a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:21:01 +0000 Subject: [PATCH 2/3] test: connector-credential-literal at the save door and the CLI doors; changeset Claude-Session: https://claude.ai/code/session_01ADHkmPZY72rbsdkcLx2ZoH Co-authored-by: Claude --- .../29341-connector-credential-literal.md | 15 + ...ector-credential-literal-doors.e2e.test.ts | 198 ++++++++++++ ...lint-connector-credential-literals.test.ts | 295 ++++++++++++++++++ .../src/lint-connector-credential-literals.ts | 4 +- .../protocol.runtime-authoring-gate.test.ts | 86 +++++ 5 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 .changeset/29341-connector-credential-literal.md create mode 100644 packages/cli/test/connector-credential-literal-doors.e2e.test.ts create mode 100644 packages/lint/src/lint-connector-credential-literals.test.ts diff --git a/.changeset/29341-connector-credential-literal.md b/.changeset/29341-connector-credential-literal.md new file mode 100644 index 00000000000..5722045eb7d --- /dev/null +++ b/.changeset/29341-connector-credential-literal.md @@ -0,0 +1,15 @@ +--- +'@objectstack/lint': patch +--- + +feat(lint): `connector-credential-literal` — `os validate`, `os build`, `os lint` and the connector save door warn on a credential-shaped literal in a connector's header or env map + +Clause-②: no — a warning only; nothing refused, nothing widened +Metadata-protocol: none + +A declarative connector entry's four maps — `providerConfig.headers`, `providerConfig.defaultHeaders`, `providerConfig.transport.headers` and `providerConfig.transport.env` — are withheld whole on the metadata read path, but a literal credential in one of them is still stored in metadata. The same `credential-literal` predicate the `flow-credential-literal` advisory asks now judges those four maps and names each credential-shaped entry by its position (never its value), with the `auth.credentialRef` route: + +- a header entry: move it to the connector's `auth: { type: 'bearer' | 'api-key' | 'basic', credentialRef }`, which the `rest` provider and an `mcp` http transport send themselves; +- a stdio `transport.env` entry: a stdio MCP server takes no `auth`, so the warning says `auth.credentialRef` reaches the server only over an http transport. + +Always a `warning`; nothing is refused and every save lands. The rule runs on all three CLI commands and on the runtime publish gate for `connector` (`PUT /meta/connector/:name`), where it rides the save response's `advisories`. `os explain connector-credential-literal` carries the reasoning. diff --git a/packages/cli/test/connector-credential-literal-doors.e2e.test.ts b/packages/cli/test/connector-credential-literal-doors.e2e.test.ts new file mode 100644 index 00000000000..a3dfa7a25c0 --- /dev/null +++ b/packages/cli/test/connector-credential-literal-doors.e2e.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os validate` and `os lint` — the two CLI doors of the + * `connector-credential-literal` advisory, run for real. + * + * The registry proves the rule is WIRED to all three commands + * (`@objectstack/lint`'s own tests); only running the commands proves each one + * PRINTS it, on the channel an author reads, without failing the run. + * + * Two fixtures: LIT declares three connectors carrying a `Bearer …` literal in + * each of the four maps (a `rest` entry's `headers` and `defaultHeaders`, an + * `mcp` http transport's `headers`, an `mcp` stdio transport's `env`); DARK is + * the same three routed through `auth.credentialRef` with only ordinary + * entries (`Accept: application/json`) left in the maps, and must stay silent. + * Every value is a probe sentinel, not a credential. + * + * Integration tier: it spawns the source CLI (`packages/cli/vitest-tiers.ts`). + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CONNECTOR_CREDENTIAL_LITERAL } from '@objectstack/lint'; +import { childEnv } from './helpers/serve-process.js'; +import { writeDefineStackConfig } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); + +const SENTINELS = [ + 'cli-headers-sentinel', + 'cli-default-headers-sentinel', + 'cli-transport-headers-sentinel', + 'cli-env-sentinel', +] as const; + +const ACCEPT = { Accept: 'application/json' }; + +const connectorsOf = (literal: boolean) => [ + { + name: 'vendor_api', + label: 'Vendor API', + type: 'api', + provider: 'rest', + providerConfig: { + baseUrl: 'https://api.vendor.invalid/v1', + headers: literal ? { ...ACCEPT, Authorization: `Bearer ${SENTINELS[0]}` } : ACCEPT, + defaultHeaders: literal ? { 'X-Vendor-Trace': `Bearer ${SENTINELS[1]}` } : ACCEPT, + }, + ...(literal ? {} : { auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' } }), + }, + { + name: 'search_tools', + label: 'Search Tools', + type: 'api', + provider: 'mcp', + providerConfig: { + transport: { + kind: 'http', + url: 'https://mcp.vendor.invalid/mcp', + headers: literal ? { Authorization: `Bearer ${SENTINELS[2]}` } : ACCEPT, + }, + }, + ...(literal ? {} : { auth: { type: 'bearer', credentialRef: 'SEARCH_TOOLS_TOKEN' } }), + }, + { + name: 'local_tools', + label: 'Local Tools', + type: 'api', + provider: 'mcp', + providerConfig: { + transport: { + kind: 'stdio', + command: 'local-mcp-server', + env: literal ? { MCP_UPSTREAM: `Bearer ${SENTINELS[3]}` } : { LOG_LEVEL: 'info' }, + }, + }, + }, +]; + +const stackOf = (literal: boolean) => ({ + manifest: { + id: `com.example.connector.credential.${literal ? 'lit' : 'dark'}`, + namespace: 'conncred', + version: '1.0.0', + type: 'app', + name: 'Connector credential door probe', + engines: { protocol: '^18' }, + }, + objects: [ + { + name: 'conncred_note', + label: 'Note', + pluralLabel: 'Notes', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name', required: true } }, + }, + ], + connectors: connectorsOf(literal), +}); + +const LIT_PATHS = [ + 'connectors[0].providerConfig.headers.Authorization', + 'connectors[0].providerConfig.defaultHeaders["X-Vendor-Trace"]', + 'connectors[1].providerConfig.transport.headers.Authorization', + 'connectors[2].providerConfig.transport.env.MCP_UPSTREAM', +]; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + process.execPath, + [CLI, ...args], + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +interface Finding { + rule?: string; + path?: string; + severity?: string; + message?: string; +} + +/** `os validate --json`: registry advisories ride `warnings` as finding objects. */ +const validateFindings = (stdout: string): Finding[] => + ((JSON.parse(stdout) as { warnings?: unknown[] }).warnings ?? []).filter( + (w): w is Finding => !!w && typeof w === 'object' && (w as Finding).rule === CONNECTOR_CREDENTIAL_LITERAL, + ); + +/** `os lint --json`: every finding is an `issues` entry. */ +const lintFindings = (stdout: string): Finding[] => + ((JSON.parse(stdout) as { issues?: Finding[] }).issues ?? []).filter((i) => i.rule === CONNECTOR_CREDENTIAL_LITERAL); + +const dirs = new Map<'lit' | 'dark', string>(); + +beforeAll(() => { + for (const kind of ['lit', 'dark'] as const) { + const dir = mkdtempSync(join(tmpdir(), `os-connector-credential-${kind}-`)); + writeDefineStackConfig(dir, stackOf(kind === 'lit')); + dirs.set(kind, dir); + } +}); + +afterAll(() => { + for (const dir of dirs.values()) rmSync(dir, { recursive: true, force: true }); +}); + +describe('connector-credential-literal at the CLI doors', () => { + it('os validate — LIT prints one warning per literal and still passes; DARK prints none', async () => { + const lit = await runCli(['validate', '--json'], dirs.get('lit')!); + expect(lit.code, `validate failed:\n${lit.stdout}\n${lit.stderr}`).toBe(0); + expect((JSON.parse(lit.stdout) as { valid?: boolean }).valid).toBe(true); + const found = validateFindings(lit.stdout); + expect(found.map((f) => f.path)).toEqual(LIT_PATHS); + expect(found.every((f) => f.severity === 'warning')).toBe(true); + // The route is in the message, which is what the text face prints. + expect(found.every((f) => (f.message ?? '').includes('auth.credentialRef'))).toBe(true); + for (const sentinel of SENTINELS) expect(JSON.stringify(found).includes(sentinel), sentinel).toBe(false); + + const dark = await runCli(['validate', '--json'], dirs.get('dark')!); + expect(dark.code, `validate failed:\n${dark.stdout}\n${dark.stderr}`).toBe(0); + expect(validateFindings(dark.stdout)).toEqual([]); + }, 180_000); + + it('os lint — LIT reports one warning per literal and still passes; DARK reports none', async () => { + const lit = await runCli(['lint', '--json'], dirs.get('lit')!); + expect(lit.code, `lint failed:\n${lit.stdout}\n${lit.stderr}`).toBe(0); + expect((JSON.parse(lit.stdout) as { passed?: boolean }).passed).toBe(true); + const found = lintFindings(lit.stdout); + expect(found.map((f) => f.path)).toEqual(LIT_PATHS); + expect(found.every((f) => f.severity === 'warning')).toBe(true); + expect(found.every((f) => (f.message ?? '').includes('auth.credentialRef'))).toBe(true); + for (const sentinel of SENTINELS) expect(JSON.stringify(found).includes(sentinel), sentinel).toBe(false); + + const dark = await runCli(['lint', '--json'], dirs.get('dark')!); + expect(dark.code, `lint failed:\n${dark.stdout}\n${dark.stderr}`).toBe(0); + expect(lintFindings(dark.stdout)).toEqual([]); + }, 180_000); +}); diff --git a/packages/lint/src/lint-connector-credential-literals.test.ts b/packages/lint/src/lint-connector-credential-literals.test.ts new file mode 100644 index 00000000000..d3f8a5f7794 --- /dev/null +++ b/packages/lint/src/lint-connector-credential-literals.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `connector-credential-literal` — the pin set, the controls, and the registry + * entry. + * + * Every value is a probe sentinel, not a credential. + */ + +import { describe, expect, it } from 'vitest'; +import { getMetadataTypeRedactor } from '@objectstack/spec/kernel'; + +import { AUTHORING_COMMANDS, AUTHORING_RULES, authoringRulesFor, runAuthoringRules } from './authoring-rules.js'; +import { + CONNECTOR_CREDENTIAL_LITERAL, + CONNECTOR_CREDENTIAL_LITERAL_MAPS, + lintConnectorCredentialLiterals, +} from './lint-connector-credential-literals.js'; +import { explainRule } from './rule-explanations.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor, stackKeyForType } from './runtime-gate.js'; +import * as barrel from './index.js'; + +/** The probe sentinels. Each names the map it stands in. */ +const S = { + headers: 'probe-headers-sentinel', + defaultHeaders: 'probe-default-headers-sentinel', + transportHeaders: 'probe-transport-headers-sentinel', + env: 'probe-env-sentinel', +} as const; + +/** A `rest` entry with a bearer literal in each of its two header maps, beside a plain `Accept`. */ +const restEntry = () => ({ + name: 'vendor_api', + label: 'Vendor API', + type: 'api', + provider: 'rest', + providerConfig: { + baseUrl: 'https://api.vendor.invalid/v1', + headers: { Authorization: `Bearer ${S.headers}`, Accept: 'application/json' }, + defaultHeaders: { 'X-Vendor-Trace': `Bearer ${S.defaultHeaders}`, Accept: 'application/json' }, + }, +}); + +/** An `mcp` http entry and an `mcp` stdio entry, one bearer literal each. */ +const mcpHttpEntry = () => ({ + name: 'search_tools', + label: 'Search Tools', + type: 'api', + provider: 'mcp', + providerConfig: { + transport: { + kind: 'http', + url: 'https://mcp.vendor.invalid/mcp', + headers: { Authorization: `Bearer ${S.transportHeaders}`, Accept: 'application/json' }, + }, + }, +}); + +const mcpStdioEntry = () => ({ + name: 'local_tools', + label: 'Local Tools', + type: 'api', + provider: 'mcp', + providerConfig: { + transport: { + kind: 'stdio', + command: 'local-mcp-server', + env: { MCP_UPSTREAM: `Bearer ${S.env}`, LOG_LEVEL: 'info' }, + }, + }, +}); + +/** The same three shapes, routed through `auth.credentialRef` and carrying only ordinary entries. */ +const routedStack = () => ({ + connectors: [ + { + ...restEntry(), + providerConfig: { + baseUrl: 'https://api.vendor.invalid/v1', + headers: { Accept: 'application/json' }, + defaultHeaders: { Accept: 'application/json', 'User-Agent': 'objectstack' }, + }, + auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' }, + }, + { + ...mcpHttpEntry(), + providerConfig: { + transport: { kind: 'http', url: 'https://mcp.vendor.invalid/mcp', headers: { Accept: 'application/json' } }, + }, + auth: { type: 'api-key', headerName: 'X-Api-Key', credentialRef: 'SEARCH_TOOLS_KEY' }, + }, + { + ...mcpStdioEntry(), + providerConfig: { transport: { kind: 'stdio', command: 'local-mcp-server', env: { LOG_LEVEL: 'info' } } }, + }, + ], +}); + +const litStack = () => ({ connectors: [restEntry(), mcpHttpEntry(), mcpStdioEntry()] }); + +const LIT_PATHS = [ + 'connectors[0].providerConfig.headers.Authorization', + 'connectors[0].providerConfig.defaultHeaders["X-Vendor-Trace"]', + 'connectors[1].providerConfig.transport.headers.Authorization', + 'connectors[2].providerConfig.transport.env.MCP_UPSTREAM', +]; + +describe('connector-credential-literal — the pin set', () => { + it('a `Bearer …` literal in each of the four maps draws one warning, at its own path', () => { + const findings = lintConnectorCredentialLiterals(litStack()); + expect(findings.map((f) => f.path)).toEqual(LIT_PATHS); + expect(findings.every((f) => f.severity === 'warning')).toBe(true); + expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([CONNECTOR_CREDENTIAL_LITERAL])); + expect(findings.map((f) => f.where)).toEqual([ + "connector 'vendor_api'", + "connector 'vendor_api'", + "connector 'search_tools'", + "connector 'local_tools'", + ]); + }); + + it('a credential NAME alone draws too (the predicate\'s R1), whatever the value opens with', () => { + const stack = { + connectors: [ + { + name: 'named', + providerConfig: { + defaultHeaders: { 'x-api-key': 'probe-named-sentinel' }, + transport: { kind: 'stdio', command: 'x', env: { GITHUB_TOKEN: 'probe-env-named-sentinel' } }, + }, + }, + ], + }; + expect(lintConnectorCredentialLiterals(stack).map((f) => f.path)).toEqual([ + 'connectors[0].providerConfig.defaultHeaders["x-api-key"]', + 'connectors[0].providerConfig.transport.env.GITHUB_TOKEN', + ]); + }); + + it('names the position and the route, and never echoes the value', () => { + const findings = lintConnectorCredentialLiterals(litStack()); + for (const f of findings) { + expect(f.message).toContain('auth.credentialRef'); + expect(f.hint).toContain('credentialRef'); + } + const wire = JSON.stringify(findings); + for (const sentinel of Object.values(S)) expect(wire.includes(sentinel), sentinel).toBe(false); + }); + + it('reads as one sentence per map; the stdio env verdict promises `auth` only over http', () => { + const byPath = new Map(lintConnectorCredentialLiterals(litStack()).map((f) => [f.path, f])); + expect(byPath.get(LIT_PATHS[0])!.message).toBe( + "header 'Authorization' in providerConfig.headers holds a credential-shaped literal, kept in metadata, " + + "not the secrets layer; move it to the connector's `auth.credentialRef`", + ); + expect(byPath.get(LIT_PATHS[3])!.message).toBe( + "env 'MCP_UPSTREAM' in providerConfig.transport.env holds a credential-shaped literal, kept in metadata, " + + 'not the secrets layer; a stdio server takes no `auth`; `auth.credentialRef` reaches it only over http', + ); + for (const f of byPath.values()) { + expect(f.message.includes('\n'), f.path).toBe(false); + expect(f.message.length, f.path).toBeLessThanOrEqual(210); + } + }); +}); + +describe('connector-credential-literal — what draws nothing', () => { + it('a non-credential header (`Accept: application/json`) and an `auth.credentialRef`-routed connector', () => { + expect(lintConnectorCredentialLiterals(routedStack())).toEqual([]); + }); + + it('a template, a blank value and a non-string value are not literals', () => { + const stack = { + connectors: [ + { + name: 'quiet', + providerConfig: { + headers: { Authorization: 'Bearer {{ token }}', 'X-Api-Key': '', Cookie: 42 }, + transport: { kind: 'stdio', command: 'x', env: { API_TOKEN: '${API_TOKEN}' } }, + }, + }, + ], + }; + expect(lintConnectorCredentialLiterals(stack)).toEqual([]); + }); + + it('a credential-shaped value OUTSIDE the four maps is not this rule\'s', () => { + const stack = { + connectors: [ + { + name: 'elsewhere', + providerConfig: { baseUrl: 'https://x.invalid', query: { token: 'probe-off-map-sentinel' } }, + metadata: { headers: { Authorization: 'Bearer probe-off-map-sentinel' } }, + }, + ], + }; + expect(lintConnectorCredentialLiterals(stack)).toEqual([]); + }); + + it('never throws on a malformed stack', () => { + for (const connectors of [undefined, null, 'x', [null, 1, 'x'], [{ providerConfig: 'x' }], [{ providerConfig: { transport: [] } }]]) { + expect(() => lintConnectorCredentialLiterals({ connectors } as Record)).not.toThrow(); + } + }); +}); + +describe('connector-credential-literal — the four maps are the redactor\'s', () => { + it('judges exactly the maps the connector read path withholds whole', () => { + // The spec does not export its map list, so it is read through the + // redactor's own answer: an entry with every map filled with a plain + // value, nothing else credential-shaped, reports the maps it withheld. + const redact = getMetadataTypeRedactor('connector'); + expect(redact, 'no built-in connector redactor').toBeDefined(); + const plain = { Accept: 'application/json' }; + const { redactedKeys } = redact!({ + name: 'probe', + label: 'Probe', + type: 'api', + provider: 'mcp', + auth: { type: 'none' }, + providerConfig: { headers: plain, defaultHeaders: plain, transport: { kind: 'http', url: 'https://x.invalid', headers: plain, env: plain } }, + }); + expect([...redactedKeys].sort()).toEqual([...CONNECTOR_CREDENTIAL_LITERAL_MAPS].sort()); + }); +}); + +describe('connector-credential-literal — registered once, at every door, as an advisory', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'lintConnectorCredentialLiterals'); + + it('is an advisory registry entry on all three commands and the runtime publish door for `connector`', () => { + expect(entry).toBeDefined(); + expect(entry!.tier).toBe('advisory'); + expect([...entry!.commands].sort()).toEqual([...AUTHORING_COMMANDS].sort()); + expect(entry!.surfaces).toContain('runtime-publish'); + expect(entry!.runtimeTypes).toEqual(['connector']); + for (const command of AUTHORING_COMMANDS) { + expect(authoringRulesFor(command).map((r) => r.name)).toContain('lintConnectorCredentialLiterals'); + } + expect(runtimeAuthoringRulesFor('connector').map((r) => r.name)).toContain('lintConnectorCredentialLiterals'); + }); + + it('reaches the CLI commands through the registry as warnings', () => { + for (const command of AUTHORING_COMMANDS) { + const findings = runAuthoringRules(command, { normalized: litStack() }).filter( + (f) => f.rule === CONNECTOR_CREDENTIAL_LITERAL, + ); + expect(findings.map((f) => f.path), command).toEqual(LIT_PATHS); + expect(findings.every((f) => f.severity === 'warning'), command).toBe(true); + } + }); + + it('a connector write lands in the snapshot at the key the rule reads', () => { + expect(stackKeyForType('connector')).toBe('connectors'); + }); + + it('reaches the runtime publish gate for a connector write as advisories, never errors', () => { + const result = runRuntimeAuthoringRules({ type: 'connector', item: restEntry() }); + expect(result.rulesRun).toContain('lintConnectorCredentialLiterals'); + expect(result.errors.filter((f) => f.rule === CONNECTOR_CREDENTIAL_LITERAL)).toEqual([]); + expect(result.advisories.filter((f) => f.rule === CONNECTOR_CREDENTIAL_LITERAL).map((f) => f.path)).toEqual([ + 'connectors[0].providerConfig.headers.Authorization', + 'connectors[0].providerConfig.defaultHeaders["X-Vendor-Trace"]', + ]); + }); + + it('a routed connector write draws none at the runtime gate', () => { + const result = runRuntimeAuthoringRules({ type: 'connector', item: routedStack().connectors[0] }); + expect(result.advisories.filter((f) => f.rule === CONNECTOR_CREDENTIAL_LITERAL)).toEqual([]); + }); + + it('exports the rule and its id from the package entry', () => { + const b = barrel as Record; + expect(b.lintConnectorCredentialLiterals).toBe(lintConnectorCredentialLiterals); + expect(b.CONNECTOR_CREDENTIAL_LITERAL).toBe(CONNECTOR_CREDENTIAL_LITERAL); + }); +}); + +describe('connector-credential-literal — `os explain`', () => { + it('carries what the verdict does not say, and names every judged map', () => { + const explained = explainRule(CONNECTOR_CREDENTIAL_LITERAL); + expect(explained, 'no `os explain connector-credential-literal` entry').toBeDefined(); + const text = explained!.paragraphs.join('\n'); + for (const map of CONNECTOR_CREDENTIAL_LITERAL_MAPS) expect(text, map).toContain(`\`${map}\``); + for (const fact of [ + 'withholds each of them whole', + 'ADR-0097 §3', + 'A stdio MCP server takes no `auth`', + 'a heuristic', + 'A `{variable}` template is not a literal', + 'never the value itself', + ]) { + expect(text, `explanation names ${fact}`).toContain(fact); + } + }); +}); diff --git a/packages/lint/src/lint-connector-credential-literals.ts b/packages/lint/src/lint-connector-credential-literals.ts index ddbae112fc9..bec8d272916 100644 --- a/packages/lint/src/lint-connector-credential-literals.ts +++ b/packages/lint/src/lint-connector-credential-literals.ts @@ -79,11 +79,11 @@ export interface ConnectorCredentialLiteralFinding { * value is still metadata. Why each map is withheld is `os explain * connector-credential-literal`. */ -const KEPT = 'kept in metadata instead of the secrets layer'; +const KEPT = 'kept in metadata, not the secrets layer'; /** The route, short, in the VERDICT as well as in the hint (see the flow rule's note). */ const STEER_HEADER = "move it to the connector's `auth.credentialRef`"; -const STEER_ENV = 'a stdio server takes no `auth`, so `auth.credentialRef` reaches it only over an http transport'; +const STEER_ENV = 'a stdio server takes no `auth`; `auth.credentialRef` reaches it only over http'; const HEADER_ROUTE = "Give the entry `auth: { type: 'bearer', credentialRef }` (or `{ type: 'api-key', headerName, credentialRef }`) " + diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 06e370d180e..3cab4f9576e 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -5215,3 +5215,89 @@ describe('the object save door judges related-list names against the live regist expect(quoteRows(rows)).toEqual([]); }); }); + +// ── #29341: the connector save door carries `connector-credential-literal` ── +// +// `@objectstack/lint`'s own tests prove the rule is registered for `connector` +// writes. This block asks the door's question, through the real +// `saveMetaItem`: does a `PUT /meta/connector/:name` publish carry the +// advisory back on the 2xx, with the row STORED (nothing refused), and stay +// silent on an `auth.credentialRef`-routed entry? Every value is a probe +// sentinel, not a credential. +describe('the connector save door carries connector-credential-literal as an advisory (#29341)', () => { + const CONNECTOR_RULE = 'connector-credential-literal'; + const SENTINELS = ['door-headers-sentinel', 'door-default-headers-sentinel', 'door-transport-sentinel', 'door-env-sentinel']; + + const restConnector = (literal: boolean) => ({ + name: 'vendor_api', + label: 'Vendor API', + type: 'api', + provider: 'rest', + providerConfig: { + baseUrl: 'https://api.vendor.invalid/v1', + headers: literal + ? { Authorization: `Bearer ${SENTINELS[0]}`, Accept: 'application/json' } + : { Accept: 'application/json' }, + defaultHeaders: literal + ? { 'X-Vendor-Trace': `Bearer ${SENTINELS[1]}` } + : { 'User-Agent': 'objectstack' }, + }, + ...(literal ? {} : { auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' } }), + }); + + const mcpConnector = (kind: 'http' | 'stdio') => ({ + name: `tools_${kind}`, + label: 'Tools', + type: 'api', + provider: 'mcp', + providerConfig: { + transport: kind === 'http' + ? { kind, url: 'https://mcp.vendor.invalid/mcp', headers: { Authorization: `Bearer ${SENTINELS[2]}` } } + : { kind, command: 'local-mcp-server', env: { MCP_UPSTREAM: `Bearer ${SENTINELS[3]}` } }, + }, + }); + + const saveConnector = (protocol: any, item: { name: string }, extra: Record = {}) => + protocol.saveMetaItem({ type: 'connector', name: item.name, item, ...extra }); + + const connectorRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'connector'); + + const ofRule = (result: any) => (result.advisories ?? []).filter((a: any) => a.rule === CONNECTOR_RULE); + + it('⭐ LIT — each map\'s literal rides the 2xx as a warning, and the row is stored', async () => { + const { protocol, rows } = makeProtocol(); + const results = [ + await saveConnector(protocol, restConnector(true)), + await saveConnector(protocol, mcpConnector('http')), + await saveConnector(protocol, mcpConnector('stdio')), + ]; + + expect(results.flatMap((r) => ofRule(r).map((a: any) => a.path))).toEqual([ + 'connectors[0].providerConfig.headers.Authorization', + 'connectors[0].providerConfig.defaultHeaders["X-Vendor-Trace"]', + 'connectors[0].providerConfig.transport.headers.Authorization', + 'connectors[0].providerConfig.transport.env.MCP_UPSTREAM', + ]); + for (const advisory of results.flatMap(ofRule)) { + expect(advisory.severity).toBe('warning'); + expect(advisory.message).toContain('auth.credentialRef'); + } + + // Nothing refused: every save landed, as written. + expect(connectorRows(rows).map((r) => r.name).sort()).toEqual(['tools_http', 'tools_stdio', 'vendor_api']); + const stored = JSON.parse(connectorRows(rows).find((r) => r.name === 'vendor_api')!.metadata); + expect(stored.providerConfig.headers.Authorization).toBe(`Bearer ${SENTINELS[0]}`); + + // The advisory rides the save response: it must not copy the credential it asks the author to move. + const wire = JSON.stringify(results.map((r) => r.advisories ?? [])); + for (const sentinel of SENTINELS) expect(wire.includes(sentinel), sentinel).toBe(false); + }); + + it('DARK — an `auth.credentialRef`-routed connector with only ordinary headers draws none', async () => { + const { protocol, rows } = makeProtocol(); + const result = await saveConnector(protocol, restConnector(false)); + expect(ofRule(result)).toEqual([]); + expect(connectorRows(rows).map((r) => r.name)).toEqual(['vendor_api']); + }); +}); From e7f39bdf04e24fac9e42b47335eeef2fe76e5e02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:42:32 +0000 Subject: [PATCH 3/3] chore(changeset): declare the lint entry and save-door advisory as a public-surface widening Claude-Session: https://claude.ai/code/session_01ADHkmPZY72rbsdkcLx2ZoH Co-authored-by: Claude --- .changeset/29341-connector-credential-literal.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/29341-connector-credential-literal.md b/.changeset/29341-connector-credential-literal.md index 5722045eb7d..8a511c345ce 100644 --- a/.changeset/29341-connector-credential-literal.md +++ b/.changeset/29341-connector-credential-literal.md @@ -4,7 +4,8 @@ feat(lint): `connector-credential-literal` — `os validate`, `os build`, `os lint` and the connector save door warn on a credential-shaped literal in a connector's header or env map -Clause-②: no — a warning only; nothing refused, nothing widened +Clause-②: yes (widening) +Three new exports on the `@objectstack/lint` entry and a new advisory on the connector save door; nothing is refused or narrowed. Metadata-protocol: none A declarative connector entry's four maps — `providerConfig.headers`, `providerConfig.defaultHeaders`, `providerConfig.transport.headers` and `providerConfig.transport.env` — are withheld whole on the metadata read path, but a literal credential in one of them is still stored in metadata. The same `credential-literal` predicate the `flow-credential-literal` advisory asks now judges those four maps and names each credential-shaped entry by its position (never its value), with the `auth.credentialRef` route: