Skip to content

fix(message-text): take the message text from a file or standard input, not only a shell argument - #53

Merged
LeeMatthewHiggins merged 5 commits into
mainfrom
riker/4240-message-text-as-data
Sep 25, 2026
Merged

LeeMatthewHiggins merged 5 commits into
mainfrom
riker/4240-message-text-as-data

Conversation

@riker-wamf

@riker-wamf riker-wamf commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

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 expands
a backtick span and a $( ) span inside a double-quoted argument. It runs them
BEFORE 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.

$ python3 inspect.py -t "Run `dart test` then check $(pwd)."
argv[1] = '-t'
argv[2] = 'Run No pubspec.yaml file found - run this command in your project folder. then check /home/riker/.dw/runner/workspaces/…/repro.'

Two separate faults in one line. The dart test span ran and its error output
was spliced into the message. The $(pwd) span leaked an absolute filesystem
path. Neither is visible to the caller.

What this pull request does

send, reply, dm and edit now take the message text from exactly one of
three sources:

Source Passes through a shell Bytes sent
--text-file <path> no exactly the file contents
--text-stdin (or --text-file -) no exactly the standard input
-t / --text YES normalized, as before

Giving 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 auth
state — the rule search already follows.

--file / -f is untouched. It attaches a file. --text-file supplies text.

edit is included. The task named send, reply and dm. edit writes
message 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, for
no detection gain.

2. File and standard-input bytes are NOT normalized.

normalizeMessageText exists to repair shell-quoting damage, such as a literal
\n that a double-quoted argument cannot express as a newline. There is no
shell 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 safe
path, so it is kept whole: no unescaping, no line-ending rewrite, no
control-character stripping.

Tests

test/src/cli/message_text_input_test.dart runs 19 cases against each of the
four commands — 76 tests. Each one drives the real ArgParser and the real
command through CommandRunner, and asserts on the JSON body that reaches the
HTTP client.

It covers byte-identity through a file and through standard input (with
backticks, $( ), ${ }, both quote kinds, a newline and a tab in one
fixture), --text-file -, the literal \n that a file keeps and inline text
still 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.

Mutant Result Killed by
M1 conflicting-source guard removed 12 failed / 76 rejects --text together with --text-file, and the two other pairs
M2a FILE bytes normalized 4 failed / 80 file text keeps a literal backslash-n
M2b STDIN bytes normalized 8 failed / 80 --text-stdin delivers the bytes unchanged, stdin text keeps a literal backslash-n
M3 warning fires for stdin too 4 failed / 76 does not warn about a backtick that came from stdin
M4 validation removed from the pre-credential hook 72 failed / 76 every case that resolves text
M5 missing-source guard removed 8 failed / 76 rejects no text source at all, answers a text usage error without reading a credential
M6 usage error returns the wrong exit code 20 failed / 80 all four usage-error cases

The 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 carriage
return 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

--text is no longer mandatory on these four commands, because it is no
longer the only source. Every existing command line that passes --text keeps
working. 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
args package's own mandatory-option error.

What this does NOT do

The provisioned slack tool still carries the OLD binary. The pin is
SLACK_CLI_REF in WAMF/WAAF_DigitalWorkforce
scripts/tools/build-slack-tool.ts — a full upstream SHA, currently
4d0dc7ce9457f6b46e133b244cc3141ad4a52114. A second pull request there must
bump 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.

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 kumar-waaf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/src/cli/message_text_input.dart Outdated
if (byte < 0) break;
buffer.addByte(byte);
}
return utf8.decode(buffer.takeBytes());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 leading U+FEFF back when the bytes started with EF BB BF.
  • The file path now calls readAsBytesSync() and decodeMessageBytes, not readAsStringSync().
  • readStandardInput calls decodeMessageBytes too.

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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 kumar-waaf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@riker-wamf

Copy link
Copy Markdown
Contributor Author

Head corrected to 277f677f99e1abe26c31041269b4168f5d529f20.

build went red on dart format --set-exit-if-changed ., and not on anything in this change:

Formatted lib/src/slack.dart
Formatted 97 files (1 changed) in 0.20 seconds.

My local Dart is 3.12.2 and CI runs 3.10.0. The two disagree about the trailing ; of the CanvasEditMode enum in lib/src/slack.dart, a file this pull request has no business touching. My dart format lib test picked it up and I committed it. Reverted.

The delta from d1b8bd3 is that one revert and nothing else. @kumar-waaf review 277f677.

@kumar-waaf kumar-waaf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and edit used 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 FormatException before 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_sha and the successful test step.

APPROVE. Both prior blockers are resolved. The untested raw stdin boundary does not block this review.

@LeeMatthewHiggins
LeeMatthewHiggins merged commit 5871628 into main Sep 25, 2026
4 checks passed
@LeeMatthewHiggins
LeeMatthewHiggins deleted the riker/4240-message-text-as-data branch September 25, 2026 13:57
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.

3 participants