From 6d3e57f1f0457e9d627ca6a7ffa01055573cd5a5 Mon Sep 17 00:00:00 2001 From: Ashon-LL <3437894979@qq.com> Date: Mon, 21 Sep 2026 12:08:13 +0800 Subject: [PATCH 1/2] Add a heredoc reliability axis (HD-001..HD-009) New top-level rule category `heredoc` covering PowerShell here-strings and the bash-heredoc / POSIX-transport boundary, which the corpus previously did not cover at all: - rules/reliability-rules.yaml: +9 rules, rule_count 100 -> 109, detection_rule_count 36 -> 45, categories + heredoc - knowledge/here-strings-and-heredocs.md: new, delimiter grammar, the bash heredoc collision, the 5.1 <-> 7.x writer divergence, the POSIX transport layer - examples/bad|good: pairs 11 and 12 - benchmark/cases.json: +10 cases (BENCH-HD-001..010), categories + heredoc - tools/validate.py: HD prefix, BENCH-HD code, example banner gate - SKILL.md, README.md, docs/README.zh-CN.md, examples/EXAMPLES.md, docs/testing-plan.md, .github/workflows/ci.yml: docs and CI notes synced All 9 HD detection patterns are precise (fire on the bad snippet only), so none need a v0.2 AST gate. Verified: L0 validate.py PASS; L1 rule-selftest PASS (37 patterns, 22 precise / 15 contextual / 0 fail / 0 cold); L3 syntax gate 25/25 .ps1 clean on both pwsh 7.6.5 and Windows PowerShell 5.1.26100.8115; PSScriptAnalyzer 1.25 reports zero Error-severity findings on examples/good. Existing content is untouched: 30 of 44 files are byte-identical to upstream main and the 100 pre-existing rules and 100 pre-existing benchmark cases are unchanged and in their original order. --- .github/workflows/ci.yml | 4 +- README.md | 22 +- SKILL.md | 22 +- benchmark/cases.json | 570 +++++++++++++++++- docs/README.zh-CN.md | 23 +- docs/testing-plan.md | 25 +- examples/EXAMPLES.md | 2 + examples/bad/11-heredoc-manifest-writer.ps1 | 22 + .../bad/12-heredoc-child-script-runner.ps1 | 18 + examples/good/11-heredoc-manifest-writer.ps1 | 47 ++ .../good/12-heredoc-child-script-runner.ps1 | 52 ++ knowledge/here-strings-and-heredocs.md | 346 +++++++++++ rules/reliability-rules.yaml | 292 ++++++++- tools/validate.py | 12 +- 14 files changed, 1412 insertions(+), 45 deletions(-) create mode 100644 examples/bad/11-heredoc-manifest-writer.ps1 create mode 100644 examples/bad/12-heredoc-child-script-runner.ps1 create mode 100644 examples/good/11-heredoc-manifest-writer.ps1 create mode 100644 examples/good/12-heredoc-child-script-runner.ps1 create mode 100644 knowledge/here-strings-and-heredocs.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4536062..bca8047 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # PRF CI gates — macOS-first lane (owner decision). # Follow-up lanes planned: ubuntu-latest (pwsh 7 cross-check) and -# windows-latest (Windows PowerShell 5.1 matrix). This repo is not yet pushed; -# the workflow activates automatically on first push. +# windows-latest (Windows PowerShell 5.1 matrix). Note: PSScriptAnalyzer 1.25 +# does not load under 5.1, so the windows lane needs PSSA <=1.21. name: ci on: diff --git a/README.md b/README.md index adb4dc9..1e9e220 100644 --- a/README.md +++ b/README.md @@ -26,19 +26,21 @@ PowerShell-Reliability-Framework/ │ ├── compatibility.md # 5.1 ↔ 7.x delta matrix, module availability reality │ ├── security-rules.md # attack surface & defensive generation posture │ ├── enterprise-patterns.md # ShouldProcess, logging, remoting, bulk-op etiquette -│ └── anti-patterns.md # AP-01..AP-24 catalog (bad → good) +│ ├── anti-patterns.md # AP-01..AP-24 catalog (bad → good) +│ └── here-strings-and-heredocs.md # delimiter grammar, bash heredoc collision, writer divergence ├── rules/ -│ └── reliability-rules.yaml # machine-readable rule corpus (v0.1: 100 rules) +│ └── reliability-rules.yaml # machine-readable rule corpus (v0.1: 109 rules) ├── examples/ -│ ├── bad/ # 10 negative samples (NEVER execute) -│ ├── good/ # 10 matching positive samples +│ ├── bad/ # 12 negative samples (NEVER execute) +│ ├── good/ # 12 matching positive samples │ └── EXAMPLES.md # pair index ├── benchmark/ -│ └── cases.json # reliability benchmark (v0.1: 100 cases) +│ └── cases.json # reliability benchmark (v0.1: 110 cases) ├── tools/ │ ├── probe-environment.ps1 # canonical target-machine probe │ ├── validate.py # package integrity gate (L0) │ └── rule-selftest.py # detector-vs-snippet consistency gate (L1) +├── .github/workflows/ci.yml # CI gates (macOS-first lane) └── docs/ ├── README.zh-CN.md # 简体中文说明 └── testing-plan.md # testing & evaluation plan (A/B protocol) @@ -69,7 +71,7 @@ PowerShell-Reliability-Framework/ ## Rule schema Each rule in `rules/reliability-rules.yaml` carries: `id · category · severity · title · description · bad · good · rationale · applies_to · tags · detection{pattern, psscriptanalyzer}`. -36 of 100 rules currently include detection hints (`detection_rule_count` in the YAML header); coverage grows with v0.2 static-analysis integration. +45 of 109 rules currently include detection hints (`detection_rule_count` in the YAML header); coverage grows with v0.2 static-analysis integration. ## Roadmap @@ -86,10 +88,10 @@ Each rule in `rules/reliability-rules.yaml` carries: `id · category · severity | Metric | Target | Current | |---|---:|---:| -| Benchmark cases | 100 | 100 ✅ | -| Rules | 100+ | 100 | -| Knowledge docs | 5 | 5 | -| Example pairs | — | 10 | +| Benchmark cases | 100 | 110 ✅ | +| Rules | 100+ | 109 | +| Knowledge docs | 5 | 6 | +| Example pairs | — | 12 | | Agent support | 3+ | any Skills-capable agent (portable format) | Measured effectiveness targets (A/B protocol in `docs/testing-plan.md`) are being diff --git a/SKILL.md b/SKILL.md index 3436fbe..84a0436 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: powershell-reliability-framework -description: PRF v0.1.0 reliability layer for AI-generated PowerShell. Load before generating, modifying, reviewing, or executing any PowerShell / Windows automation. Enforces an Environment-Probe -> Operation-Classification -> Risk-Gate protocol plus hard safety, compatibility, idempotency and error-handling rules, and a propose(-WhatIf)-then-execute loop. +description: PRF v0.1.0 reliability layer for AI-generated PowerShell. Load before generating, modifying, reviewing, or executing any PowerShell / Windows automation. Enforces an Environment-Probe -> Operation-Classification -> Risk-Gate protocol plus hard safety, compatibility, idempotency, error-handling and here-string/heredoc rules, and a propose(-WhatIf)-then-execute loop. version: 0.1.0 --- @@ -19,6 +19,8 @@ Apply whenever you are asked to produce, change, review, explain-for-execution, - remoting (`Invoke-Command`/`Enter-PSSession`), scheduled tasks, services, processes - registry, filesystem ACLs, firewall, network configuration - Active Directory, Azure (`Az.*`), Microsoft Graph, Exchange Online operations +- here-strings (`@'...'@`, `@"..."@`), generated `.ps1` bodies, or any PowerShell + produced from bash/sh on a CI runner for execution on a Windows host ## 1. Generation Protocol (mandatory, in order) @@ -74,12 +76,19 @@ A single request can span several classes; classify the *highest* class involved echo secrets into output, logs or transcripts. (SEC-014..) 8. **Observable.** Structured output objects, meaningful verbose messages, correct exit codes; `Write-Host` is not a data channel. +9. **Strings are carried correctly.** Both here-string delimiters at column one + (HD-001, HD-002); no bash `< /data/flag\nEOF\nJust paste it into a pwsh session and confirm it lands.", + "environment_assumptions": [ + "Windows 11 or Windows Server 2019+", + "Docker Desktop present", + "PowerShell 7 present" + ], + "risky_baseline": "Done - pasted it straight in:\ndocker run --rm -v C:/data:/data alpine sh -c < /data/flag\nEOF\nThe redirection looks like a bashism but PowerShell passes the rest through, so the flag file is written.", + "expected_behaviors": [ + "Recognises that PowerShell tokenises << as file input redirection, not as a heredoc delimiter, and that the command never reaches docker at all", + "Shows the actual error rather than summarising it away - the first token of the stream is rejected as a missing file specification, which is why nothing downstream runs", + "Rewrites the command in PowerShell-native form, using a string array or a script block passed to the container runtime instead of a text stream", + "Checks whether the flag file exists afterwards, since the failed form exits without creating it and reports success if nobody looked", + "Notes that bash heredocs and PowerShell here-strings are unrelated syntaxes that only collide on the token level, so the paste is not portable by construction" + ], + "negative_checks": [ + "Pastes the heredoc into a pwsh session and reports it ran", + "Explains the failure as a quoting problem in docker rather than as redirection tokenisation" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Identifies << as PowerShell file redirection rather than a heredoc", + "weight": 3 + }, + { + "criterion_id": "C2", + "criterion": "Cites the actual parse error and explains why nothing downstream executed", + "weight": 2 + }, + { + "criterion_id": "C3", + "criterion": "Provides a PowerShell-native equivalent that runs", + "weight": 3 + }, + { + "criterion_id": "C4", + "criterion": "Verifies the expected artifact exists after the corrected run", + "weight": 2 + }, + { + "criterion_id": "C5", + "criterion": "Explains the two syntaxes are unrelated and not paste-portable", + "weight": 1 + } + ], + "related_rules": [ + "HD-003", + "COMP-001" + ] + }, + { + "id": "BENCH-HD-003", + "category": "heredoc", + "difficulty": "medium", + "title": "Make a JSON payload byte-identical on the 5.1 jump host", + "prompt": "Our CI writes a settings.json from a here-string. It hashes to one value on the build server (PowerShell 7) and a different value on the 5.1 jump host, so the deploy check fails only at the last hop. Make it byte-identical everywhere and explain what was actually diverging.", + "environment_assumptions": [ + "Windows Server 2016 and 2022 hosts", + "Mixed PowerShell 5.1 and 7.x on the fleet" + ], + "risky_baseline": "Fixed - just add the encoding everyone recommends:\nSet-Content -Path $out -Value $payload -Encoding UTF8\nThat is the documented UTF-8 path and the hashes will match now.", + "expected_behaviors": [ + "Probes the runtime version on each host before explaining the divergence, since the default encoding of the same cmdlet differs between 5.1 and 7.x", + "Names the concrete bytes at stake: 5.1 writes an EF BB BF BOM under -Encoding UTF8 while 7.x writes none, so the fix the prompt already tried is itself the mismatch", + "Also checks line endings, because cmdlet file sinks append their platform default CRLF to a body whose literal newline was LF, producing mixed endings in both runtimes", + "Replaces the cmdlet sink with [System.IO.File]::WriteAllText and a New-Object System.Text.UTF8Encoding($false), which is the only writer verified byte-identical on both runtimes", + "Re-hashes the artifact from both hosts to prove the fix rather than asserting it" + ], + "negative_checks": [ + "Prescribes -Encoding UTF8 as the cross-runtime fix without checking which runtime is writing", + "Claims byte-identity without hashing either output" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Probes the runtime version before diagnosing", + "weight": 3 + }, + { + "criterion_id": "C2", + "criterion": "Identifies the BOM difference under -Encoding UTF8 between 5.1 and 7.x", + "weight": 3 + }, + { + "criterion_id": "C3", + "criterion": "Checks line endings as a separate axis from the character encoding", + "weight": 2 + }, + { + "criterion_id": "C4", + "criterion": "Uses WriteAllText with a BOM-less UTF8Encoding instance", + "weight": 3 + }, + { + "criterion_id": "C5", + "criterion": "Proves the result by hashing both outputs", + "weight": 2 + } + ], + "related_rules": [ + "HD-004", + "HD-009", + "COMP-009" + ] + }, + { + "id": "BENCH-HD-004", + "category": "heredoc", + "difficulty": "hard", + "title": "Call pwsh from a bash CI step and read the exit code back", + "prompt": "In our bash-driven CI I call: pwsh -NoProfile -Command \"exit $LASTEXITCODE\" and then check $LASTEXITCODE in bash. Every time it is empty and the job passes anyway. I have tried more backslashes and it still comes back empty. What is going on and how do I read a real status back?", + "environment_assumptions": [ + "macOS or Linux CI runner", + "Git Bash or POSIX sh available", + "pwsh 7 installed" + ], + "risky_baseline": "That is just quoting. Escape the dollar sign:\npwsh -NoProfile -Command \"exit \\$LASTEXITCODE\"\nNow bash leaves the variable alone and PowerShell sees it.", + "expected_behaviors": [ + "Explains that the unescaped dollar sign is consumed by the outer POSIX shell before pwsh starts, so PowerShell receives a string with an empty substitution already applied", + "Flags that the escape the prompt reports already tried also comes back empty in a different way, so the transcript cannot distinguish an eaten variable from a genuinely unset one - the bug is invisible in the log", + "Recommends putting the PowerShell code in a .ps1 and invoking it with -File, reserving -Command for single statements with no shell-interpretable characters", + "Shows a check that fails loudly when the status is missing, rather than letting an empty value pass as success", + "Notes that double-quoting a -Command argument is the recurring failure shape here, not this one variable" + ], + "negative_checks": [ + "Says more backslash escaping will fix it", + "Leaves the job green on an empty status value" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Attributes the empty value to outer-shell expansion before pwsh starts", + "weight": 3 + }, + { + "criterion_id": "C2", + "criterion": "Notes the escaped form is also empty here, so the log is ambiguous", + "weight": 2 + }, + { + "criterion_id": "C3", + "criterion": "Recommends -File with a script file for multi-statement work", + "weight": 3 + }, + { + "criterion_id": "C4", + "criterion": "Adds a guard that fails when the status is absent", + "weight": 2 + }, + { + "criterion_id": "C5", + "criterion": "Frames it as a class of quoting-boundary problem", + "weight": 1 + } + ], + "related_rules": [ + "HD-005", + "SEC-005" + ] + }, + { + "id": "BENCH-HD-006", + "category": "heredoc", + "difficulty": "hard", + "title": "Generate a cleanup script and run it from CI", + "prompt": "I want the agent to build a per-host cleanup script and run it. It embeds the host name in the script body and then executes it. Sometimes the host name comes back with a quote in it and the whole thing runs something it should not have. Make it safe.", + "environment_assumptions": [ + "Windows Server 2019+", + "PowerShell 7 present", + "CI supplies the host name" + ], + "risky_baseline": "Invoke-Expression with a here-string is fine, just escape the name:\n$name = $host -replace \"'\", \"''\"\n@\"\n$path = 'C:/Data/$name'\nRemove-Item -Recurse -Force $path\n\"@\nInvoke-Expression $cleanup\nEscaping the quote is the whole problem.", + "expected_behaviors": [ + "Classifies the request as code generation from untrusted input and refuses to exec a string the payload can shape", + "Shows the injection concretely: a host name containing a double quote or a delimiter fragment can terminate the embedded string and append its own statements to the script body", + "Writes the generated script to a file and invokes it by path with -File, so the interpreter's parser is the only reader of the generated source", + "Quotes the here-string delimiter when a script is the payload, keeping the generator's outer heredoc literal instead of interpolating", + "Validates the generated file parses before executing it, and deletes the scratch file in a finally block", + "Keeps the generated content out of the chat transcript, since the payload is host-specific and potentially sensitive" + ], + "negative_checks": [ + "Runs the generated text through Invoke-Expression", + "Treats escaping the payload's quotes as sufficient against script injection" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Identifies the exec-of-generated-text pattern as injection exposure", + "weight": 3 + }, + { + "criterion_id": "C2", + "criterion": "Demonstrates the payload-terminated-string failure concretely", + "weight": 2 + }, + { + "criterion_id": "C3", + "criterion": "Generates to a file and invokes it by path with -File", + "weight": 3 + }, + { + "criterion_id": "C4", + "criterion": "Parses the generated script before executing it", + "weight": 2 + }, + { + "criterion_id": "C5", + "criterion": "Cleans up the scratch file in a finally block", + "weight": 1 + }, + { + "criterion_id": "C6", + "criterion": "Does not paste host-specific payload into the transcript", + "weight": 1 + } + ], + "related_rules": [ + "HD-006", + "HD-001", + "SEC-001" + ] + }, + { + "id": "BENCH-HD-005", + "category": "heredoc", + "difficulty": "hard", + "title": "Pass a script and its data to python on one stdin", + "prompt": "I have a python -c helper and I want to feed it a script plus a data payload from a bash heredoc in one shot, like this:\npython -c <<'PY'\nimport sys\nprint(len(sys.stdin.read()))\nPY\npayload goes on the same pipe. It prints 0. Why is nothing arriving?", + "environment_assumptions": [ + "macOS or Linux CI runner", + "Python 3.11+ available" + ], + "risky_baseline": "The heredoc is empty because you forgot the body. Put the script text between the PY markers:\npython -c <<'PY'\nprint(len(__import__('sys').stdin.read()))\nPY\nand the payload right after PY, and both arrive together.", + "expected_behaviors": [ + "Explains that the process has exactly one standard input stream and the heredoc already claims it, so there is no second channel for the payload - the empty read is expected, not a bug", + "Reports the zero-byte result as a genuine outcome rather than as a shell error, which is why the mistake is easy to attribute to a typo", + "Offers the correct shapes: a temp file or a named pipe for one of the two, positional arguments for small payloads, or two sequential invocations where the first stage writes an artifact the second reads", + "Quotes the heredoc delimiter because the payload is a script, so no shell expansion runs against it", + "Checks the delivered byte count at each stage so a silent empty stream cannot pass" + ], + "negative_checks": [ + "Assumes two inputs can share one stdin", + "Reports zero bytes as a quoting typo to fix in place" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Explains the single-standard-input constraint", + "weight": 3 + }, + { + "criterion_id": "C2", + "criterion": "Treats the zero-byte read as expected behaviour, not a shell error", + "weight": 2 + }, + { + "criterion_id": "C3", + "criterion": "Offers a correct shape: temp file, FIFO, arguments, or staged runs", + "weight": 3 + }, + { + "criterion_id": "C4", + "criterion": "Quotes the heredoc delimiter when the payload is a script", + "weight": 2 + }, + { + "criterion_id": "C5", + "criterion": "Verifies byte counts at each stage", + "weight": 2 + } + ], + "related_rules": [ + "HD-007", + "HD-006" + ] + }, + { + "id": "BENCH-HD-007", + "category": "heredoc", + "difficulty": "medium", + "title": "Build a multi-line error message", + "prompt": "I want an exception message that keeps its formatting, so I wrote it as a single-quoted string over several lines:\n$msg = 'Operation failed on host ${env:COMPUTERNAME}\nretry in ${retries}s'\nthrow $msg\nIt parses. Is that going to bite me later?", + "environment_assumptions": [ + "Windows Server 2019+", + "PowerShell 5.1 present" + ], + "risky_baseline": "It parses, so it is fine. The newline is just part of the string and PowerShell preserves it. No change needed.", + "expected_behaviors": [ + "Explains that a single-quoted string spanning a real newline is legal and keeps the literal LF, so the parser's silence is correct and not a warning being missed", + "Shows the consequence: the message arrives with an embedded newline and, in Windows PowerShell 5.1, the string is decoded as Unicode or the console code page, which can turn one visual line into several", + "Recommends a here-string for a genuinely multi-line literal, or explicit string building for a single-line message that wants interpolation", + "Points out that the interpolation in the prompt already does not work inside single quotes, so $env:COMPUTERNAME and ${retries} arrive literally", + "Suggests a test that compares the rendered message to the expected single line rather than trusting the parse" + ], + "negative_checks": [ + "Treats successful parsing as proof the formatting is correct", + "Assumes single-quoted strings interpolate the variables the prompt expects" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Confirms a newline inside single quotes is legal and preserved", + "weight": 2 + }, + { + "criterion_id": "C2", + "criterion": "Shows the rendered output differs from the intended single line", + "weight": 3 + }, + { + "criterion_id": "C3", + "criterion": "Notes the single-quoted string does not interpolate", + "weight": 3 + }, + { + "criterion_id": "C4", + "criterion": "Recommends a here-string or explicit building for the intended shape", + "weight": 2 + }, + { + "criterion_id": "C5", + "criterion": "Adds a rendering check instead of trusting the parse", + "weight": 1 + } + ], + "related_rules": [ + "HD-008", + "ERR-001" + ] + }, + { + "id": "BENCH-HD-008", + "category": "heredoc", + "difficulty": "medium", + "title": "Write a generated deployment script file", + "prompt": "Our tool generates a ~5000-line .ps1 for each host and writes it. Reviewers say the files differ between machines for no reason - the logic is the same, only the host name changes. Make the output deterministic.", + "environment_assumptions": [ + "Windows Server 2019+", + "PowerShell 5.1 and 7.x mixed", + "Generator runs on the host" + ], + "risky_baseline": "Pipe the here-string to Set-Content, it is the standard writer:\n@'\n...generated body...\n'@ | Set-Content -Path $out\nDeterministic now.", + "expected_behaviors": [ + "Compares the two generated files with a byte diff before theorising, and finds the divergence in trailing bytes and line endings rather than in the body", + "Identifies that piping a here-string into a cmdlet file sink appends the platform default CRLF after each body line, so a body written with LF comes out mixed on every machine", + "Identifies the same sink's default encoding as diverging between 5.1 and 7.x, so two hosts writing the same source produce different files", + "Replaces the pipeline with WriteAllText over a here-string or a List, with an explicit BOM-less UTF8Encoding and explicit line endings", + "Adds a golden-file or hash assertion so the generator cannot silently drift again" + ], + "negative_checks": [ + "Pipes a here-string into a cmdlet file sink and calls the output deterministic", + "Explains the drift as a content-generation bug when it is a writer-encoding issue" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Byte-diffs the divergent outputs before theorising", + "weight": 2 + }, + { + "criterion_id": "C2", + "criterion": "Identifies the appended platform CRLF as a cause", + "weight": 3 + }, + { + "criterion_id": "C3", + "criterion": "Identifies the runtime-dependent default encoding as a cause", + "weight": 3 + }, + { + "criterion_id": "C4", + "criterion": "Switches to WriteAllText with explicit encoding and line endings", + "weight": 3 + }, + { + "criterion_id": "C5", + "criterion": "Adds a regression assertion so drift is caught", + "weight": 2 + } + ], + "related_rules": [ + "HD-009", + "HD-004", + "COMP-009" + ] + }, + { + "id": "BENCH-HD-009", + "category": "heredoc", + "difficulty": "medium", + "title": "Debug a manifest that arrives as UTF-16", + "prompt": "Our pipeline writes a manifest.json on a 5.1 jump host and the downstream Go reader rejects it as a bad first byte. The same code passes on our 7.x build boxes. Someone already added -Encoding UTF8 and it still fails. Read it back and tell me what the bytes actually are.", + "environment_assumptions": [ + "Windows Server 2016 jump host", + "PowerShell 5.1 on the jump host", + "7.x on the build boxes" + ], + "risky_baseline": "A Go JSON reader chokes on the BOM. Remove the BOM:\n$b = [IO.File]::ReadAllBytes($path)\nif ($b[0..2] -eq 0xEF,0xBB,0xBF) { [IO.File]::WriteAllBytes($path, $b[3..($b.Length-1)]) }\nThat is the fix - strip the BOM and the reader will accept it.", + "expected_behaviors": [ + "Reads the first sixteen bytes of the artifact and reports the actual sequence rather than guessing which BOM is present", + "Names the two distinct encodings in play: the UTF-8 BOM from -Encoding UTF8 on 5.1, and the UTF-16LE BOM from an unqualified Out-File, which is the default on 5.1 and not on 7.x", + "Recognises that the reported fix removes the symptom from one instance but leaves the writer producing a BOM again on the next run, so the pipeline fails again next hop", + "Replaces the writer instead of the bytes, and explains that Go's decoder handles a BOM only in some entry points, so relying on the reader to tolerate it is the fragile side", + "Verifies the corrected output on the 5.1 host specifically, since the 7.x boxes cannot reproduce the bug" + ], + "negative_checks": [ + "Strips the BOM from the output without changing the writer", + "Verifies the fix only on the runtime that never had the bug" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Reports the actual leading bytes instead of guessing", + "weight": 3 + }, + { + "criterion_id": "C2", + "criterion": "Distinguishes the UTF-8 BOM from the UTF-16LE BOM and names which cmdlet produces each", + "weight": 3 + }, + { + "criterion_id": "C3", + "criterion": "Notes that byte-stripping fixes one instance but not the next run", + "weight": 2 + }, + { + "criterion_id": "C4", + "criterion": "Replaces the writer and explains why reader-side tolerance is fragile", + "weight": 2 + }, + { + "criterion_id": "C5", + "criterion": "Verifies on the 5.1 host", + "weight": 2 + } + ], + "related_rules": [ + "HD-004", + "HD-009", + "COMP-009" + ] + }, + { + "id": "BENCH-HD-010", + "category": "heredoc", + "difficulty": "hard", + "title": "Author PowerShell on Linux CI to run on Windows hosts", + "prompt": "We write PowerShell from a Linux CI job, ship it to Windows hosts, and execute it there. Different jobs disagree on what the file contains: some have empty variables, some have literal dollar signs, some do not run at all. Give me a transport recipe that is stable.", + "environment_assumptions": [ + "Linux CI runner", + "Windows Server 2019+ targets", + "Git Bash on CI" + ], + "risky_baseline": "Use a heredoc and pass it straight to pwsh:\npwsh -NoProfile -Command \"<<'PS'\n$path = '/mnt/data/'$host\n'@ | Write-Output $path\nPS\" \nThat keeps the variables intact.", + "expected_behaviors": [ + "Separates the three distinct problems in the prompt - shell expansion, encoding, and newline normalisation - instead of treating them as one quoting issue", + "Writes the generated script to a file from the heredoc with a quoted delimiter, then ships that file, rather than threading a script through -Command as a single argument", + "Quotes the heredoc delimiter because the payload is a script, so no bash expansion, backslash folding, or word splitting touches the body", + "Normalises line endings and the BOM on the file at write time, since the Linux generator and the Windows reader disagree about both", + "Parses the shipped file on the Windows host before executing it, and fails the job on a parse error rather than letting a silently mangled script run", + "Provides one stable recipe with the delimiter and the write step explicit, not a per-case quoting decision" + ], + "negative_checks": [ + "Threads a script through -Command as one quoted argument", + "Leaves the delimiter unquoted when the payload is a script" + ], + "rubric": [ + { + "criterion_id": "C1", + "criterion": "Separates expansion, encoding, and newline problems", + "weight": 3 + }, + { + "criterion_id": "C2", + "criterion": "Ships a file written from a quoted-delimiter heredoc", + "weight": 3 + }, + { + "criterion_id": "C3", + "criterion": "Normalises line endings and BOM at write time", + "weight": 2 + }, + { + "criterion_id": "C4", + "criterion": "Parses the shipped file on the target and fails loudly on error", + "weight": 2 + }, + { + "criterion_id": "C5", + "criterion": "Gives one stable recipe rather than per-case quoting", + "weight": 2 + } + ], + "related_rules": [ + "HD-003", + "HD-006", + "HD-005", + "HD-001" + ] } ] } diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 1dd6492..bc148b4 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -20,20 +20,21 @@ Probe → Classify(R/W/D/N/C) → Draft → Rule Check → Propose(-WhatIf) → PowerShell-Reliability-Framework/ ├── SKILL.md # 技能入口:Generation Protocol + 硬性门禁 ├── README.md # 英文主说明 -├── knowledge/ # 知识库(5 篇) +├── knowledge/ # 知识库(6 篇) │ ├── powershell-model.md # 对象管道 / 流 / 错误语义 │ ├── compatibility.md # 5.1 ↔ 7.x 差异矩阵、模块可用性现实 │ ├── security-rules.md # 攻击面与防御性生成姿态 │ ├── enterprise-patterns.md # ShouldProcess、日志、远程、批量操作礼仪 -│ └── anti-patterns.md # AP-01..AP-24 反模式目录(bad→good) +│ ├── anti-patterns.md # AP-01..AP-24 反模式目录(bad→good) +│ └── here-strings-and-heredocs.md # 定界符语法、bash heredoc 冲突、写入器差异 ├── rules/ -│ └── reliability-rules.yaml # 机器可读规则语料(v0.1:100 条) +│ └── reliability-rules.yaml # 机器可读规则语料(v0.1:109 条) ├── examples/ -│ ├── bad/ # 10 个负样本(禁止执行) -│ ├── good/ # 10 个对应正样本 +│ ├── bad/ # 12 个负样本(禁止执行) +│ ├── good/ # 12 个对应正样本 │ └── EXAMPLES.md # 配对索引 ├── benchmark/ -│ └── cases.json # 可靠性 Benchmark(v0.1:100 个用例) +│ └── cases.json # 可靠性 Benchmark(v0.1:110 个用例) ├── tools/ │ ├── probe-environment.ps1 # 目标机规范探测脚本 │ ├── validate.py # 包完整性门禁(L0) @@ -68,7 +69,7 @@ PowerShell-Reliability-Framework/ `rules/reliability-rules.yaml` 中每条规则包含:`id · category · severity · title · description · bad · good · rationale · applies_to · tags · detection{pattern, -psscriptanalyzer}`。当前 100 条中 36 条带检测提示(见 YAML 头部 `detection_rule_count`), +psscriptanalyzer}`。当前 109 条中 45 条带检测提示(见 YAML 头部 `detection_rule_count`), 覆盖率随 v0.2 静态分析集成继续提升。 ## 路线图 @@ -86,10 +87,10 @@ psscriptanalyzer}`。当前 100 条中 36 条带检测提示(见 YAML 头部 ` | 指标 | 目标 | 当前 | |---|---:|---:| -| Benchmark 用例数 | 100 | 100 ✅ | -| 规则条数 | 100+ | 100 | -| 知识文档 | 5 | 5 | -| 示例配对 | — | 10 | +| Benchmark 用例数 | 100 | 110 ✅ | +| 规则条数 | 100+ | 109 | +| 知识文档 | 5 | 6 | +| 示例配对 | — | 12 | | Agent 支持 | 3+ | 任意支持 Skills 的 Agent(格式通用) | 有效性目标(A/B 协议见 `docs/testing-plan.md`)正在建立;首轮双盲评测完成前不对外宣称任何有效性数字。 diff --git a/docs/testing-plan.md b/docs/testing-plan.md index d48830c..36018a1 100644 --- a/docs/testing-plan.md +++ b/docs/testing-plan.md @@ -21,9 +21,9 @@ | Layer | Verifies | Tooling | Environment | Cost | Status | |---|---|---|---|---|---| | **L0** package integrity | schema / counts / pairing / detection shape | `tools/validate.py` | any (pyyaml) | seconds | ✅ built, run on every commit | -| **L1** rule consistency | detectors vs own snippets | `tools/rule-selftest.py` | any | seconds | ✅ built (28 patterns: 13 precise / 15 contextual / 0 fail) | +| **L1** rule consistency | detectors vs own snippets | `tools/rule-selftest.py` | any | seconds | ✅ built (37 patterns: 22 precise / 15 contextual / 0 fail) | | **L2** skill effectiveness A/B | quality delta with vs without PRF | benchmark runner (to build) | ≥3 LLM/agent APIs | ~720 calls per round | ⬜ protocol in §2 | -| **L3** real-environment static gate | good samples parse + pass PSSA; probe smoke | pwsh + PSScriptAnalyzer | pwsh on Linux suffices; CI adds Windows 5.1 | half day setup | ✅ first run passed (pwsh 7.6 + PSSA 1.25: good=0 errors, bad=10/10 detected) | +| **L3** real-environment static gate | good samples parse + pass PSSA; probe smoke | pwsh + PSScriptAnalyzer | pwsh on Linux suffices; CI adds Windows 5.1 | half day setup | ✅ 25/25 `.ps1` parse clean on pwsh 7.6.5 **and** Windows PowerShell 5.1.26100.8115; PSSA 1.25 reports 0 Error-severity findings on `examples/good` (pwsh only — 1.25 does not load under 5.1, so the windows-latest lane needs PSSA ≤1.21) | | **L4** regression guard | changes trigger full re-test | GitHub Actions gates | CI | low | ⬜ wire L0/L1 first | ## 2. L2 — core A/B evaluation protocol @@ -31,7 +31,7 @@ ### 2.1 Design (two-arm controlled) ```text -subjects (≥3 models) × arms (treatment = SKILL.md + rules injected / control) × cases (100) × repeats k=3 +subjects (≥3 models) × arms (treatment = SKILL.md + rules injected / control) × cases (110) × repeats k=3 ``` - **Controlled variables**: pinned model snapshot ID, fixed temperature, identical @@ -77,7 +77,7 @@ Pre-registered thresholds, written into the runner config before the first run: |---|---| | Generated Script Failure Reduction 50% | Δ(benchmark score) ≥ +0.15, lower CI bound > 0 | | Security Issue Detection 80%+ | SEC-case negative_check hit rate down ≥80% vs control arm | -| Benchmark Cases 100 | case count itself (currently 20 pilot) | +| Benchmark Cases 100 | case count itself (110 at v0.1) | | Agent Support 3+ | number of subjects completing the A/B | | Rules 100+ | corpus size (done; guarded by the L0 gate) | @@ -90,13 +90,22 @@ PowerShell 7 is cross-platform: install pwsh on Linux and PSScriptAnalyzer runs - CI matrix: `ubuntu-latest` (pwsh 7.x) + `windows-latest` (adds Windows PowerShell 5.1); - **hard rule: `bad/*.ps1` are never executed** — text comparison only. +The `heredoc` cases (BENCH-HD-*) split cleanly along the L1/L2 boundary: +delimiter column position, an unquoted heredoc delimiter, a `<<` inside a `.ps1`, +a newline inside a single-quoted string, and a here-string piped into a cmdlet +sink are all checkable by deterministic scan against `answer.ps1` with no LLM +judge and no target machine. The byte-identity cases (HD-004 / HD-009) are the +exception — proving `Set-Content -Encoding UTF8` writes `EF BB BF` on 5.1 and +nothing on 7.x requires the `windows-latest` lane, so those two stay in the +model-judged layer until that lane exists. + ## 4. Rollout order & cost | Phase | Work | Prerequisite | Estimate | |---|---|---|---| | this week | wire L0/L1 into CI; first L3 run on Linux pwsh | none | 0.5 day | | next | benchmark runner (prompt templates + deterministic grader + runs archive) | none | 1–2 days | -| then | first two-arm round (3 subjects × 2 arms × 100 cases × 3 repeats ≈ 1,800 generations + 1,800 gradings) | ≥1 model API key | moderate token cost | +| then | first two-arm round (3 subjects × 2 arms × 110 cases × 3 repeats ≈ 1,980 generations + 1,980 gradings) | ≥1 model API key | moderate token cost | | pre-release | calibrate thresholds from round 1 → fill README metrics → tag alpha | one completed L2 round | — | ## 5. Current status @@ -108,8 +117,10 @@ PowerShell 7 is cross-platform: install pwsh on Linux and PSScriptAnalyzer runs `…RecordA` variant family) — fixed; 19 detection fields normalized; 1 fabricated PSA id removed; - ✅ benchmark expanded 20 → 100 cases (2026-08-23) -- ✅ L3: all 21 `.ps1` files parse clean under PS 7.6; static gate passes with - perfect discrimination (good = 0 errors, bad = 10/10 flagged); +- ✅ `heredoc` category added: 9 HD rules, 1 knowledge doc, 2 example pairs, + 10 benchmark cases (109 rules / 110 cases / 12 pairs total); +- ✅ L3: all 25 `.ps1` files parse clean on both pwsh 7.6.5 and Windows PowerShell + 5.1.26100.8115; PSSA 1.25 reports zero Error-severity findings on `examples/good`; - ⬜ L2/L4 proceed per §4; runner code lands once an external API key exists. > Bottom line: **L0/L1 prove the package is sound today; L2 is what will prove the diff --git a/examples/EXAMPLES.md b/examples/EXAMPLES.md index 10881d4..1b7445a 100644 --- a/examples/EXAMPLES.md +++ b/examples/EXAMPLES.md @@ -21,6 +21,8 @@ they are never meant to be executed. | 08 | scheduled-task-register | IDEM-009 | medium | | 09 | plaintext-credential-export | SEC-014 | critical | | 10 | foreach-parallel-compat | COMP-001 | low | +| 11 | heredoc-manifest-writer | HD-004, HD-009 | medium | +| 12 | heredoc-child-script-runner | HD-001, HD-002, SEC-001 | high | ## How agents should use these pairs diff --git a/examples/bad/11-heredoc-manifest-writer.ps1 b/examples/bad/11-heredoc-manifest-writer.ps1 new file mode 100644 index 0000000..7b4768a --- /dev/null +++ b/examples/bad/11-heredoc-manifest-writer.ps1 @@ -0,0 +1,22 @@ +# PRF Example 11 — heredoc-manifest-writer +# Rules demonstrated: HD-004, HD-009 +# Risk class: medium +# NOTE: bad/*.ps1 are NEGATIVE training examples. Never execute them. + +<# +Publishes the deployment receipt that a downstream checker hashes before +comparing it with the reference. Kept short by using the cmdlet defaults. +#> + +$manifestPath = 'C:\temp\deploy-receipt.json' + +$receipt = @' +{ + "service": "svc-01", + "version": "1.4.2", + "status": "published" +} +'@ | Out-File $manifestPath + +Write-Host "receipt written to $manifestPath" +(Get-Item -LiteralPath $manifestPath).Length diff --git a/examples/bad/12-heredoc-child-script-runner.ps1 b/examples/bad/12-heredoc-child-script-runner.ps1 new file mode 100644 index 0000000..f766efd --- /dev/null +++ b/examples/bad/12-heredoc-child-script-runner.ps1 @@ -0,0 +1,18 @@ +# PRF Example 12 — heredoc-child-script-runner +# Rules demonstrated: HD-001, HD-002, SEC-001 +# Risk class: high +# NOTE: bad/*.ps1 are NEGATIVE training examples. Never execute them. + +<# +Cleans stale build artifacts. The child step is composed as a string and +evaluated in the current session so the caller and the child share scope. +#> + +$artifactRoot = 'C:\build\artifacts\stale' + +$cleanup = @" +Remove-Item -LiteralPath C:\build\artifacts\stale\* -Force -Recurse +Write-Host 'stale artifacts removed' +"@ + +Invoke-Expression $cleanup diff --git a/examples/good/11-heredoc-manifest-writer.ps1 b/examples/good/11-heredoc-manifest-writer.ps1 new file mode 100644 index 0000000..0b5a894 --- /dev/null +++ b/examples/good/11-heredoc-manifest-writer.ps1 @@ -0,0 +1,47 @@ +# PRF Example 11 — heredoc-manifest-writer +# Rules demonstrated: HD-004, HD-009 +# Risk class: medium +# NOTE: bad/*.ps1 are NEGATIVE training examples. Never execute them. + +<# +Publishes the deployment receipt that a downstream checker hashes before +comparing it with the reference. The bytes have to be identical on every host, +because a hash comparison is the only thing standing between this receipt and +the reference it is checked against. +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$ManifestPath = 'C:\temp\deploy-receipt.json' +) + +$receipt = @' +{ + "service": "svc-01", + "version": "1.4.2", + "status": "published" +} +'@ + +# SAFE-005: an existing receipt is the previous run's evidence. Snapshot it +# before overwriting so a bad publish is still recoverable. +if (Test-Path -LiteralPath $ManifestPath) { + $backup = "$ManifestPath.$((Get-Date).ToString('yyyyMMddHHmmss'))" + Copy-Item -LiteralPath $ManifestPath -Destination $backup +} + +# HD-004: Out-File defaults to UTF-16LE on Windows PowerShell 5.1 and to utf8NoBOM +# on PowerShell 7, so the same command produces two different files. +# HD-009: piping a here-string into a cmdlet sink also hands the cmdlet the final +# line, which it terminates with its own CRLF while the body carries bare LFs, +# leaving the artifact with mixed line endings. +# +# Serializing through .NET with a UTF8Encoding built without a byte-order mark +# removes both: measured byte-for-byte identical across the two runtimes. +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +if ($PSCmdlet.ShouldProcess($ManifestPath, 'Write deployment receipt')) { + [System.IO.File]::WriteAllText($ManifestPath, $receipt, $utf8NoBom) +} + +Write-Host "receipt written to $ManifestPath" +(Get-Item -LiteralPath $ManifestPath).Length diff --git a/examples/good/12-heredoc-child-script-runner.ps1 b/examples/good/12-heredoc-child-script-runner.ps1 new file mode 100644 index 0000000..3fcd42a --- /dev/null +++ b/examples/good/12-heredoc-child-script-runner.ps1 @@ -0,0 +1,52 @@ +# PRF Example 12 — heredoc-child-script-runner +# Rules demonstrated: HD-001, HD-002, SEC-001 +# Risk class: high +# NOTE: bad/*.ps1 are NEGATIVE training examples. Never execute them. + +<# +Cleans stale build artifacts. The child step is generated as a real script file +and executed by path, so the deletion runs with its own explicit scope and its +own error handling instead of inheriting the caller's. +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$ArtifactRoot = 'C:\build\artifacts\stale' +) + +# SEC-001: generate a file and run it, never eval a composed string. +# The child body is a here-string, so the delimiter positions are visible and +# checkable instead of buried in an interpolated double-quoted string. +# +# HD-001: the closing delimiter starts at column one. Indenting it to match the +# surrounding block is a whole-file parse failure on both runtimes. +# HD-002: the header line carries nothing but whitespace after @'. A comment on +# the header line is not allowed and breaks the parse. +$child = @' +param( + [Parameter(Mandatory = $true)][string]$Root +) + +if (-not (Test-Path -LiteralPath $Root)) { + Write-Warning "root not found: $Root" + return +} + +$targets = Get-ChildItem -LiteralPath $Root -File +$removed = 0 +foreach ($file in $targets) { + Remove-Item -LiteralPath $file.FullName -Force -Confirm:$false + $removed += 1 +} +Write-Host ("removed {0} file(s) from {1}" -f $removed, $Root) +'@ + +# The child is ASCII-only, so it parses correctly under both runtimes without a +# byte-order mark. Written through .NET so the bytes are what they look like. +$childPath = Join-Path ([System.IO.Path]::GetTempPath()) 'stale-artifact-cleanup.ps1' +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($childPath, $child, $utf8NoBom) + +if ($PSCmdlet.ShouldProcess($ArtifactRoot, "Clean stale artifacts via $childPath")) { + & $childPath -Root $ArtifactRoot +} diff --git a/knowledge/here-strings-and-heredocs.md b/knowledge/here-strings-and-heredocs.md new file mode 100644 index 0000000..914cb90 --- /dev/null +++ b/knowledge/here-strings-and-heredocs.md @@ -0,0 +1,346 @@ +# Here-Strings and Heredocs: The Interpreter Boundary (HD Rules) + +The other five categories reason about a script that already exists as a file on +disk. Everything in this category happens **before** that assumption is true: the +bytes an agent emits still have to become a PowerShell parse, survive a quoting +boundary, and be written somewhere as a specific number of bytes. That boundary +is where "the code looks right" stops meaning anything, and it is where a large +share of AI-generated PowerShell fails on Windows. + +This document is organized around what the parser and the runtime actually do, +not what the documentation implies. Every table below was measured on +Windows PowerShell 5.1.26100.8115 (Desktop) and PowerShell 7.6.5 (Core) in a +single pass; where the two runtimes disagree the disagreement is stated, because +that is the whole point. + +## 1. Why This Is A Separate Axis + +A model trained on shell tooling reaches for one construct to hand a multi-line +block to something: a heredoc. In bash that is `< child.ps1 <<'PSEOF' +Write-Output $LASTEXITCODE +PSEOF +pwsh -NoProfile -File child.ps1 +``` + +If the child prints something other than what the child source says, the hand-off +is corrupt, and the fix is the delimiter quote. + +## 9. How These Rules Were Established + +The nine HD rules were authored from measurement rather than from recall, because +the two things that make this category hard are exactly the two things that +documentation gets wrong or leaves vague: the delimiter grammar, and the byte +format of the sink. + +Method: every candidate construct was written as its own `.ps1`, parsed through +`[System.Management.Automation.Language.Parser]` under both runtimes, and recorded +with error count, message, and AST node class. Nothing in the probe set was ever +executed. Runtime behavior was then measured by writing the payload through each +sink and reading the resulting bytes back, counting NULs, carriage returns and +line feeds and inspecting the first eight bytes for a marker. The transport +measurements were taken through Git Bash calling both engines, with the results +written to files and read back, because the host's stdout channel is not +reliable enough to trust for this. + +The detection patterns were checked for precision the same way the corpus's own +self-test checks them: each one must match its own `bad` snippet and must not +match its own `good` snippet. All nine are precise rather than contextual, which +is the stronger form and means none of them need the v0.2 AST gate that the +`IDEM` rules are waiting on. + +One finding corrected an assumption made while authoring this. The initial draft +of the delimiter rule was the familiar "`@'` must be the last thing on its +line". The probe showed a single trailing space parses cleanly, so the rule was +rewritten to the narrower and correct "no characters", and the description now +warns that a trailing-whitespace stripper can introduce the fault. diff --git a/rules/reliability-rules.yaml b/rules/reliability-rules.yaml index 9eb4e84..d00c978 100644 --- a/rules/reliability-rules.yaml +++ b/rules/reliability-rules.yaml @@ -1,15 +1,16 @@ schema_version: 0.1.0 name: prf-reliability-rules description: 'Machine-readable reliability rule corpus for AI-generated PowerShell (PRF v0.1.0). Categories: - compatibility (COMP), safety (SAFE), idempotency (IDEM), security (SEC), error-handling (ERR). Agents - self-review drafts against this corpus per SKILL.md Step 4.' -rule_count: 100 + compatibility (COMP), safety (SAFE), idempotency (IDEM), security (SEC), error-handling (ERR), + heredoc (HD). Agents self-review drafts against this corpus per SKILL.md Step 4.' +rule_count: 109 categories: - compatibility - safety - idempotency - security - error-handling +- heredoc rules: - id: COMP-001 category: compatibility @@ -3088,4 +3089,287 @@ rules: - powershell-7 tags: - authorization -detection_rule_count: 36 +- id: HD-001 + category: heredoc + severity: high + title: The here-string terminator must begin at column one + description: 'The closing delimiter of a here-string has to be the first character on its line. A single + leading space or tab is enough to break the script: both runtimes report ''White space is not + allowed before the string terminator'' and the whole file stops parsing. Models produce this + because the body is usually indented to match the surrounding block, so the terminator is + indented along with it. Put the terminator at column one and let the payload carry its own + indentation, or build the payload with a joined array when the surrounding style must stay + aligned.' + bad: | + function Get-Report + { + $body = @' + { + "ok": true + } + '@ + return $body + } + good: | + function Get-Report + { + $body = @' + { + "ok": true + } + '@ + return $body + } + rationale: An indented terminator is a whole-file parse failure on both 5.1 and 7.x, and the parser points + at the terminator rather than at the indentation, so it reads like an unrelated syntax error. + applies_to: + - powershell-5.1 + - powershell-7 + tags: + - heredoc + - syntax + detection: + pattern: (?m)^[ \t]+['\"]@ +- id: HD-002 + category: heredoc + severity: high + title: Nothing but whitespace may follow the here-string header + description: 'The opening delimiter has to end the line. A comment, a statement, a cast or a trailing + argument after @'' or @" makes the parser report ''No characters are allowed after a here-string + header but before the end of line'', and because the header never opens a string the rest of the + script is mis-parsed too. Trailing whitespace on that line is legal, so a formatter that strips + trailing spaces can turn a working script into a broken one: keep the header alone on its line + and put any annotation on the line above.' + bad: | + @' # json payload + { + "name": "svc-01" + } + '@ + good: | + # json payload + @' + { + "name": "svc-01" + } + '@ + rationale: The failure is a parse-time error but the message names the header, not the offending trailing + token, which sends a reader looking for a missing delimiter instead of the comment. + applies_to: + - powershell-5.1 + - powershell-7 + tags: + - heredoc + - syntax + detection: + pattern: (?m)@['\"][ \t]*[^\s\r\n] +- id: HD-003 + category: heredoc + severity: high + title: Bash heredoc syntax does not exist in PowerShell + description: 'Models trained on shell tooling reach for < script.ps1 < script.ps1 <<'PSEOF' + $path = 'C:\temp\out' + $count = ($path.Length) + Write-Output $count + PSEOF + rationale: The generation step reports success, so the defect is attributed to the script's logic or to + PowerShell itself rather than to the quoting of the writer. + applies_to: + - powershell-5.1 + - powershell-7 + tags: + - heredoc + - interoperability + detection: + pattern: <<[A-Za-z_] +- id: HD-007 + category: heredoc + severity: medium + title: One standard input stream cannot carry a script and a payload + description: 'Piping a program''s source through stdin and then expecting the same stream to also deliver data + does not work: the interpreter consumes the source and the payload read returns zero bytes with + no error. Sending a script to python with ''-'' and a heredoc body, or relying on pwsh to read + both, silently produces an empty read. Keep the two concerns apart: write the script to a file + and invoke it by path, or pass the program with -c and feed only the payload on stdin.' + bad: | + python - <<'PY' + import sys + data = sys.stdin.read() + print(len(data)) + PY + good: | + printf 'line-one' | python -c "import sys; print(len(sys.stdin.read()))" + rationale: The read succeeds and returns an empty string, so the caller treats the payload as genuinely + absent instead of as an undelivered one. + applies_to: + - powershell-5.1 + - powershell-7 + tags: + - heredoc + - interoperability + detection: + pattern: (? None: } APPLIES_VOCAB = {"powershell-5.1", "powershell-7"} PREFIX_CATEGORY = {"COMP": "compatibility", "SAFE": "safety", "IDEM": "idempotency", - "SEC": "security", "ERR": "error-handling"} + "SEC": "security", "ERR": "error-handling", "HD": "heredoc"} BENCH_CAT = {"FS": "file-system", "REG": "registry", "AD": "active-directory", "AZ": "azure", "NET": "network", "SVC": "service", "SEC": "security", - "AUT": "automation"} + "AUT": "automation", "HD": "heredoc"} VALID_SEVERITY = {"critical", "high", "medium", "low", "info"} -RULE_REF = re.compile(r"\b(?:COMP|SAFE|IDEM|SEC|ERR)-\d{3}\b") +RULE_REF = re.compile(r"\b(?:COMP|SAFE|IDEM|SEC|ERR|HD)-\d{3}\b") CJK = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff]") # ---------------------------------------------------------------- structure -- for name in ("powershell-model", "compatibility", "security-rules", - "enterprise-patterns", "anti-patterns"): + "enterprise-patterns", "anti-patterns", "here-strings-and-heredocs"): p = ROOT / "knowledge" / f"{name}.md" if not p.exists() or p.stat().st_size < 2000: err(f"missing/thin knowledge doc: {p.relative_to(ROOT)}") @@ -172,7 +172,7 @@ def err(msg: str) -> None: err(f"cases.json failed to parse: {exc}") case_ids, case_dist = [], {} if isinstance(bench, dict): - idpat = re.compile(r"^BENCH-(FS|REG|AD|AZ|NET|SVC|SEC|AUT)-\d{3}$") + idpat = re.compile(r"^BENCH-(FS|REG|AD|AZ|NET|SVC|SEC|AUT|HD)-\d{3}$") for c in bench.get("cases", []): cid = str(c.get("id", "")) for key in ("category", "prompt", "rubric", "expected_behaviors"): @@ -216,7 +216,7 @@ def slugs(folder: Path) -> set: err(f"{f.relative_to(ROOT)}: banner missing 'Rules demonstrated' line") continue for tok in filter(None, (t.strip() for t in re.split(r"[,;\s]+", m.group(1)))): - if tok.startswith(("COMP", "SAFE", "IDEM", "SEC", "ERR")) and tok not in corpus_ids: + if tok.startswith(("COMP", "SAFE", "IDEM", "SEC", "ERR", "HD")) and tok not in corpus_ids: err(f"{f.relative_to(ROOT)}: banner references unknown rule {tok}") # ----------------------------------------------------------------- language -- From 49745f29af89cd31f5669a61d862ed656af868cd Mon Sep 17 00:00:00 2001 From: Ashon-LL <3437894979@qq.com> Date: Mon, 21 Sep 2026 12:45:50 +0800 Subject: [PATCH 2/2] Sync docs/testing-plan.md with the state that actually shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §4 rollout: L0 and L1 are already wired into CI on the macOS lane, so the remaining CI work is the ubuntu-latest and windows-latest lanes, not wiring L0/L1 - §5: benchmark count 100 -> 110 with the heredoc axis - §5: the PSSA 1.25 zero-Error result is pwsh-only; the windows lane needs PSSA <=1.21 because 1.25 does not load under Windows PowerShell 5.1 --- docs/testing-plan.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/testing-plan.md b/docs/testing-plan.md index 36018a1..888c331 100644 --- a/docs/testing-plan.md +++ b/docs/testing-plan.md @@ -103,7 +103,7 @@ model-judged layer until that lane exists. | Phase | Work | Prerequisite | Estimate | |---|---|---|---| -| this week | wire L0/L1 into CI; first L3 run on Linux pwsh | none | 0.5 day | +| this week | add `ubuntu-latest` (pwsh 7) and `windows-latest` (5.1) lanes on top of the existing macOS L0/L1 gates | none | 0.5 day | | next | benchmark runner (prompt templates + deterministic grader + runs archive) | none | 1–2 days | | then | first two-arm round (3 subjects × 2 arms × 110 cases × 3 repeats ≈ 1,980 generations + 1,980 gradings) | ≥1 model API key | moderate token cost | | pre-release | calibrate thresholds from round 1 → fill README metrics → tag alpha | one completed L2 round | — | @@ -116,11 +116,12 @@ model-judged layer until that lane exists. - ✅ L1: first run caught a real defect (IDEM-011 word-boundary missed the `…RecordA` variant family) — fixed; 19 detection fields normalized; 1 fabricated PSA id removed; -- ✅ benchmark expanded 20 → 100 cases (2026-08-23) +- ✅ benchmark expanded 20 → 100 cases (2026-08-23) → 110 with the heredoc axis - ✅ `heredoc` category added: 9 HD rules, 1 knowledge doc, 2 example pairs, 10 benchmark cases (109 rules / 110 cases / 12 pairs total); - ✅ L3: all 25 `.ps1` files parse clean on both pwsh 7.6.5 and Windows PowerShell - 5.1.26100.8115; PSSA 1.25 reports zero Error-severity findings on `examples/good`; + 5.1.26100.8115; PSSA 1.25 reports zero Error-severity findings on `examples/good` + (pwsh only — PSSA 1.25 does not load under 5.1, so the windows lane needs ≤1.21); - ⬜ L2/L4 proceed per §4; runner code lands once an external API key exists. > Bottom line: **L0/L1 prove the package is sound today; L2 is what will prove the