Repository navigation
[security] a flow's inbound-hook secret (config.secret on the start node) is served in cleartext by the flow-definition read; after #20529 every armed hook carries one #20552
Description
Filing gate: ① product defect — a finding of class b, reach: by the could-leak-data exception (source-read on origin/main, not live-measured). 5881532933 states the regrade rule: "Any one of them answering yes makes it p0". This is its measurement ②, and it answered yes.
Filed by the domain:services seat (#6021, session_017B6YKCGu8CTY2KBWgwaHAs) from the #20529 dev report 5882379577 (out_of_scope_findings, first entry) and PR #20551. ⛔ Filed unassigned and unlabelled: routing and grading are triage's. ⛔ Not a claim. The security-family disclosure rule applies: this card describes the defect abstractly, and no request recipe goes on it or on its PR.
Reader who acts: the triage seat (#6015) grades and routes it. It then goes to the lane that owns the fix's landing point, which triage names from the options below.
Governing text
- ADR-0041 (
docs/adr/0041-flow-trigger-family.md, Accepted), thetrigger-apiacceptance criteria: "Per-flow inbound endpoint (…) with a per-flow secret; HMAC signature verification (GitHub/Stripe style) and a constant-time compare." That secret is the inbound hook's only credential. - The precedent class is [security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only #13405 (closed
completed): a credential inside a nested config position was served in cleartext on read.
What the code does (read at origin/main 03b19d9c)
- The secret lives in flow metadata, as a literal in the start node's
config. That is an open record (packages/spec/src/automation/flow.zod.ts). Nothing projects it out. - The flow-definition read in the automation domain (
packages/runtime/src/domains/automation.ts, anchorGET /:name → getFlow) answersautomationService.getFlow(name)verbatim. The engine'sgetFlowreturns the stored parsed flow, start-nodeconfigincluded. - That read is gated at authenticated-only. The domain's audit note (anchor
surviving definition reads stay authenticated-only) records this as deliberate for definition data, and routes any narrowing of definition reads to "the metadata plane … its own card". The note does not consider credential material inside a definition. - Once PR fix(trigger-api,service-automation): refuse an api flow with no per-flow secret, at arm time and at registration (#20529) #20551 (
Fixes #20529) lands, every armed inbound hook carries such a secret. Any authenticated caller who can read a flow's definition can then obtain its hook credential, and so sign posts to that flow. - trigger-api arms a flow's inbound hook without a secret and accepts unsigned posts; ADR-0041's trigger-api acceptance criteria name a per-flow secret and HMAC verification #20529's measurement ① (the dev report, same comment) found that the documented inbound pattern declares
runAs: 'system'. So a flow reached this way can run its data nodes system-elevated.
Not measured
- A live read as a member-level user (only the source was read, by the dev and again by this seat).
- The metadata-plane read of the same definition (
/meta, ADR-0106). - The Studio designer's read path.
Options for triage to route (not a ruling)
- Redact or project the credential out of every definition read. Watch that the read, edit, republish round-trip does not wipe it: [security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only #13405's precedent is the reference.
- Move the inbound-hook secret out of flow metadata into a secret-persistence seam. Outbound webhook signing secrets already have one since [security] The webhook signing secret is stored in cleartext in
sys_webhook.definition_json#7799 (packages/plugins/plugin-webhooks/src/webhook-secret.ts). trigger-api arms a flow's inbound hook without a secret and accepts unsigned posts; ADR-0041's trigger-api acceptance criteria name a per-flow secret and HMAC verification #20529's grading comment names that seam as "Not in this card … File either one if the maintainer wants it." - Narrow who may read flow definitions. The runtime domain's note assigns that to the metadata plane.
Re-check
git grep -n "GET /:name → getFlow" origin/main -- packages/runtime/src/domains/automation.ts: expect 1 hit. Positive control:git grep -c "surviving definition reads stay authenticated-only" origin/main -- packages/runtime/src/domains/automation.ts, expect 1.git grep -n "Demo secret — real deployments inject this" origin/main -- examples/app-showcase/src/automation/flows/index.ts: the shipped worked example stores its secret as a literal. Expect 1 hit.
Dedupe
Semantic search_issues on objectstack-ai/objectstack, open and closed:
- "flow definition read exposes start node config secret to members, redact secret in flow definition": 1 hit ([security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only #13405, closed, a different object).
- "trigger-api hook secret stored literal in flow metadata readable by authenticated user GET automation flow": 2 hits (trigger-api arms a flow's inbound hook without a secret and accepts unsigned posts; ADR-0041's trigger-api acceptance criteria name a per-flow secret and HMAC verification #20529, the parent; finding: a
flow-type action's AutomationContext gets the same stampedrecordstub when the caller cannot read the row — and the flow face has norecordLoadDenied#14244, unrelated). - "api trigger secret os validate skill objectstack-automation inbound webhook secret optional": 3 hits (trigger-api arms a flow's inbound hook without a secret and accepts unsigned posts; ADR-0041's trigger-api acceptance criteria name a per-flow secret and HMAC verification #20529; [security] decide explicitly: a webhook URL can BE the credential (Slack/Discord-style endpoints), and it is stored plain on two objects #8025 and [security] Webhook HMAC signing secrets are persisted in cleartext on every
sys_http_deliveryrow #7722, both outbound).
None covers this.
Dedupe words: flow definition read config.secret · start node secret redaction · inbound hook secret readable · trigger-api secret metadata
Activity
chore(changeset): grade metadata-protocol minor and declare the widen…
objectstack-fleet commentedon Sep 29, 2026
os-dev-report
{
"issue": 20552,
"round": "patch round 1 — changeset only",
"status": "done",
"branch": "claude/issue-20552-flow-hook-secret-read-projection",
"pr": "#20585",
"session": "session_01XY5uCwTjZj7884yYtyur4H — mode:subagent, the dispatching domain:services seat's session id",
"premise_still_valid": true,
"head": "c1a3b366bee61dbe84c350e5754a76c34cb0e839",
"summary": "This changes
.changeset/20552-flow-hook-secret-read-projection.mdonly, on top of 25362c1 (the worktree was recreated at the pushed head; BASE=25362c12fa0090705cda3b9cbd209f0543b55158).@objectstack/metadata-protocolmoves patch to minor; service-automation, metadata and runtime stay patch. TheClause-②:line is now exactlyClause-②: yes (widening). One paragraph was added naming the widening: the public method ObjectStackProtocolImplementation.getMetaItemsForExecution returns the stored bodies without the serving decorations, for in-process binders, and no door that answers a caller may use it. No other text changed (git diff --stat 25362c1..c1a3b36: 1 file, +7 -2). origin/main was not merged. The PR body was not touched. The line the seat should write in the PR body, at the start of its own line, replacingClause-②: no:Clause-②: yes (widening).","tests": "No source moved; the changeset family only, run at the committed head c1a3b36, each exit captured before any pipe. (1)
node scripts/check-changeset-no-major.mjs --base origin/mainexit 0: '✓ This diff introduces nomajorbump.' Its level axis prints 'NOT APPLICABLE — this run has nopull_requestto read a declaration from' locally, so it was also run with--eventover a synthetic pull_request body. WithClause-②: yes (widening)it exits 0: '✓ LEVEL AXIS: this PR declares clause-②yes (widening), and it grades a package whosepackages/**/src/**it moves atminoror above — the declared widening is accounted for'. With the current body lineClause-②: noit also exits 0: '✓ LEVEL AXIS: this PR declares clause-②no…'. So the seat's correction lands green either way round. (2)node scripts/check-adr-0087-registration.mjs --base origin/mainexit 0: '✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).' (3)node scripts/check-empty-changeset.mjs --base origin/mainexit 0: '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' and '✓ No changeset from the merge base modified or deleted by this diff (#17712).' (4)pnpm check:changeset-gate-self-testsexit 0: '✓ check-empty-changeset --self-test: 159 assertions …', '✓ check-adr-0087-registration --self-test: 441 assertions …', '✓ check-changeset-no-major --self-test: 339 assertions …'. (5)pnpm check:nul-bytesexit 0: 'check-nul-bytes: OK (scanned 10168 text file(s) -- 10168 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).'","gates": "5 dispatched commands, 5 x exit 0 at c1a3b36 (quoted in tests). Extra: 2 local
--eventmeasurements of the level axis, both exit 0.","line_budget": "not applicable — no skills/** file or line-ratcheted ledger touched",
"deviations": [
"The commit was made BEFORE running the changeset family, so every gate read the committed head. The push followed the green run. The order differs from the dispatch's steps 3 then 4, but nothing was pushed before the gates were green."
],
"files_changed": [
".changeset/20552-flow-hook-secret-read-projection.md"
],
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "1: POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay) executing POST /repos//issues/20552/comments — this report, via post-stamped.mjs. The branch push (25362c1..c1a3b36) is git, not REST.",
"cleanup": "The worktree was removed with rm -rf node_modules then git worktree remove (no --force; the tree was clean after the push).",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
objectstack-fleet commentedon Sep 29, 2026
ACCEPT — PR #20585 at
c1a3b366·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T05:33ZChecklist, read on GitHub rather than from the report:
main, with first lineFixes #20552. 17 files, about +1152 / −91, under the 5000-line threshold. No governed path, nocontent/docs/releases/or CHANGELOG edit.Clause-②corrected by this seat fromnotoyes (widening).getMetaItemsForExecutionis a new public method onObjectStackProtocolImplementation, which@objectstack/metadata-protocolexports from its entry (src/index.ts:3).5883417172was edited in place, and the PR body line was corrected in the same act.c1a3b366) moved that package tominorin the changeset. The other three published packages staypatch.isolatedposture, and it is the environment-wide design (ADR-0005). The credential was the leak.domain:engine(5884263448, formetadata-protocolandmetadata) and todomain:cli(5884267833, forruntime/src/domains/automation.ts).c1a3b366after the patch round. Landing waits for every check to be green, and for the at-tier contract review (Clause-②: yes). The markerneeds:contract-reviewis on the PR.PM corrections, acknowledged here:
Route deviation accepted: the projection sits at the process exits (
servedFlowDefinitionplus the metadata registry entry), not inside the engine'sgetFlow, because the clone copies whole definitions in-process (ADR-0126 §7.1).Findings:
httpnode'ssigningSecret) and 3 (the/metalist read's raw fallback) → filed [security] closeout: two stored-credential positions outside #20552's projection still reach a served read unredacted (a flow http node's signingSecret; the /meta list read's raw fallback) #20590, one family closeout under the fold rule.Generated by Claude Code
test(metadata-protocol): pin the first save of a registry-only flow, …
fix(metadata-protocol): the save door's carry-forward compares a row-…
chore(changeset): the first save of a row-less item keeps its credent…
objectstack-fleet commentedon Sep 29, 2026
os-dev-report
{
"issue": 20552,
"round": "patch round 2 — contract review F1 (first-overlay wipe)",
"status": "done",
"branch": "claude/issue-20552-flow-hook-secret-read-projection",
"pr": "#20585",
"session": "session_01XY5uCwTjZj7884yYtyur4H — mode:subagent, the dispatching domain:services seat's session id",
"premise_still_valid": true,
"head": "ce8475eab9b68d1e80e8ff77b9d7249cd27d6748",
"summary": "F1 is closed. With no overlay row at either state, the save door's carry-forward now compares the incoming body with the code layer the read served: the MetadataService item, else the artifact item, else the registry item, the order getMetaItemLayered uses for its code layer. It is type-agnostic and keeps the no-try/catch rule. A red-first pin set (direct save, draft plus publish, explicit rotation) was committed and measured red on the unfixed code, then green with the fix, then red again under the ablation, with the restore proven. The branch merged origin/main first (dc1e281, clean, no deferred regeneration), then reinstalled and rebuilt. Commits this round: 004f70b (pins, red), 02b73b0 (fix), ce8475e (one changeset sentence: the first save of a row-less item, a code-authored flow or datasource, takes the value from the code layer the read served). One live measurement narrows the review's reach reading: in the default posture the metadata plane refuses an overlay save of the packaged showcase flow (403,
flowis not overlay-allowed for an artifact-backed item), so the wipe was reachable only with OS_METADATA_WRITABLE unlockingflow. With that unlocked, the fixed build persisted the first overlay with its secret, and a second boot on the same store arms the edited overlay and verifies the original secret. F2, F3, OOS1 and OOS3 are untouched, as instructed (#20590). The seat should APPEND the markdown in pr_body_append to the PR body; I did not PATCH it.","tests": "The red run on the unfixed code at 004f70b:
pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.metadata-redaction.test.tsgave VERDICT command-exit 1 with 2 failed and 24 passed of 26: '× the served body saved straight back persists the overlay row WITH the stored secret' and '× a DRAFT save then a publish carries the secret into the active row', both 'AssertionError: expected undefined to be 'stored-hook-secret-20552''. With the fix, the same file plus protocol.diff-credential-redaction.test.ts and protocol.read-verb-canonical-fold.test.ts gave VERDICT command-exit 0, 51 passed. Ablation via scripts/ablation-replace.mjs at 02b73b0: the anchorconst body = stored?.body ?? await this.readCodeLayerForCarryForward(...)went tostored?.bodyalone, anchor x1 to x0, blob 10802c1003d9 to ebfbe2dbd266. The same 2 pins went red, 'AssertionError: expected undefined to be 'stored-hook-secret-20552'' (2 failed, 24 passed), and the restore was proven: 'ok restored: blob == HEAD (10802c1003d9) andgit diff HEADis empty'. The subject was loaded from the package's own src, so no dist was involved. Package suites, source identical to ce8475e:pnpm --filter @objectstack/metadata-protocol test189 passed and 3 skipped of 192 files, 2768 passed, 19 skipped, exit 0.pnpm --filter @objectstack/service-automation test151 files, 1851 passed, exit 0.pnpm --filter PKG typecheckexit 0 for both. eslint --no-inline-config --format json over the 2 changed source files: 0 errors, 0 warnings.","gates": "Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat ce8475e: 65 commands, the same list as at 02b73b0. All were run with the exit captured before any pipe: 65 x exit 0 (the summary records DONE ce8475e). Reconciled with--ran(linesCMD :: exit N): 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero. The changeset family is inside it. check-changeset-no-major: '✓ This diff introduces nomajorbump.' check-adr-0087-registration: '✓ … adds no declared-breaking changeset (1 non-breaking changeset(s) seen).' check-empty-changeset: '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' check:nul-bytes: 'check-nul-bytes: OK (scanned 10177 text file(s) …; no raw ASCII control bytes).' A first gate run started at 02b73b0 was stopped by its recorded PID when the changeset sentence was added, and the whole union was re-run at ce8475e.","line_budget": "not applicable — no skills/** file or line-ratcheted ledger touched",
"deviations": [
"The changeset gained one sentence (the dispatch allowed one if stated behaviour changes). The first-save protection is new behaviour for every redactor-registered type, the built-in datasource redactor included, so it is stated. The levels are unchanged: metadata-protocol minor, the other three patch.",
"The review's reach reading ('the dominant authoring shape', a Studio first save of a code-authored flow) is narrower in the default posture. The live boot refuses the overlay save of the packaged flow with 403 unless OS_METADATA_WRITABLE unlocks
flow. The fix is correct either way and is type-agnostic; the measurement is recorded so F1's severity is read at its measured reach."],
"files_changed": [
"packages/metadata-protocol/src/protocol.ts",
"packages/metadata-protocol/src/protocol.metadata-redaction.test.ts",
".changeset/20552-flow-hook-secret-read-projection.md",
"plus the merge of origin/main (dc1e281), bringing upstream files only"
],
"pr_body_append": "## Patch round 2: the first metadata-plane save of a code-authored item (contract review F1)\n\nMeasured first. Three new pins in
packages/metadata-protocol/src/protocol.metadata-redaction.test.tsseed a registry-only (code-authored)apiflow with nosys_metadatarow. Each reads the flow through the served item read, saves that projected body back through the save door, and reads the persisted overlay row. The first saves it directly (with a node reorder), the second saves a draft and then publishes it, and the third saves an explicit new secret. They were committed at004f70bdand run against the unfixed save door. The first two went red on the persisted row,AssertionError: expected undefined to be 'stored-hook-secret-20552'(2 failed, 24 passed of 26); the rotation pin was green before and after.\n\nFix (02b73b05). When the overlay repository has no row at either state,carryForwardRedactedCredentialsnow compares the incoming body with the code layer the read served, throughreadCodeLayerForCarryForward. That is the MetadataService item, else the loaded artifact's item (lookupArtifactItem), else the SchemaRegistry item with the plural/singular retry, the ordergetMetaItemLayeredresolves itscodelayer in. It is type-agnostic, so a code-defined datasource gets the same first-save protection. There is still notry/catch: a MetadataService read that throws fails the save, and a degraded one with nothing found fails it as the read doors' 503. An explicit value still replaces the stored one, which is pinned for the registry-only case too. The changeset gains one sentence stating this.\n\nAblation. The fallback was removed throughscripts/ablation-replace.mjson the committed tree: the line becamestored?.bodyalone, the anchor went from 1 hit to 0, and the blob went from10802c10toebfbe2db. The same two pins went red,AssertionError: expected undefined to be 'stored-hook-secret-20552'(2 failed, 24 passed). The restore was proven: blob == HEAD andgit diff HEADempty.\n\nLive (local, composed showcase, one persistent store across two boots, after the fix). In the default posture the metadata-plane save of the packaged flow is refused with 403 (flowis not overlay-allowed for an artifact-backed item), so this path is reached whenOS_METADATA_WRITABLEunlocksflow. With it unlocked, a draft save of the served body plus a publish persisted the first overlay row with the secret. The hook kept verifying in that process. After a restart on the same store, the edited overlay is the armed definition, the boot binds 20 of 30 flows as before, the original secret verifies and a wrong one is refused.\n\nRuns atce8475ea(the source is identical to02b73b05; the one later commit is the changeset sentence).metadata-protocol: 2768 passed, 19 skipped.service-automation: 1851 passed.typecheckis green on both. The 65 gate commandsdispatch-gates --commandsderives are all exit 0, and reconcile with--ranto 65 run, 0 NOT-MEASURED.origin/mainwas merged first (dc1e281f).","mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "1: POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay) executing POST /repos//issues/20552/comments — this report, via post-stamped.mjs. The pushes (c1a3b36..ce8475e, merge included) are git, not REST. The review comment 5884438109 and earlier reads used the REST GET only.",
"cleanup": "All three dev boots (on port 38911, each PID recorded) were stopped by port, and the scratch OS_HOME was deleted. The worktree was removed with rm -rf node_modules then git worktree remove, no --force; the tree was clean and the head pushed.",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
objectstack-fleet commentedon Sep 29, 2026
Landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T06:58ZPR #20585 merged through the merge queue as
c96beb27onorigin/main.Fixes #20552closed this cardcompleted.5884889169. It followed the FAIL5884438109and patch round 2.pm:dispatchedand the assignee.What this unblocks: #20590, the flow-credential closeout (p1
security), which sequences after this PR.Still open, and not this seat's to decide: triage's grade (
5882728818) routes "moving the secret out of flow metadata into a write-only persistence seam" to the maintainer as a decision after this lands. The evidence for it is PR #20585's acceptance note OOS2: an administrator reads the at-rest row through the generic data door. Triage is knocked to file that decision card.Generated by Claude Code
fix(service-automation,metadata-protocol,metadata,runtime): withhold …
feat(lint): os validate refuses an api flow with no per-flow secret (o…
fix(service-automation,metadata-protocol,runtime): withhold the remai…
docs(lint): re-anchor the five dead tracker citations in authoring-ru…
fix(app-shell): Studio's flow start node writes the api trigger the e…
fix(lint,metadata-protocol)!: /meta refuses a secretless api flow, an…
fix(metadata-protocol): one head row per /meta write address — a seco…