Skip to content

Commit a22fa55

Browse files
committed
wip: hand the runtime authoring gate the redaction context
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6981abf commit a22fa55

6 files changed

Lines changed: 324 additions & 65 deletions

File tree

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,28 @@ export interface AuthoringRuleContext {
337337
* this input or does not judge.
338338
*/
339339
judgeFilter?: IObjectQLEngine['judgeFilter'];
340+
/**
341+
* [#20611] The credential positions of the WRITTEN item that the write path
342+
* restores from the stored row before it persists the item — stack-relative,
343+
* in the rules' own finding-path spelling (`flows[0].nodes[1].config.secret`).
344+
*
345+
* Set only by the runtime publish gate (`runtime-gate.ts`), from what its host
346+
* states; ABSENT on the three CLI commands, whose stacks carry the author's
347+
* own values. It exists because the read path withholds a credential from
348+
* every served definition (a flow's start-node `config.secret`), so a body
349+
* saved back after a read arrives WITHOUT it, and the host's carry-forward
350+
* puts the stored value back only after every gate has run — on purpose, so
351+
* that no gate handles a restored credential. A rule therefore cannot tell a
352+
* withheld credential from a missing one by reading the body, and this set is
353+
* how it is told: a path listed here is WITHHELD AND STORED, and a rule
354+
* judging whether a credential is present reads it as present. A path not
355+
* listed is judged on the body as sent, so a credential that is absent and
356+
* not stored is missing.
357+
*
358+
* ⛔ Positions only, never values: the gate never sees a restored credential.
359+
* Read by `validateFlowApiTriggerSecret` only.
360+
*/
361+
restoredCredentialPaths?: ReadonlySet<string>;
340362
}
341363

342364
export interface AuthoringRule {
@@ -1113,7 +1135,8 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11131135
// routes nowhere, silently demoting it to a manual flow. #20553 made it five: an
11141136
// `api`-bound flow with no usable `config.secret`, which the engine's own
11151137
// `registerFlow` refuses (ADR-0041) — on its OWN entry below
1116-
// (`validateFlowApiTriggerSecret`), because it is CLI-only for now. None of those verdicts
1138+
// (`validateFlowApiTriggerSecret`), because it reads a context input this entry
1139+
// has no use for (#20611). None of those verdicts
11171140
// can be changed by installing a package, so there is no reading under which
11181141
// the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
11191142
// (the object may come from another installed package — a hedge this rule
@@ -1144,33 +1167,32 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11441167
run: (stack) => validateFlowTriggerReadiness(stack),
11451168
},
11461169
// #20553 — `flow-api-trigger-secret-missing`, split out of the entry above as
1147-
// its own exported rule (the `validateSecurityRoleWord` precedent: one rule id
1148-
// sits on ONE side of the runtime wall). Same family, same `error`, all three
1149-
// commands — but NOT the runtime publish gate yet, and #20611 is the card that
1150-
// moves it across. The flow read path withholds `config.secret` from every
1151-
// served definition (#20552) and `saveMetaItem` restores the stored secret only
1152-
// just before the put, AFTER this table has judged the body the caller sent —
1153-
// so on the gate, a signed flow's ordinary GET → edit → PUT reads as
1154-
// secretless. Measured on `825c33ff9f`: with this id on the gate, the two
1155-
// round-trip pins in `protocol.metadata-redaction.test.ts` fail; off it, 26/26.
1156-
// The `/meta` door therefore keeps its pre-rule behaviour (it stores a
1157-
// secretless flow, and the engine refuses it at registration) until the gate
1158-
// judges the carried-forward body — then this entry becomes `CLI_AND_RUNTIME`
1159-
// with `runtimeTypes: ['flow']`, like the one above.
1170+
// its own exported rule. Same family, same `error`, all three commands.
1171+
//
1172+
// #20611 — and the runtime publish gate too. The flow read path withholds
1173+
// `config.secret` from every served definition (#20552), and `saveMetaItem`
1174+
// restores the stored secret only just before the put, AFTER this table has
1175+
// judged the body the caller sent — deliberately, so no gate handles a
1176+
// restored credential. So the gate is handed the POSITIONS that restore will
1177+
// fill (`AuthoringRuleContext.restoredCredentialPaths`), and the rule reads a
1178+
// withheld-and-stored secret as present: a signed flow's ordinary
1179+
// GET → edit → PUT passes, and a secretless `api` flow is refused at `/meta`
1180+
// with this id instead of being stored for the engine to refuse at
1181+
// registration.
11601182
{
11611183
name: 'validateFlowApiTriggerSecret',
11621184
tier: 'gating',
11631185
input: 'normalized',
11641186
commands: ALL,
11651187
source: 'packages/lint/src/validate-flow-trigger-readiness.ts',
1166-
surfaces: CLI_ONLY,
1167-
surfaceReason:
1168-
'Not yet runtime-safe: the publish gate judges a /meta save BEFORE saveMetaItem restores the ' +
1169-
'inbound-hook secret the flow read path withholds, so a signed api flow\'s ordinary GET, edit, PUT ' +
1170-
'round trip reaches this rule secretless and would be refused. It crosses when the gate judges the ' +
1171-
'carried-forward body (the seam follow-up named in the comment above); until then the engine\'s ' +
1172-
'registerFlow refusal is what a secretless flow saved through /meta meets.',
1173-
run: (stack) => validateFlowApiTriggerSecret(stack),
1188+
// Runtime publish gate (#20611): judged on the per-write snapshot like the
1189+
// entry above, with the host's restored-credential positions read as
1190+
// present. The CLI never sets that input, so there the author's own
1191+
// `config.secret` is the whole answer.
1192+
surfaces: CLI_AND_RUNTIME,
1193+
runtimeTypes: ['flow'],
1194+
run: (stack, ctx) =>
1195+
validateFlowApiTriggerSecret(stack, { restoredCredentialPaths: ctx.restoredCredentialPaths }),
11741196
},
11751197
// ADR-0090 D3 fallout — an approval `{ type: 'role' }` resolves against the
11761198
// better-auth org-membership tier, not positions, so a position name authored

