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
18 changes: 18 additions & 0 deletions .changeset/20654-flow-credential-literal-advisory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/lint': minor
'@objectstack/spec': patch
---

A credential typed as a literal into a flow position that every flow reader is served now draws one `flow-credential-literal` warning at `os validate`, `os build`, `os lint` and the runtime publish gate, and the spec describes of those positions route an outbound credential to a declarative connector's `credentialRef` (#20654).

Clause-②: no

**Why.** A flow definition is served, as authored, to every member who can read flows. The flow read path withholds the credential slots the spec declares, but it cannot withhold a value inside an open map or a url, because it cannot tell a credential there from an ordinary value. The supported home for an outbound credential is a declarative connector: its `auth: { type, credentialRef }` names a secrets-layer reference that is resolved at boot and never stored in metadata.

**What the warning covers.** An `http` node's `config.headers` entry, a query parameter of an `http` node's `config.url`, and a node's `connectorConfig.input` at any depth, including nodes inside `try_catch`, `loop` and `parallel` regions. A value draws when it is a non-blank string with no `{…}` template, and either its name reads as a credential (`Authorization`, `Cookie`, `x-api-key`, a name carrying `token`, `secret`, `password` and similar) or it opens with an auth scheme (`Bearer`, `Basic`, `Token`, `Digest`, `ApiKey`) followed by a value. A `{variable}` template is resolved per run and draws nothing.

**What it does not do.** It never refuses: every finding is a `warning`, and a save, validate, build or lint that passed before still passes (`--strict` promotes it, as it promotes every warning). It never echoes the value it names. Nothing is withheld on any read.

**Fix, by where the credential sits.** Declare a `connectors:` entry with a `provider` and call it from a `connector_action` node. A header credential goes to `auth: { type: 'bearer', credentialRef }`, or to `auth: { type: 'api-key', headerName, credentialRef }` for a key in a named header. A key in the url's query string goes to `auth: { type: 'api-key', paramName, credentialRef }`. On a connector node, drop the credential from `input`: the connector authenticates through its own `auth.credentialRef`.

