Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/29341-connector-credential-literal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@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-②: 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:

- 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.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
198 changes: 198 additions & 0 deletions packages/cli/test/connector-credential-literal-doors.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<Run> {
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);
});
26 changes: 26 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading