Resolves #17730
The Data Sync Pipeline's staging path now refuses to publish content carrying a denylisted term: it throws, the stage fails, and the publication commit is never made. It sits beside the existing path allowlist (assertGeneratedOnly) and shares its semantics deliberately — one is a gate on which paths may be published, this is a gate on what they may contain. It does not sanitize: a pipeline that quietly rewrites content teaches nobody, and the author never learns their comment leaked into a tree that mirrors it within the hour.
The guard is inert until an operator populates NEO_CONFIDENTIAL_TERMS. An unset repository secret renders as an empty string, which the guard reads as the explicit policy "there are no terms to guard" — so this PR ships the mechanism, and the list is an operator action. That is stated here rather than buried because a guard nobody populates looks exactly like a guard that works.
Evidence: L3 (a real git-repository fixture — the guard refuses a seeded publication and the local origin's history is verified unchanged) → L1 only for the workflow env: wiring, which no sandbox run can exercise. Residual: none — all eight ACs are delivered on this branch.
AC Evidence
| AC-1 | DataSyncPipeline.spec.mjs — a staged file carrying a denylisted term fails the run, and nothing is published; asserts both the rejection and remoteSubjects === ['initial'] |
| AC-2 | same spec — the failure names the file and WHICH rule fired, never the matched value; asserts the message contains the path, :4 and term #1, and .not.toContain(TERM) |
| AC-3 | AC-1's arm is the gate proof: the assertion that survives is the absence of a publication commit, not the presence of a log line |
| AC-4 | denylist is sourced from NEO_CONFIDENTIAL_TERMS, never the tree; every arm uses the synthetic token zzsynthetictenantzz, and the pure arms use acme |
| AC-5 | same spec — identity forms publish normally, exercising <person@TERM.com> and @TERM in one file and asserting {changed: true, pushed: true}; plus the unit arm covering both shapes against a private host |
| AC-6 | the guard throws rather than skipping, so a false positive is a failed hourly run; findConfidentialProse's JSDoc records why that cost forces the list to stay narrow |
| AC-7 | red-proof below |
| AC-8 | learn/agentos/process/correction-culture.md — new section Correcting a public artifact: the window is one hour |
Deltas from ticket
denylist is a required parameter with no default. The ticket did not ask for this; #17728 did, an hour before this branch. There, a test seam injected by parameter name failed OPEN when the callee renamed the parameter — the injection was silently ignored, the production default took over, and a unit run wrote a waking broadcast to every peer's mailbox. A defaulted [] here would reproduce that class exactly: rename the option later and every caller silently reverts to "guard nothing". A required parameter fails closed under that rename. Cost is six existing call sites now stating denylist: [].
Absent and empty are different states. readConfidentialTerms throws when the variable is missing and returns [] when it is empty. "Nobody configured this" and "there is nothing to guard" are different statements, and conflating them is how a guard silently protects nothing.
The identity-form exemption is one character of lookbehind, not a pattern list. An identity form is always introduced by @; prose never is. So <user@name.com> and @name pass, while name-memory-core and https://mcp.name.net/ — a private host, the leak shape that motivated this — still fail. The census behind the ticket found identity forms outnumbering real leaks 2:1, so a guard that trips on them fails every hourly run and is switched off within a day.
Scanning covers all staged paths, not only resources/content/**. Same cost, strictly larger coverage; the portal's derived JSON is mirrored from the same artifacts.
Substrate note: correction-culture.md is an ordinary process doc under learn/agentos/, not directly loaded per turn, so it carries in-doc lifecycle rationale rather than a slot-disposition block. It extends an existing document rather than adding a sixth.
Test Evidence
Red-proof (mutation). Commenting out the single assertNoConfidentialContent call and running the suite: 2 failed, 42 passed. The two reds are exactly the gate-firing arms, and both fail for the right reason rather than an adjacent one — expect(received).rejects.toThrow() because the run no longer rejects, and expect(message).toContain(generatedFile) because no error is produced. Notably the identity-form arm stays green under the mutation, which is correct and is the check that the arm is not accidentally passing because of the guard.
Guard restored, suite back to 44 passed.
Post-Merge Validation
Nothing is owed after merge, so there is no checklist here — deliberately, because both candidates fail the residual rule for the same reason and inventing owners for them would create exactly the dangling pointers that rule exists to prevent.
Populating the NEO_CONFIDENTIAL_TERMS secret is an operator action on a credential surface that no existing ticket owns; it is stated in the second paragraph, where a reader meets it before the diff rather than after. The first hourly run after merge exercising the added env: key is observation, not work: it happens whether or not anyone watches, and the workflow already fails loudly on a stage error.
Commits
ed65cad303 — the guard, its wiring, and its arms
bbb4170e82 — the author-facing one-hour window note (AC-8)
Authored by Grace (Claude Opus 5, Claude Code). Session 728a756d-71df-48e6-8dad-0bac498ca23e.
Resolves #17730
The Data Sync Pipeline's staging path now refuses to publish content carrying a denylisted term: it throws, the stage fails, and the publication commit is never made. It sits beside the existing path allowlist (
assertGeneratedOnly) and shares its semantics deliberately — one is a gate on which paths may be published, this is a gate on what they may contain. It does not sanitize: a pipeline that quietly rewrites content teaches nobody, and the author never learns their comment leaked into a tree that mirrors it within the hour.The guard is inert until an operator populates
NEO_CONFIDENTIAL_TERMS. An unset repository secret renders as an empty string, which the guard reads as the explicit policy "there are no terms to guard" — so this PR ships the mechanism, and the list is an operator action. That is stated here rather than buried because a guard nobody populates looks exactly like a guard that works.Evidence: L3 (a real git-repository fixture — the guard refuses a seeded publication and the local origin's history is verified unchanged) → L1 only for the workflow
env:wiring, which no sandbox run can exercise. Residual: none — all eight ACs are delivered on this branch.AC Evidence
| AC-1 |
DataSyncPipeline.spec.mjs— a staged file carrying a denylisted term fails the run, and nothing is published; asserts both the rejection andremoteSubjects === ['initial']| | AC-2 | same spec — the failure names the file and WHICH rule fired, never the matched value; asserts the message contains the path,:4andterm #1, and.not.toContain(TERM)| | AC-3 | AC-1's arm is the gate proof: the assertion that survives is the absence of a publication commit, not the presence of a log line | | AC-4 | denylist is sourced fromNEO_CONFIDENTIAL_TERMS, never the tree; every arm uses the synthetic tokenzzsynthetictenantzz, and the pure arms useacme| | AC-5 | same spec — identity forms publish normally, exercising<person@TERM.com>and@TERMin one file and asserting{changed: true, pushed: true}; plus the unit arm covering both shapes against a private host | | AC-6 | the guard throws rather than skipping, so a false positive is a failed hourly run;findConfidentialProse's JSDoc records why that cost forces the list to stay narrow | | AC-7 | red-proof below | | AC-8 |learn/agentos/process/correction-culture.md— new section Correcting a public artifact: the window is one hour |Deltas from ticket
denylistis a required parameter with no default. The ticket did not ask for this; #17728 did, an hour before this branch. There, a test seam injected by parameter name failed OPEN when the callee renamed the parameter — the injection was silently ignored, the production default took over, and a unit run wrote a waking broadcast to every peer's mailbox. A defaulted[]here would reproduce that class exactly: rename the option later and every caller silently reverts to "guard nothing". A required parameter fails closed under that rename. Cost is six existing call sites now statingdenylist: [].Absent and empty are different states.
readConfidentialTermsthrows when the variable is missing and returns[]when it is empty. "Nobody configured this" and "there is nothing to guard" are different statements, and conflating them is how a guard silently protects nothing.The identity-form exemption is one character of lookbehind, not a pattern list. An identity form is always introduced by
@; prose never is. So<user@name.com>and@namepass, whilename-memory-coreandhttps://mcp.name.net/— a private host, the leak shape that motivated this — still fail. The census behind the ticket found identity forms outnumbering real leaks 2:1, so a guard that trips on them fails every hourly run and is switched off within a day.Scanning covers all staged paths, not only
resources/content/**. Same cost, strictly larger coverage; the portal's derived JSON is mirrored from the same artifacts.Substrate note:
correction-culture.mdis an ordinary process doc underlearn/agentos/, not directly loaded per turn, so it carries in-doc lifecycle rationale rather than a slot-disposition block. It extends an existing document rather than adding a sixth.Test Evidence
Red-proof (mutation). Commenting out the single
assertNoConfidentialContentcall and running the suite: 2 failed, 42 passed. The two reds are exactly the gate-firing arms, and both fail for the right reason rather than an adjacent one —expect(received).rejects.toThrow()because the run no longer rejects, andexpect(message).toContain(generatedFile)because no error is produced. Notably the identity-form arm stays green under the mutation, which is correct and is the check that the arm is not accidentally passing because of the guard.Guard restored, suite back to 44 passed.
Post-Merge Validation
Nothing is owed after merge, so there is no checklist here — deliberately, because both candidates fail the residual rule for the same reason and inventing owners for them would create exactly the dangling pointers that rule exists to prevent.
Populating the
NEO_CONFIDENTIAL_TERMSsecret is an operator action on a credential surface that no existing ticket owns; it is stated in the second paragraph, where a reader meets it before the diff rather than after. The first hourly run after merge exercising the addedenv:key is observation, not work: it happens whether or not anyone watches, and the workflow already fails loudly on a stage error.Commits
ed65cad303— the guard, its wiring, and its armsbbb4170e82— the author-facing one-hour window note (AC-8)Authored by Grace (Claude Opus 5, Claude Code). Session 728a756d-71df-48e6-8dad-0bac498ca23e.