Skip to content

fix(observability): let the sink write to the file it read - #57

Merged
rldyourmnd merged 1 commit into
mainfrom
feat/held-path-identity
Aug 14, 2026
Merged

fix(observability): let the sink write to the file it read#57
rldyourmnd merged 1 commit into
mainfrom
feat/held-path-identity

Conversation

@rldyourmnd

Copy link
Copy Markdown
Contributor

The defect

OpenJSONLSink opened the destination to scan the existing history, closed it, and opened it again to append. A rename between the two left the recovered duplicate-identity and size state describing the first file while every write went to the second.

The SameFile check that looked like it covered this proved the path equalled the descriptor at the second open — not that it was the file scanned.

The fix

One descriptor for the sink's lifetime: O_RDWR|O_APPEND|O_CREATE, validated, then read from and appended to. O_APPEND puts writes at the end regardless of the read offset, so validation and extension are the same object by construction. A destination whose name no longer resolves to the object just opened is refused rather than followed.

What the test does and does not do

The window lived inside OpenJSONLSink. Reaching it from outside would need a hook that exists only to be hooked — the kind of control this repository is trying to remove — so it is closed by construction and the test says so plainly rather than implying it reproduces the defect.

I wrote a behavioural test first and then found it did not discriminate: it renamed the path after the sink was open, which the old code survives too, because by then it also held a descriptor. The comment was corrected. What it now pins is that the construction cannot be silently undone.

Scope, deliberately narrowed

You confirmed untrusted commands are not a goal. The other two check-then-use surfaces — workspace resolution and executable resolution — need an actor with concurrent write access to the machine already running the Task, which the stated trust boundary excludes: manifests and commands are trusted reviewed inputs, and someone who can rewrite a PATH directory can do so with or without a race.

So they are recorded in SECURITY.md, not closed, including the consequence that executable_path records what was inspected, what an attacker would need, and the trigger that would make them real — running commands that are not fully trusted — along with the fix that would then apply. A stated gap is worth more than a control that does not remove it.

Verification

gofmt, go vet, staticcheck v0.7.0, full suite under umask 002, -race, all six contract checkers. All green. The now-dead scanJSONL wrapper is removed.

OpenJSONLSink opened the destination to scan the existing history, closed it,
and opened it again to append. A rename between the two left the recovered
duplicate-identity and size state describing the first file while every write
went to the second, so the sink's own idea of what it had already recorded no
longer belonged to the file it was extending. The SameFile check proved the path
equalled the descriptor at the second open — not that it was the file scanned.

The sink now opens once with O_RDWR|O_APPEND|O_CREATE and holds that descriptor
for its lifetime, reading the history from it. O_APPEND puts writes at the end
regardless of where reading left the offset, so validation and extension are one
object by construction. A destination whose name no longer resolves to the
object just opened is refused rather than followed.

The window itself is closed by construction rather than covered by a test.
Reaching it from outside would need a hook that exists only to be hooked, and
the accompanying test says so plainly instead of implying it reproduces the
defect; what it does is fail if the single-descriptor construction is undone.

The two remaining check-then-use gaps are recorded in the security model rather
than closed. Workspace resolution and executable resolution both validate a
pathname and act on it a moment later, so an actor with concurrent write access
to the running machine can move the used object away from the checked one —
which is also why executable_path records what was inspected. Both need write
access to a machine that is already running the Task, which the stated trust
boundary excludes: manifests and their commands are trusted, reviewed inputs,
and a party who can rewrite a PATH directory can do so with or without a race.
The entry names what would make them real and what the fix would be, so the
deferral is a decision with a trigger rather than an omission.
@rldyourmnd
rldyourmnd enabled auto-merge August 14, 2026 23:50
@rldyourmnd
rldyourmnd merged commit 1ab8bd8 into main Aug 14, 2026
4 checks passed
@rldyourmnd
rldyourmnd deleted the feat/held-path-identity branch August 14, 2026 23:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant