From 4edd95f6d4deaf022cb49e48d0bf53ce9d9468a9 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Mon, 31 Aug 2026 05:10:58 +0000 Subject: [PATCH] fix(hooks): signed head counts, /dev/null cat, last-wins here-string Treat -n+NUM as a transforming count, allow /dev/null as a byte-neutral cat operand, and scan only the effective fd-zero here-string. --- inventory/inventory-graph.json | 10 ++++---- .../pre-tool-use-template-source-ext.test.ts | 4 ++++ templates/hooks/pre-tool-use.mjs | 23 +++++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/inventory/inventory-graph.json b/inventory/inventory-graph.json index 6a83916e5..27bdfbada 100644 --- a/inventory/inventory-graph.json +++ b/inventory/inventory-graph.json @@ -5,15 +5,15 @@ "provenance": { "base": "05c800f40d1ad53b42a78609d2667ef4f726808b", "planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398", - "head": "d727152f1cc3b5421caf415754148425157aa69e", - "sourceSha256": "90df3fdf1e1f81b6433bffca600eae40f894b98b47cb554c8a47f86eefb50d7c", + "head": "4eee53f45e4d8004df7e85d33ef10c31ca0eb725", + "sourceSha256": "5a3f216e6eae61d33de2ea820286764fd8b1798329c9c54abdba97811dec6817", "generatedAt": null, "generator": "scripts/generate-inventory-graph.mjs" }, "base": "05c800f40d1ad53b42a78609d2667ef4f726808b", "planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398", - "head": "d727152f1cc3b5421caf415754148425157aa69e", - "sourceSha256": "90df3fdf1e1f81b6433bffca600eae40f894b98b47cb554c8a47f86eefb50d7c", + "head": "4eee53f45e4d8004df7e85d33ef10c31ca0eb725", + "sourceSha256": "5a3f216e6eae61d33de2ea820286764fd8b1798329c9c54abdba97811dec6817", "counts": { "public": { "skills": 35, @@ -76776,5 +76776,5 @@ } }, "inventorySha256": "f5ea9ebf29ba491f17b39431660e07f71eb3d78399d481c1c2b00fc34cf2ab50", - "manifestSha256": "4f7be07074304f82f195315f2c16907b3a38f1f3d230d5ec2bc8cd080526640c" + "manifestSha256": "2d925a7d24b87d34b5d763776a542311d4207792c373995bb1e832d8e6c430c2" } diff --git a/src/hooks/__tests__/pre-tool-use-template-source-ext.test.ts b/src/hooks/__tests__/pre-tool-use-template-source-ext.test.ts index 99cdc476e..3c3592f99 100644 --- a/src/hooks/__tests__/pre-tool-use-template-source-ext.test.ts +++ b/src/hooks/__tests__/pre-tool-use-template-source-ext.test.ts @@ -409,6 +409,7 @@ describe('pre-tool-use template source extension detection', () => { ['prefix file before stdin operand is not a transparent cat', "printf '%s\\n' 'rm src/app.ts' | cat /etc/hosts - | bash", false], ['suffix file after stdin operand is not a transparent cat', "printf '%s' 'rm src/app.ts' | cat - /etc/hosts | bash", false], ['later missing file after stdin operand is not modeled as passthrough', "printf '%s\\n' 'rm src/app.ts' | cat - -- -- | bash", false], + ['here-string then stdin redirect is not the effective program', "cat <<< 'rm src/app.ts' < /dev/null | bash", false], ['named coprocess writing only a log', 'coproc worker bash verify.sh > results.log', false], ] as const)('stays quiet: %s', (_label, command, expectedWarning) => { expect(hasDelegationNotice(runPreToolUseHook(command))).toBe(expectedWarning); @@ -505,6 +506,9 @@ describe('pre-tool-use template source extension detection', () => { ['no-op 0<&0 keeps pipeline stdin on cat', "printf '%s\\n' 'rm src/app.ts' | cat -u 0<&0 | bash", true], ['head -c slices stdin before the shell program', "head -c 13 <<'EOF' | bash\nrm src/app.tsJUNK\nEOF", true], ['cat here-string feeds a pipeline program', "cat <<< 'rm src/app.ts' | bash", true], + ['tail attached +count still transforms stdin', "tail -n+2 <<'EOF' | bash\n#\nrm src/app.ts\nEOF", true], + ['byte-neutral /dev/null cat operand still forwards stdin', "printf '%s\\n' 'rm src/app.ts' | cat - /dev/null | bash", true], + ['later here-string overrides earlier stdin redirect', "cat < /dev/null <<< 'rm src/app.ts' | bash", true], ['executing long option --noediting still runs -c', "bash --noediting -c 'rm src/app.ts'", true], ['stdin -n +n clears noexec', "printf '%s\\n' 'rm src/app.ts' | bash -n +n", true], ['explicit stdin shell heredoc source write', "bash -s <<'EOF'\necho hacked > src/app.ts\nEOF", true], diff --git a/templates/hooks/pre-tool-use.mjs b/templates/hooks/pre-tool-use.mjs index 7517458c1..8f10fbf0f 100644 --- a/templates/hooks/pre-tool-use.mjs +++ b/templates/hooks/pre-tool-use.mjs @@ -1137,11 +1137,11 @@ function isPassthroughStage(stage) { const value = entry.token.value; if (!optionsEnded) { if (value === '--') { optionsEnded = true; continue; } - if (/^-[u]+$/.test(value) || value === '-') continue; + if (/^-[u]+$/.test(value) || value === '-' || value === '/dev/null') continue; if (value.startsWith('-') && value !== '-') return false; return false; } - if (value === '-') continue; + if (value === '-' || value === '/dev/null') continue; return false; } return true; @@ -1462,13 +1462,22 @@ function checkPipelineProducer(stage, directory, command) { const raw = words.slice(cmd.index + 1).map(entry => entry.token); const operands = cmd.base === 'cat' || cmd.base === 'tac' ? argsAfter(words, cmd.index) : headTailOperands(raw); if (!operands || operands.some(token => token.dynamic) || operands.length > 0) return !operands; - if ((cmd.base === 'head' || cmd.base === 'tail') && raw.some(token => /^(?:-n|--lines|-c|--bytes)$/.test(token.value) || /^-[nc][0-9]+/.test(token.value) || /^(?:--lines|--bytes)=/.test(token.value))) return true; + if ((cmd.base === 'head' || cmd.base === 'tail') && raw.some(token => /^(?:-n|--lines|-c|--bytes)$/.test(token.value) || /^-[nc][+-]?[0-9]+/.test(token.value) || /^(?:--lines|--bytes)=/.test(token.value))) return true; + let hereWord = null; for (let k = 0; k < stage.length; k += 1) { - if (stage[k].type !== 'op' || stage[k].value !== '<<<') continue; - const word = stage[k + 1]; - if (word?.dynamic) return true; - if (word?.type === 'word' && checkBashCommand(word.value, directory)) return true; + const token = stage[k]; + if (token.type !== 'op' || token.kind !== 'in') continue; + const prev = stage[k - 1]; + const io = token.glued && prev?.type === 'word' && !prev.quoted && !prev.escaped && /^\d+$/.test(prev.value) ? Number(prev.value) : 0; + if (io !== 0) continue; + if (token.value === '<<<') { + hereWord = stage[k + 1]; + continue; + } + hereWord = null; } + if (hereWord?.dynamic) return true; + if (hereWord?.type === 'word' && checkBashCommand(hereWord.value, directory)) return true; return heredocSections(command).some(section => ( section.fd === 0 && Boolean(checkBashCommand(section.body, directory)) ));