‎packages/lint/src/runtime-gate.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,49 @@ export function nameKeyFindingPath(path: string, candidate: AnyRec): string {
832832
return `${stackKey}.${name}${rest}`;
833833
}
834834

835+
/**
836+
* [#20611] The host's restored-credential positions, re-spelled from the
837+
* redactor registry's item-relative dotted form (`nodes.1.config.secret`) into
838+
* the finding-path form the rules emit and compare against, anchored at the
839+
* written item's place in `candidate` (`flows[0].nodes[1].config.secret`).
840+
*
841+
* The written item is the LAST member of its collection in the candidate —
842+
* {@link buildRuntimeWriteSnapshots} appends it — so that index is the anchor.
843+
* Each segment is spelled `[n]` exactly where the item holds an array at that
844+
* point of the walk and `.key` everywhere else: read off the item, never
845+
* guessed from the segment's digits, so a record key that happens to be
846+
* numeric keeps its key spelling. A path that walks off the item keeps the key
847+
* spelling from there on; it names no position any rule reports, so it
848+
* excuses nothing.
849+
*/
850+
function restoredCredentialStackPaths(
851+
candidate: AnyRec,
852+
type: string,
853+
dottedPaths: readonly string[],
854+
): ReadonlySet<string> {
855+
const stackKey = stackKeyForType(type);
856+
const collection = stackKey ? candidate[stackKey] : undefined;
857+
if (!stackKey || !Array.isArray(collection) || collection.length === 0) return new Set();
858+
const index = collection.length - 1;
859+
const item: unknown = collection[index];
860+
const out = new Set<string>();
861+
for (const dotted of dottedPaths) {
862+
let path = `${stackKey}[${index}]`;
863+
let node: unknown = item;
864+
for (const segment of dotted.split('.')) {
865+
if (Array.isArray(node) && /^(0|[1-9][0-9]*)$/.test(segment)) {
866+
path += `[${segment}]`;
867+
node = node[Number(segment)];
868+
} else {
869+
path += `.${segment}`;
870+
node = node !== null && typeof node === 'object' ? (node as AnyRec)[segment] : undefined;
871+
}
872+
}
873+
out.add(path);
874+
}
875+
return out;
876+
}
877+
835878
function runRules(
836879
rules: readonly AuthoringRule[],
837880
stack: AnyRec,
@@ -894,6 +937,20 @@ export function runRuntimeAuthoringRules(args: {
894937
* it skip the engine's judgement and answer as they did without it.
895938
*/
896939
judgeFilter?: IObjectQLEngine['judgeFilter'];
940+
/**
941+
* [#20611] The positions in `item` at which the host's write path restores a
942+
* credential from the stored row before it persists the item — dotted and
943+
* item-relative, the `@objectstack/spec/kernel` redactor registry's
944+
* `redactedKeys` spelling (`nodes.1.config.secret`). The read path withholds
945+
* those credentials, so a body saved back after a read arrives without them,
946+
* and the host restores them only after this gate has run.
947+
*
948+
* Handed to the rules as `AuthoringRuleContext.restoredCredentialPaths`,
949+
* translated into the candidate snapshot's finding-path spelling; see
950+
* {@link restoredCredentialStackPaths}. Omitted, every position is judged on
951+
* the body as sent. ⛔ Positions only: no credential value reaches this gate.
952+
*/
953+
restoredCredentialPaths?: readonly string[];
897954
}): RuntimeGateResult {
898955
const rules = runtimeAuthoringRulesFor(args.type);
899956
const empty: RuntimeGateResult = { errors: [], advisories: [], rulesRun: [] };
@@ -921,6 +978,19 @@ export function runRuntimeAuthoringRules(args: {
921978
sduiManifest: args.sduiManifest,
922979
runtimeWriteType: args.type,
923980
judgeFilter: args.judgeFilter,
981+
// [#20611] Spelled against the CANDIDATE, the one snapshot that holds the
982+
// written item. The baseline pass shares the set and cannot match it: the
983+
// item is not in the baseline, and every other entry sits at an index the
984+
// item does not.
985+
...(args.restoredCredentialPaths !== undefined && args.restoredCredentialPaths.length > 0
986+
? {
987+
restoredCredentialPaths: restoredCredentialStackPaths(
988+
snapshots.candidate,
989+
args.type,
990+
args.restoredCredentialPaths,
991+
),
992+
}
993+
: {}),
924994
};
925995
const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint));
926996
const added = runRules(rules, snapshots.candidate, ctx)

‎packages/lint/src/validate-flow-trigger-readiness.ts‎

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@
5757
// arm it — yet `os validate` never builds the engine, so until this rule
5858
// it answered "passed" for a flow no runtime will ever register. It is its
5959
// OWN exported rule, `validateFlowApiTriggerSecret` (1h, at the foot of
60-
// this file), because it runs on the CLI surface only until #20611 — see
61-
// its docblock for why, and see `FLOW_API_TRIGGER_SECRET_MISSING` for why
62-
// the judgement is carried here rather than read from the runtime.
60+
// this file), because at the runtime publish gate it reads one input the
61+
// rest of the family does not (#20611) — see its docblock for why, and see
62+
// `FLOW_API_TRIGGER_SECRET_MISSING` for why the judgement is carried here
63+
// rather than read from the runtime.
6364
//
6465
// ⚠️ One more rule lived here and is RETIRED (#17396):
6566
// `flow-schedule-organization-missing`, a `warning` on a time-triggered
@@ -140,13 +141,14 @@
140141
// flows keep being served. What IS refused is the dead flow's own publish — and,
141142
// on the CLI surface, a package build whose stack contains one.
142143
//
143-
// ⚠️ All of the above is about `validateFlowTriggerReadiness`. The sixth id,
144-
// `flow-api-trigger-secret-missing`, lives in `validateFlowApiTriggerSecret` on
145-
// its own registry entry, which is CLI-only until #20611: the publish gate
146-
// judges a `/meta` save before the stored `config.secret` the read path withheld
147-
// is restored, so at that door a signed flow's round trip would read as
148-
// secretless. That door still stores a secretless flow today, and the engine
149-
// refuses it at registration.
144+
// The sixth id, `flow-api-trigger-secret-missing`, lives in
145+
// `validateFlowApiTriggerSecret` on its own registry entry, which crosses the
146+
// runtime wall too (#20611) on the same per-write snapshot. It reads one more
147+
// input there: the publish gate judges a `/meta` save before the stored
148+
// `config.secret` the read path withheld is restored, so the gate hands the rule
149+
// the positions that restore fills, and a withheld-and-stored secret reads as
150+
// present. A secretless `api` flow is refused at that door; a signed flow's
151+
// round trip is not.
150152

151153
import {
152154
TimeRelativeTriggerSchema,
@@ -773,8 +775,9 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
773775

774776
// 1h. ⚠️ NOT here — the `api` trigger's secret (#20553) is its own exported
775777
// rule, {@link validateFlowApiTriggerSecret} below, on its own registry
776-
// entry: it sits on the OTHER side of the runtime wall (CLI-only until
777-
// #20611), and one rule id sits on ONE side of it.
778+
// entry: at the runtime publish gate it reads the host's
779+
// restored-credential positions (#20611), an input nothing else in this
780+
// family has a use for.
778781

779782
// 2. Auto-triggered flow whose status is 'draft' — authored or defaulted
780783
// (defineFlow parses at definition time, so the two are the same here).
@@ -822,32 +825,45 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
822825
* engine reads its `config` as `{}` and refuses it for the same reason — and is
823826
* located at `nodes`, since there is no start node to point at.
824827
*
825-
* ## Why this is a separate function from {@link validateFlowTriggerReadiness}
828+
* ## At the runtime publish gate: a withheld-and-stored secret is present (#20611)
826829
*
827-
* A surface boundary, not taste — the registry's `validateSecurityRoleWord`
828-
* split is the precedent. `validateFlowTriggerReadiness` runs on the runtime
829-
* publish gate too; this rule cannot, yet (#20611). The flow read path withholds
830-
* `config.secret` from every served definition (#20552), and `saveMetaItem`
831-
* restores the stored secret only just before the put — AFTER the runtime
832-
* authoring gate has judged the body the caller sent. So an ordinary `/meta`
833-
* GET → edit → PUT of a SIGNED flow reaches the gate secretless, and this rule
834-
* would refuse a save that keeps the secret. Measured on `825c33ff9f`: with this
835-
* id at the gate, `protocol.metadata-redaction.test.ts` fails exactly its two
836-
* round-trip pins; with it dropped there, 26/26 pass. So this id stays CLI-only
837-
* (`os validate` / `os build` / `os lint`, whose stacks carry the author's own
838-
* secret) until the gate judges the carried-forward body, and it is split out
839-
* WHOLE rather than filtered at one entry: one rule id sits on ONE side of the
840-
* wall. Meanwhile the `/meta` door behaves as it did before this rule existed:
841-
* it stores a secretless flow, and the engine refuses it at registration.
830+
* The flow read path withholds `config.secret` from every served definition
831+
* (#20552), and `saveMetaItem` restores the stored secret only just before the
832+
* put — AFTER the runtime authoring gate has judged the body the caller sent,
833+
* and deliberately so: no gate handles a restored credential. So an ordinary
834+
* `/meta` GET → edit → PUT of a SIGNED flow reaches this rule without its
835+
* secret, exactly like a flow that never had one. The body alone cannot tell
836+
* the two apart; the host can, and the gate passes its answer in as
837+
* `options.restoredCredentialPaths` (`AuthoringRuleContext`): the positions the
838+
* host's carry-forward will fill from the stored row. The start node's secret
839+
* path listed there is WITHHELD AND STORED, and reads as present; one not
840+
* listed is judged on the body as sent, so a secret that is absent and not
841+
* stored is missing, and the save is refused. The CLI never sets the option —
842+
* its stacks carry the author's own secret.
843+
*
844+
* It is a separate function from {@link validateFlowTriggerReadiness} because
845+
* that option is its input alone: the rest of the family judges nothing the
846+
* read path withholds.
847+
*
848+
* @param options.restoredCredentialPaths Stack-relative paths, in this rule's
849+
* own finding-path spelling (`flows[0].nodes[1].config.secret`), that the
850+
* write path restores from the stored row. Positions only, never values.
842851
*/
843-
export function validateFlowApiTriggerSecret(stack: AnyRec): FlowTriggerReadinessFinding[] {
852+
export function validateFlowApiTriggerSecret(
853+
stack: AnyRec,
854+
options: { restoredCredentialPaths?: ReadonlySet<string> } = {},
855+
): FlowTriggerReadinessFinding[] {
844856
const findings: FlowTriggerReadinessFinding[] = [];
845857
recordsOf(stack.flows).forEach((flow, flowIndex) => {
846858
const flowName = typeof flow.name === 'string' ? flow.name : `#${flowIndex}`;
847859
const start = startNodeOf(flow);
848860
const config = (start?.node.config ?? {}) as AnyRec;
849861
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;
850862
const bindsApiTrigger = !isArrayRecordTriggerType(config) && resolveFlowTriggerKind(flow) === 'api';
863+
const secretPath = start ? `flows[${flowIndex}].nodes[${start.index}].config.secret` : undefined;
864+
// [#20611] Withheld and stored ⇒ present: the host restores the stored
865+
// secret at exactly this position before the item is persisted.
866+
if (secretPath !== undefined && options.restoredCredentialPaths?.has(secretPath)) return;
851867
const secretProblem = bindsApiTrigger ? describeUnusableSecret(start, config) : undefined;
852868
if (!secretProblem) return;
853869
// Which declaration binds it — the engine's own message names the same
@@ -864,9 +880,7 @@ export function validateFlowApiTriggerSecret(stack: AnyRec): FlowTriggerReadines
864880
severity: 'error',
865881
rule: FLOW_API_TRIGGER_SECRET_MISSING,
866882
where: start ? `flow "${flowName}" › start node` : `flow "${flowName}"`,
867-
path: start
868-
? `flows[${flowIndex}].nodes[${start.index}].config.secret`
869-
: `flows[${flowIndex}].nodes`,
883+
path: secretPath ?? `flows[${flowIndex}].nodes`,
870884
message:
871885
`binds the inbound api trigger (${binds.join(' and ')}) but ${secretProblem}. An inbound hook ` +
872886
`is armed only with a per-flow secret that every post is HMAC-verified against (ADR-0041), so the ` +

0 commit comments

Comments
 (0)