Repository navigation
Description
Before submitting
- I searched open and closed issues and did not find a report of this problem.
- I reviewed this report and removed credentials, tokens, private paths, hostnames, and other sensitive data.
Problem
binaryDigest() in lib/gentle-ai-binary.ts reuses a cached sha256 while the file's identity dev:ino:size:mtimeNs:ctimeNs is unchanged. The comment above it assumes that "any rewrite changes ctime". That does not hold on kernels with coarse inode timestamps: Linux advances inode mtime/ctime in jiffies (4 ms with CONFIG_HZ=250), so a same-size, in-place rewrite within one tick keeps the whole identity, and binaryDigest() returns the digest of the previous content.
Visible effect: tests/native-review-refusal-identity.test.ts fails deterministically on such hosts (both the runtime and typescript flavors, on Node 22 and Node 24), while CI passes. The probe rewrites the executable in place with # identity A and # refusing B, which have the same length, within one tick. binaryDigest() returns digest(A) for B, the refusal cache in lib/native-review-cli.ts (around lines 510-532) stores the wrong digest, and the last call re-sends --non-blocking-findings instead of reusing the cached refusal.
The same cached digest also backs the runtime integrity check of the gentle-ai binary against its manifest (lib/gentle-ai-binary.ts, around line 425) and the capability cache in lib/native-review-cli.ts (around lines 2527-2535). There the practical impact is low, because it needs an in-place, same-size swap within one clock tick of a previous digest, but the documented guarantee does not hold.
Steps to reproduce
- Use a Linux host with coarse kernel timer granularity (for example
CONFIG_HZ=250). - On
main(b1af8a493), run:
HOME=$(mktemp -d) node --experimental-strip-types --test tests/native-review-refusal-identity.test.ts
It fails on every run with the flags assertion below. - Minimal proof that the identity collides: in a loop, write
#!/bin/sh\n# identity A\nexit 0\nto a file, takelstatSync(f, {bigint: true}), overwrite it in place with#!/bin/sh\n# refusing B\nexit 0\n, and comparedev:ino:size:mtimeNs:ctimeNs. On the reporting host the identity was identical in 193/200 iterations on ext4 and in 195/200 on tmpfs, so the cause is clock granularity, not the filesystem.
Expected and actual behavior
Expected: a rewritten binary is always hashed again, as the comment states, so the refusal cache, the capability cache and the integrity check never act on a stale digest, and the test passes regardless of kernel timer granularity.
Actual: on coarse-clock hosts, a same-size in-place rewrite within one tick reuses the previous digest, and native-review-refusal-identity fails deterministically.
Why CI passes (inferred, not verified on the runner): the GitHub-hosted runner kernel likely has finer timestamp granularity, so the two writes land in different ticks.
Suggested fix (the same idea as git's "racy index" rule): do not cache a digest when the file's ctime/mtime is within a timestamp-granularity margin of the moment the digest was computed, so a file changed "just now" is always hashed again. Giving A and B different lengths in the probe would make the test pass but hide the production assumption, so the fix belongs in binaryDigest(), with a regression test that pins the same-tick, same-size rewrite.
Happy to send the fix if this is approved.
gentle-pi version
main @ b1af8a4
Pi version
1.5.2
Operating system
Linux
Relevant logs or error output (optional)
not ok - S1/S3 old refusal cannot overwrite newer valid refusal
Expected values to be strictly deep-equal:
+ actual - expected
[ true, true, false, false,
+ true,
- false
]
Activity