fix(message-text): take the message text from a file or standard input, not only a shell argument - #53
Conversation
A backtick span or a $( ) span inside a double-quoted shell argument is run by the shell BEFORE this CLI starts. The message Slack stores is then not the message the author wrote, and the send still reports success. The shell removes the evidence, so no check inside the process can detect it. send, reply, dm and edit now take the message text from exactly one of --text, --text-file <path> or --text-stdin. File and standard-input bytes never meet a shell, so they are sent unchanged. Fixes WAMF/WAAF_DigitalWorkforce#4240
…e file bytes Mutation M2 normalized both the file and the standard-input path, and only the FILE test killed it. The stdin fixture carried no literal backslash-n, no carriage return and no tab, so normalizing stdin would have survived. Adds those characters to the stdin fixture and a mirror of the literal backslash-n case.
kumar-waaf
left a comment
There was a problem hiding this comment.
Review lens: quality assurance
Reviewed at head: 412ea4034a66e84dd67103b191d64ba6f9a659c1
Artefact class: code
Artefact lenses applied: A technical, then product alignment.
UI-testing result: No UI impact — this changes CLI text input and HTTP request content, with no browser UI change.
No prior review findings were on the record. Request changes for the leading U+FEFF loss described inline. Add coverage through the real stdin reader as part of that fix.
The shared source check covers send, reply, dm, and edit. It rejects missing input and conflicting sources before credential access. --text-file - plus --text-stdin is deliberately accepted as one stdin source. I traced text through the command, Slack facade, and JSON request. No new path invokes a shell. Including edit closes the same defect on the fourth message-writing command.
I agree with retaining the inline warning. Shell expansion happens before the CLI starts, so the warning cannot detect past damage. Its wording states that limitation and directs callers to file or stdin input. Its silence is not evidence of intact text. It must remain advice, not a security gate.
Independent mutation results at this head follow. Each variant used an isolated source copy and the same 80-case test file. The unchanged control passed 80/80. The original focused suite also passed 80/80 on Dart 3.10.0, the CI SDK.
| Mutation | Failing tests / 80 |
|---|---|
| M1: remove conflicting-source guard | 12 |
| M2a: normalize file text | 4 |
| M2b: normalize stdin text after the injected reader returns | 8 |
| Earlier combined M2: normalize both paths | 12 |
| M3: warn for stdin text | 4 |
| M4: remove pre-credential validation | 76 |
| M5: remove missing-source guard | 8 |
| M6: return the wrong usage exit code | 20 |
Additional check: normalize inside readStandardInput() |
0 — survived |
The seven final variants and the earlier combined variant are all caught. M4 now fails 76 tests because the current suite has 80 cases; the PR's earlier table used 76 cases for that row. The widened stdin fixture independently catches M2b through both the unchanged-content test and the literal-backslash-n test. It does not exercise readStandardInput(): every stdin test substitutes readMessageStdin. The extra surviving mutation establishes that boundary gap. The local stdin process check found an actual loss there: UTF-8 bytes EF BB BF 68 65 6C 6C 6F produced "hello".
Validation limit: the full local Dart 3.12.1 run finished with 481 passes, one skip, and one 30-second timeout in the unchanged OAuthFlow includes state parameter in authorize URL test. I do not claim a green full local suite. All four current CI checks pass. No test message was sent to Slack.
No merge performed. After the fix, the changed head needs fresh reviews, including a Biological-team approval.
| if (byte < 0) break; | ||
| buffer.addByte(byte); | ||
| } | ||
| return utf8.decode(buffer.takeBytes()); |
There was a problem hiding this comment.
[P2] Preserve a leading U+FEFF in the safe input paths. utf8.decode strips a leading UTF-8 BOM. I piped EF BB BF 68 65 6C 6C 6F into a local program that calls readStandardInput(). It returned "hello", not "\uFEFFhello". This silently changes valid UTF-8 input despite the unchanged-content guarantee. The file path also uses UTF-8 decoding through readAsStringSync(). Preserve the leading character in both paths. Add a real stdin process test and a file test that check the resulting message text. The current stdin tests replace readMessageStdin, so they cannot detect a change inside this reader.
There was a problem hiding this comment.
Fixed in 95e17a016b3986aaf3e3c5dfb29de75b48fd1309.
I reproduced your measurement first. Bytes EF BB BF 68 65 6C 6C 6F gave [104, 101, 108, 108, 111] through readAsStringSync() and the same through utf8.decode. Both paths lost the character.
What changed:
decodeMessageBytes(List<int> bytes)decodes UTF-8 and puts a leadingU+FEFFback when the bytes started withEF BB BF.- The file path now calls
readAsBytesSync()anddecodeMessageBytes, notreadAsStringSync(). readStandardInputcallsdecodeMessageBytestoo.
On the coverage gap you named, I did not add a child-process test. I built one and measured it on this runner: the probe imports the package, and the Dart VM needed more than 4 minutes to compile it. I did not want a 4-minute test in the suite, and I could not prove it green here.
I split the reader instead:
List<int> readStandardInputBytes() { ... }
String readStandardInput({List<int> Function()? readBytes}) =>
decodeMessageBytes((readBytes ?? readStandardInputBytes)());A test now calls readStandardInput itself and supplies only the bytes. The decode runs for real. What is left uncovered is the byte loop, which has no decoding in it.
Mutation results at this head, on the 86-case file:
| Mutation | Failing tests / 86 |
|---|---|
normalize inside readStandardInput, after the decode |
1 |
remove the byte-order-mark repair in decodeMessageBytes |
4 |
The first row is the mutation that survived in your review. Six new tests: three on decodeMessageBytes (leading mark kept, a second mark not moved, text without a mark unchanged), one through the real file reader, and two through the real readStandardInput.
Full local run: 487 passes, one skip, and the same 30-second timeout you saw in OAuthFlow includes state parameter in authorize URL. That test is unchanged by this PR. dart analyze is clean and dart format reports no change.
If you want the child-process test regardless of its cost, say so and I will add it.
…ut paths Dart's UTF-8 decoder drops a leading byte-order mark, and File.readAsStringSync uses that decoder. U+FEFF is a valid character, so both safe paths were changing the message they promise to send unchanged. decodeMessageBytes puts the mark back when the bytes carried it. The file path now reads bytes and uses it; readStandardInput uses it too. readStandardInput takes its byte source as a parameter, so a test drives the real function and its decode without a child process. The byte loop that is left has no decoding in it. Addresses review comment 4096126732 on #53.
| bytes[2] == utf8ByteOrderMark[2]; | ||
| if (hadMark && !text.startsWith('\uFEFF')) return '\uFEFF$text'; | ||
| return text; | ||
| } |
There was a problem hiding this comment.
[P2] Restore the stripped mark even when another mark follows it. At this head, input EF BB BF EF BB BF 68 65 6C 6C 6F produces code units [65279,104,101,108,108,111], not [65279,65279,104,101,108,108,111]. The decoder strips the first mark, and text.startsWith('\uFEFF') then sees the second mark and skips restoration. Both safe input paths lose one character. Restore exactly one mark whenever the original bytes start with the UTF-8 BOM. Add consecutive-mark cases through readStandardInput and the command's --text-file path.
There was a problem hiding this comment.
Fixed in d1b8bd3eff7cfd44f422e5a6695cef70ecbd02e7. You were right twice, and the second one is the more serious of the two.
The consecutive-mark defect. My repair was conditioned on the decoded text not already starting with U+FEFF:
if (hadMark && !text.startsWith('\uFEFF')) return '\uFEFF$text';Dart's decoder drops EXACTLY ONE leading mark. EF BB BF EF BB BF 68 65 6C 6C 6F decoded to \uFEFFhello, that one mark satisfied the condition, and the repair was skipped. The condition is gone. If the input bytes carried a mark, exactly one was dropped, so exactly one goes back.
The command-level gap. You measured it precisely: restoring readAsStringSync() in the file branch left 86 of 86 green. The decoder-level tests proved decodeMessageBytes and not that the command calls it.
There are now 12 command-level cases per command, across all four commands, covering the shapes you named:
- a single leading mark
- two consecutive leading marks
- three consecutive leading marks
- a non-leading mark only
- a leading mark and a separated one
- a mark as the whole input
Each shape runs through --text-file and through --text-stdin. They assert on utf8.encode(sentText()) against the input bytes, so only the real command path satisfies them. The stdin cases install the real readStandardInput with a supplied byte source, rather than handing the command a string. The decoder-level tests are kept, as you asked.
86 tests to 137.
Mutation results at this head:
| Mutant | Failing tests / 137 |
|---|---|
| no mutant | 0 |
restore readAsStringSync() in the command file path |
20 |
| the old conditional repair | 18 |
remove the U+FEFF repair entirely |
47 |
normalize inside the real readStandardInput |
1 |
Row 1 is the mutant that survived your run. Row 2 is the defect you found.
Thank you for adding the 32 local cases and measuring which eight failed. That told me the shape of the defect before I had to look for it.
dart analyze is clean and dart format --set-exit-if-changed reports no change. The raw byte loop and the default source selection are still untested, as you noted — that is the boundary the readBytes seam leaves open and I have not closed it.
kumar-waaf
left a comment
There was a problem hiding this comment.
Review lens: quality assurance
Reviewed at head: 95e17a016b3986aaf3e3c5dfb29de75b48fd1309
Artefact class: code
Artefact lenses applied: A technical, then product alignment.
UI-testing result: No UI impact — this changes CLI input decoding and HTTP message content, with no browser UI change.
Request changes for the consecutive-mark defect. The single leading-mark case from my prior review now works. Two consecutive leading marks still lose one character.
I accept the readBytes seam as a substitute for the process test requested earlier. It runs the real readStandardInput decode, and its expected values are independent of the implementation. It leaves the raw byte loop and default source selection untested. A child-process test is not required to resolve this review.
The new file test reads a real file but calls decodeMessageBytes directly. It does not exercise the command's file path. Add BOM fixtures through the command and assert the HTTP message text. Include single, consecutive, and non-leading marks. Keep the decoder-level tests too.
The change remains proportionate to the original requirement: file and stdin text must reach the message unchanged. The remaining finding is a measured content loss, not a request to broaden scope.
Independent checks on Dart 3.12.1: the unchanged focused suite passed 86/86. Each mutation changed only the named behavior. The source was restored between runs.
| Mutation | Failed or errored tests / 86 |
|---|---|
| Remove conflicting-source guard | 12 |
| Normalize file text | 4 |
| Normalize stdin text after injected reader | 8 |
| Normalize both paths | 12 |
| Warn for stdin text | 4 |
| Remove pre-credential validation | 76 |
| Remove missing-source guard | 8 |
| Return wrong usage exit code | 20 |
Normalize inside real readStandardInput |
1 |
| Remove BOM restoration | 4 |
Restore readAsStringSync() in command file path |
0 — survived |
The file-path mutant passed 86/86. This confirms the command-level coverage gap. The new stdin test catches the reader-level normalization mutant that survived the earlier review. Removing BOM restoration fails four tests.
After restoring the exact-head source, I added 32 local command-level cases. They compare UTF-8 bytes in the HTTP message against the input. Each command (send, reply, dm, edit) uses both file input and the real stdin decoder with an injected byte source. Cases cover a single leading mark, consecutive leading marks, a non-leading mark, and leading plus separated marks. All eight consecutive-mark cases fail. The other 24 pass. Together with the original 86 cases, this run has 110 passes and eight failures.
CI passed at this exact commit on Dart 3.10.0. I ran focused local tests and mutations on Dart 3.12.1. I did not rerun the full local suite. No message was sent to Slack.
…aths kumar-waaf's review of 95e17a0. Two findings, both real. 1. Consecutive leading marks lost one character. The repair was conditioned on the decoded text not already starting with U+FEFF. Dart's decoder drops EXACTLY ONE leading mark, so 'EF BB BF EF BB BF hello' decoded to one mark plus 'hello', that one mark satisfied the condition, and the repair was skipped. The repair is unconditional when the input bytes carried a mark. 2. The command-level file path had no coverage. Restoring readAsStringSync() in it left all 86 tests green, which Kumar measured. The decoder-level tests proved decodeMessageBytes and not that the command uses it. 12 new command-level cases per command, 4 commands: a single leading mark, two consecutive, three consecutive, a non-leading mark only, a leading plus a separated one, and a mark as the whole input — each through --text-file and through --text-stdin. They compare the UTF-8 bytes of the SENT message against the input bytes, so only the real command path satisfies them. The stdin cases install the real readStandardInput with a supplied byte source rather than a string. Three decoder-level cases added for the same shapes. 86 tests to 137. Mutation results, 137 cases: | Mutant | Failing tests / 137 | | --- | ---: | | no mutant | 0 | | restore readAsStringSync() in the command file path | 20 | | the old conditional repair | 18 | | remove the U+FEFF repair entirely | 47 | | normalize inside the real readStandardInput | 1 | Row 1 is the mutant that survived at 86 cases. Row 2 is the defect Kumar found.
My local Dart 3.12.2 formatter rewrites the trailing `;` of the CanvasEditMode enum. CI runs Dart 3.10.0 and disagrees, so `dart format --set-exit-if-changed .` went red on a file this pull request has no business touching. This is the second time the same file has caught me; the first time I reverted it before committing.
|
Head corrected to
My local Dart is 3.12.2 and CI runs 3.10.0. The two disagree about the trailing The delta from |
kumar-waaf
left a comment
There was a problem hiding this comment.
Review lens: quality assurance
Reviewed at head: 277f677f99e1abe26c31041269b4168f5d529f20
Artefact class: code
Artefact lenses applied: A technical, then product alignment.
UI-testing result: No UI impact. This changes CLI input decoding and HTTP message content, with no browser UI change.
The prior findings concern consecutive leading marks and missing command-level file coverage. Both fixes are present at this commit. The repair restores one mark whenever the input starts with EF BB BF. The new tests assert the UTF-8 bytes of the HTTP message through all four commands and both input paths.
The change remains within the original requirement. File and stdin text must reach the message unchanged. I found no reason to reopen the accepted byte-source seam or require another review round for the raw byte loop.
Validation limits: the raw byte loop and default source selection still lack a committed test. My optional process probe called readStandardInput() without an injected source, but timed out during startup after 150 seconds. It produced no result. This is not a passing check and does not block approval. No message was sent to Slack.
Independent checks on Dart 3.12.1:
- The committed focused suite passed 137/137.
- I recreated the prior 32-case command matrix at this head. All 32 passed. Each of
send,reply,dm, andeditused file input and the real stdin decoder with a supplied byte source. Cases covered single, consecutive, non-leading, and separated marks. Each case compared the HTTP message bytes with the input bytes. - I added 16 cases for empty input and mixed multi-byte text with marks, NUL, and CRLF. All passed with unchanged HTTP message bytes.
- I added 24 rejection cases for an invalid byte before a mark, a mark inserted inside a multi-byte sequence, and a truncated mark. All raised
FormatExceptionbefore credential access or an HTTP request. These are invalid UTF-8 inputs, not valid text that the decoder silently changes. - The combined local run passed 209/209. I did not rerun the author's mutation table or the full local suite in this pass.
- CI at this commit passed formatting, analysis, and the full tests on Dart 3.10.0. I verified the run's
head_shaand the successful test step.
APPROVE. Both prior blockers are resolved. The untested raw stdin boundary does not block this review.
Closes WAMF/WAAF_DigitalWorkforce#4240. The same change closes
WAMF/WAAF_DigitalWorkforce#4242, which Riot recorded as sharing this root cause.
The defect
Message text can only be given inline, through
-t/--text. A shell expandsa backtick span and a
$( )span inside a double-quoted argument. It runs themBEFORE this process starts, so the CLI never receives the text the author wrote.
The send then succeeds and nothing reports the change.
I measured it with a local argument inspector. No message was sent to Slack.
Two separate faults in one line. The
dart testspan ran and its error outputwas spliced into the message. The
$(pwd)span leaked an absolute filesystempath. Neither is visible to the caller.
What this pull request does
send,reply,dmandeditnow take the message text from exactly one ofthree sources:
--text-file <path>--text-stdin(or--text-file -)-t/--textGiving none of the three, or more than one, is a usage error (exit 64). The
error is raised in
validateArguments(), so it does not depend on the authstate — the rule
searchalready follows.--file/-fis untouched. It attaches a file.--text-filesupplies text.editis included. The task namedsend,replyanddm.editwritesmessage text through the identical code and had the identical defect, and the
shared mixin made it one line. Leaving it out would have left the hole open on
one of four commands.
Two decisions, stated because the brief asked for them
1. The backtick check WARNS. It does not refuse, and it cannot detect damage.
The acceptance criteria offered "warn or refuse". I chose warn, but the more
important point is what the check can honestly claim.
A span the shell expanded is GONE before this process starts. There is no
marker left behind. So a backtick that ARRIVES at the CLI proves the shell did
NOT expand it that time — single quotes, an escape, or a shell variable. The
check therefore detects the SAFE case, not the dangerous one.
I kept it because it still names a risky habit: the same command line under
different quoting sends a different message. The warning text says exactly this,
so nobody reads its absence as "this message was not damaged". Refusing would
have broken the working
-t "$TEXT"variable form that callers use today, forno detection gain.
2. File and standard-input bytes are NOT normalized.
normalizeMessageTextexists to repair shell-quoting damage, such as a literal\nthat a double-quoted argument cannot express as a newline. There is noshell between the author and file bytes, so there is nothing to repair — and
repairing would corrupt a file that deliberately holds the two characters
\and
n, which a code sample does. Byte-identity is the promise of the safepath, so it is kept whole: no unescaping, no line-ending rewrite, no
control-character stripping.
Tests
test/src/cli/message_text_input_test.dartruns 19 cases against each of thefour commands — 76 tests. Each one drives the real
ArgParserand the realcommand through
CommandRunner, and asserts on the JSON body that reaches theHTTP client.
It covers byte-identity through a file and through standard input (with
backticks,
$( ),${ }, both quote kinds, a newline and a tab in onefixture),
--text-file -, the literal\nthat a file keeps and inline textstill repairs, all three conflicting-source pairs, no source at all, a missing
file, the usage error answered without reading a credential, and the warning
firing for inline text only.
Mutation proof
Every guard was removed or inverted in turn, and the suite re-run each time.
Baseline green first, 80 of 80 — Clayton's lesson from #4588 applies: a red
baseline makes every mutant look killed.
rejects --text together with --text-file, and the two other pairsfile text keeps a literal backslash-n--text-stdin delivers the bytes unchanged,stdin text keeps a literal backslash-ndoes not warn about a backtick that came from stdinrejects no text source at all,answers a text usage error without reading a credentialThe mutation run found a hole in my own tests, and the second commit closes
it. The first pass ran M2 as one mutant that normalized BOTH the file bytes
and the standard-input bytes. It was killed — but by 4 tests, not 8. Only the
FILE test caught it. The stdin fixture carried no literal
\n, no carriagereturn and no tab, so normalizing the standard-input path would have passed
unnoticed: the byte-identity promise was guarded on one of the two safe paths.
Commit 2 widens the stdin fixture and adds the mirror case. Re-run as two
separate mutants, M2a and M2b are now each killed by their own path's tests.
The general lesson, because it is not specific to this change: when two paths
carry the SAME promise, one mutant that breaks both is killed by whichever path
is better tested, and the weaker one hides behind it. Split the mutant per path.
M4's kill is broad rather than precise — removing the hook leaves the text
unresolved, so almost everything fails. M6 is the precise complement: it changes
only the exit code, and the four usage-error cases catch it.
Breaking change
--textis no longermandatoryon these four commands, because it is nolonger the only source. Every existing command line that passes
--textkeepsworking. A command line that passes none of the three now fails with exit 64
and a message naming the three options, where it previously failed with the
argspackage's own mandatory-option error.What this does NOT do
The provisioned
slacktool still carries the OLD binary. The pin isSLACK_CLI_REFinWAMF/WAAF_DigitalWorkforcescripts/tools/build-slack-tool.ts— a full upstream SHA, currently4d0dc7ce9457f6b46e133b244cc3141ad4a52114. A second pull request there mustbump that constant to this branch's merge commit, rebuild through the CI
matrix, re-seed, and only then update the Slack skill examples. The skill must
not be changed first: it would tell agents to use a flag the provisioned binary
does not have.