`@objectstack/lint` exports the rule `lintFlowCredentialLiterals`, its id `FLOW_CREDENTIAL_LITERAL`, and the one predicate it asks, `isCredentialShapedLiteral(name, value)`. In `@objectstack/spec`, only the descriptions of `HttpConfigSchema.headers` and a flow node's `connectorConfig.input` change; no shape changes.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (47)...
→ Running author-time rules (48)...
→ Checking capability providers (#3366)...
→ 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 @@ -679,7 +679,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 (47)...
→ Running author-time rules (48)...
→ Checking capability providers (#3366)...
→ 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 @@ -270,7 +270,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (47)...
→ Running author-time rules (48)...

✗ 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/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ const result = FlowSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **connectorId** | `string` | ✅ | Registered connector name |
| **actionId** | `string` | ✅ | Action key declared by the connector |
| **input** | `Record<string, any>` | optional | Mapped inputs for the action |
| **input** | `Record<string, any>` | optional | Mapped inputs for the action. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: the connector authenticates through its own `auth.credentialRef`. |

### Nested Shape: `FlowNode.inputSchema[string]`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const result = HttpConfigSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **url** | `string` | ✅ | Target URL |
| **method** | `string` | optional | HTTP method (default GET; POST when durable) |
| **headers** | `Record<string, string>` | optional | Request headers |
| **headers** | `Record<string, string>` | optional | Request headers. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: declare a connector whose `auth` is `bearer` or `api-key` (a header), with `auth.credentialRef` naming the secret, and call it from a `connector_action` node. |
| **body** | `any` | optional | Request body (JSON-serialised) |
| **durable** | `boolean` | optional | Fire-and-forget via the durable outbox (retry/dead-letter) instead of inline request/response |
| **timeoutMs** | `number` | optional | Per-request timeout (ms) |
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 @@ -382,7 +382,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (47)...
→ Running author-time rules (48)...
→ Checking capability providers (#3366)...
→ Checking package docs (ADR-0046)...

Expand Down
191 changes: 191 additions & 0 deletions packages/cli/test/flow-credential-literal-doors.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os validate` and `os lint` — the two CLI doors of the `flow-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. The
* measured starting point was silence at both doors: a flow carrying a
* credential-shaped literal in an `http` node's headers and url and in a
* connector node's input validated clean and linted clean.
*
* Two fixtures, one flow each: LIT carries three literals (a header, a url
* query parameter, a nested connector input); DARK is the same flow with
* `{var}` templates in those three positions, 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 { FLOW_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 TSX = resolve(HERE, '../../../node_modules/.bin/tsx');

const SENTINELS = ['cli-header-sentinel', 'cli-url-sentinel', 'cli-input-sentinel'] as const;

const stackOf = (literal: boolean) => ({
manifest: {
id: `com.example.credential.${literal ? 'lit' : 'dark'}`,
namespace: 'cred',
version: '1.0.0',
type: 'app',
name: 'Credential door probe',
engines: { protocol: '^17' },
},
objects: [
{
name: 'cred_note',
label: 'Note',
pluralLabel: 'Notes',
sharingModel: 'private',
fields: { name: { type: 'text', label: 'Name', required: true } },
},
],
flows: [
{
name: 'cred_probe',
label: 'Credential probe',
type: 'autolaunched',
variables: [{ name: 'api_token', type: 'text', isInput: true }],
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'call',
type: 'http',
label: 'Call',
config: {
url: literal
? `https://example.invalid/hook?api_key=${SENTINELS[1]}`
: 'https://example.invalid/hook?api_key={api_token}',
method: 'POST',
headers: {
Authorization: literal ? `Bearer ${SENTINELS[0]}` : 'Bearer {api_token}',
'X-Trace-Label': 'plain-value',
},
},
},
{
id: 'conn',
type: 'connector_action',
label: 'Connector',
connectorConfig: {
connectorId: 'rest',
actionId: 'request',
input: { method: 'GET', path: '/p', auth: { clientSecret: literal ? SENTINELS[2] : '{api_token}' } },
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'call' },
{ id: 'e2', source: 'call', target: 'conn' },
{ id: 'e3', source: 'conn', target: 'end' },
],
},
],
});

const LIT_PATHS = [
'flows[0].nodes[1].config.headers.Authorization',
'flows[0].nodes[1].config.url',
'flows[0].nodes[2].connectorConfig.input.auth.clientSecret',
];

interface Run {
code: number;
stdout: string;
stderr: string;
}

function runCli(args: string[], cwd: string): Promise<Run> {
return new Promise((resolvePromise) => {
execFile(
TSX,
[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 === FLOW_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 === FLOW_CREDENTIAL_LITERAL);

const dirs = new Map<'lit' | 'dark', string>();

beforeAll(() => {
for (const kind of ['lit', 'dark'] as const) {
const dir = mkdtempSync(join(tmpdir(), `os-flow-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('flow-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);
});
28 changes: 28 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ import { validateRuleCompilability } from './validate-rule-compilability.js';
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 { lintLivenessProperties } from './lint-liveness-properties.js';
import { lintAutonumberFormats } from './lint-autonumber-formats.js';
import { lintViewRefs } from './lint-view-refs.js';
Expand Down Expand Up @@ -1443,6 +1444,33 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
hint: f.hint,
})),
},
// A credential typed as a LITERAL into a flow position every flow reader is
// served — an `http` node's `headers` or `url` query, a node's
// `connectorConfig.input` — named at every authoring door, with the
// declarative connector's `credentialRef` as the route (the triage ruling on
// #20590, direction A). Nothing is withheld and nothing is refused: the
// predicate is a heuristic, so the tier is `advisory`, and
// `authoring-rule-wiring.test.ts` holds the source to it. The fourth door is the one a Studio / REST / MCP
// author of a flow has, so the entry is on it for `flow`, exactly where
// `lintFlowPatterns`' advisories already surface.
{
name: 'lintFlowCredentialLiterals',
tier: 'advisory',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/lint-flow-credential-literals.ts',
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow'],
run: (stack) =>
lintFlowCredentialLiterals(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-and-misleading or experimental is set hopefully and does nothing.
// Ledger-driven (entries opt in via `authorWarn`), so it is high-signal and
Expand Down
Loading
Loading