Author an unattended PowerShell script for work that is fully scriptable but not agent-launchable: the human is only the privilege or policy boundary (UAC, an elevated shell, or a run the repo reserves for the operator). The agent authors the script and never launches the real run. The human launches it once. The script writes cutover.result/1 JSON the agent reads back. Use when: 'run this elevated', 'I have to launch it', 'UAC', 'operator must apply', 'unattended cutover', 'scriptable but I ...
20 stars
0 votes
0 copies
0 views
Added September 29, 2026
ai-agentsrustgoshellgitapisecurity
Works with
terminal
cli
api
Security analysis
B75/100
criticalAccesses system keychains or credential stores
Installs into .claude/skills of the current project.
Are you the author of Unattended?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/melodic-software-unattended)
---
description: "Author an unattended PowerShell script for work that is fully scriptable but not agent-launchable: the human is only the privilege or policy boundary (UAC, an elevated shell, or a run the repo reserves for the operator). The agent authors the script and never launches the real run. The human launches it once. The script writes cutover.result/1 JSON the agent reads back. Use when: 'run this elevated', 'I have to launch it', 'UAC', 'operator must apply', 'unattended cutover', 'scriptable but I cannot run it'. Don't use it for a dashboard click, a 2FA code, or anything the agent can already run itself."
argument-hint: "<scriptable procedure a human must launch>"
user-invocable: true
disable-model-invocation: false
metadata:
workflow-stage: anytime
summary: Author an unattended script a human launches once for a privilege or policy boundary
---
# Unattended launch
`/wizard:generate` is for a step a human has to perform: a dashboard click, a
2FA code, an unplug. This skill is the other case. The work is fully
scriptable, and a human is in the loop only because the agent lacks the
privilege or a policy reserves the run for the operator. The human launches
the script once. The script does the work and writes a result file. The agent
reads that file. The human does not paste a terminal log back into the chat.
An irreversible step (`wsl --unregister`, `git push --force`) still asks the
human, in this skill or in `generate`. That is a consent prompt, not a reason
to make the rest of the script interactive.
## When to use which
| Reason a human is involved | Owner |
|---|---|
| Agent lacks the privilege (UAC, elevated shell) | this skill |
| Agent is forbidden by policy | this skill |
| Dashboard, 2FA, or another step no script can perform | `/wizard:generate` |
| Irreversible and needs consent | `Confirm-Irreversible` in the script |
## Process
### 1. Scope
List the steps, the inputs, and why the agent cannot launch the script. Read
the repo for commands and key names. From a live `.env`, take key names only.
Never values. Mark each irreversible step with the exact name the human will
type (`wsl --unregister Ubuntu-26.04`); step 2 passes those names to
`-Irreversible`. Show the ordered stages and wait for the user to confirm them.
**Done when:** every stage is named, each input has a resolution rung, and
each irreversible step is marked and listed as an `-Irreversible` name.
### 2. Author
Copy [template.ps1](template.ps1). Leave the library above `# STAGES` unchanged.
Replace the example `Invoke-UnattendedRun` body with the procedure, using only
these helpers:
- `Invoke-UnattendedRun -ResultDirectory -Stages -Irreversible <names> -Secrets <declarations>`.
Runs the stages and writes the result. `-Irreversible` is the declared list of
irreversible steps, for example `'wsl --unregister Ubuntu-26.04'`. It is
printed to the transcript before the stages run (`irreversible actions:
none` when empty; not under `-Test`) and recorded as `irreversible_actions`,
on success and on failure. `-Secrets` is the declared list of secrets, each
`@{ Name = 'API_TOKEN'; FilePath = 'C:\ops\token.txt' }` (`FilePath` is
optional). Every declared name resolves once, before the first stage, so the
human answers every hidden prompt up front and the rest of the run is
unattended. The names are printed (`secrets: none` when empty; not under
`-Test`) and recorded as `secrets`, names only, on success and on failure.
- `Assert-Elevation -Mode Required` or `Forbidden`. Elevation is a constraint
with two failure directions.
- `Assert-NotInside -Name <wsl-distro>`. The script must not be running inside
the WSL distro it restarts. It compares `Name` with `WSL_DISTRO_NAME` only: a
service, container or process is not detected. A WSL login cutover cannot be a
script running inside that distro; emit PowerShell so it runs on the Windows
host.
- `Resolve-UnattendedSecret -Name <ENV> -FilePath <optional>`. First hit wins:
environment variable, then the file, then a `Microsoft.PowerShell.SecretManagement`
vault, then the native store (macOS Keychain through `security
find-generic-password -s <name> -w`; Linux `pass show <name>` for an entry file `<name>.gpg`, first line
only), then one hidden prompt. Each store rung is skipped silently when its
module, command or the name is absent. The vault uses only a string secret
(see the store gotchas). A name declared in
`-Secrets` returns the value resolved before the first stage; declaring a name
twice fails the run. An undeclared name runs the ladder at this call, with its
own prompt. The value is redacted out of the transcript.
- `Assert-PriorResult -Path <result-latest.json>`. Do not start until the
previous script's result is `ok`.
- `Add-Preflight -Name -Test -Fix`. Fail before later steps, and carry the
remediation command in the result.
- `Invoke-IdempotentStep -Name -Done -Action`. A re-run after a partial failure
skips work that is already done.
- `Wait-ForState -Name -Predicate -TimeoutSeconds 300 -IntervalSeconds 5`. The
preferred way to prove a requested state: poll the outcome, never the exit
code of the request. It polls the predicate, prints one progress line per
poll, and returns once the predicate's last output is truthy; otherwise it
throws `timed out waiting for <Name>` with the last value or error. A
predicate that throws counts as not yet, because ephemeral targets race. The
predicate must first assert a non-empty observation (`$pools.Count -gt 0 -and
...`): a vacuous pass is the author's bug, so a drain proof that sees no pools
must fail, not pass. It records a `wait <Name>` step. It emits `$true`, so
standing alone it is written `$null = Wait-ForState ...`.
- `Use-GuardedResource -Name -Take -Prove -Release [-TolerateTakeExit]`. Take a
shared resource out of service and release it only after proof. `Prove` must
throw on failure or emit a truthy value as its last output; `$false`, no
output, or a nonzero native exit all count as failed proof and keep the
resource held. A failure leaves it listed in `held_resources`.
`-TolerateTakeExit` records a nonzero native exit from `Take` as a warning
instead of throwing (a thrown exception still fails), so `Prove` is the only
gate. Drain example: the request exits 5 while the drain proceeds, so use
`-TolerateTakeExit` with `-Prove { Wait-ForState -Name drain -Predicate {
$pools.Count -gt 0 -and -not ($pools | Where-Object Active) } }`.
- `Assert-ParsedState -Name -Value`. Throws when a parsed listing is `$null`, an
empty array, or only whitespace, and otherwise prints the item count.
Unknown state is a stop, not `already absent`.
- `Invoke-NativeUtf8 -Block`. Runs the block with `WSL_UTF8=1` and UTF-8
console decoding, restores both afterwards (also when the block throws), and
returns the block's output. It is the pin for `wsl.exe` listings (see
Gotchas). A CLI that has `--json` should use that instead of parsing human
output.
- `Confirm-Irreversible -Name`. The human types the name. Anything else aborts.
It refuses before prompting when the name is not in `-Irreversible`
(`irreversible step X was not declared`) and while any guarded resource is
still held (`refusing irreversible step X while resources are held: ...`).
A confirmed step is recorded as `irreversible <Name>`.
Set the result directory to a path the agent can read after the human runs the
script. The envelope is `cutover.result/1`: per-step `status` and `detail`,
`mode`, `warnings`, `held_resources`, `irreversible_actions`, `secrets`,
`transcript`, and a `result-latest.json` copy. The schema string is unchanged,
and `irreversible_actions`, `secrets` and `mode` are additive fields: read their
absence in an older result as an empty list and `run`.
#### Order and unknowns
- Irreversible steps go last, after every precondition is proven in the same
run. A proof from an earlier run or an answer typed at a prompt is not one.
`Confirm-Irreversible` enforces the resource half: it refuses while any
`Use-GuardedResource` is still held.
- Every guard whose false branch skips a destructive step
(`-Done { $names -notcontains 'Ubuntu-26.04' }`) reads a listing that is empty
when the read failed. Pass the parsed listing through `Assert-ParsedState`
first, so an unknown state stops the run instead of reading as `already
absent`.
- After the destructive step, re-verify the outcome with `Wait-ForState` (the
distro is gone, the port is closed). The destructive command's exit code is
not that proof.
#### Dry run
The script takes `-WhatIf` and `-Test`. Neither invokes a mutating block, and
both write only the result directory, as `result-dry-latest.json`, so a
preview never replaces a real run's `result-latest.json`.
- `-WhatIf` narrates the plan: one `What if:` line per step, including steps
already done, then the blast radius as counts: steps that would run,
resources that would be taken out of service, declared irreversible actions.
- `-Test` reports the delta: nothing is printed but one final line. The result's
`delta` array holds only the steps that would run plus failed preflights.
With both switches, `-Test` wins.
- The result gains `mode` (`run`, `whatif`, `test`) and, in a dry run,
`planned` with the counts `steps`, `resources`, `irreversible` and `secrets`.
Read-only helpers run in every mode, so a missing prerequisite fails a dry run
with no side effects: `Assert-Elevation`, `Assert-NotInside`,
`Assert-PriorResult`, `Add-Preflight`, `Assert-ParsedState`,
`Invoke-NativeUtf8`, the `-Done` probe of `Invoke-IdempotentStep`, and the
environment, file and store rungs of secret resolution (vault, Keychain, `pass`). Each
probe, preflight test and wrapped read must only read. The vault rung reads
every registered vault, and a locked vault, Keychain or `pass` can ask the human
for a password during a dry run (see the store gotchas).
Mutating helpers skip their blocks and record a `would-run` step, or `skipped`
when `-Done` is already true: `Invoke-IdempotentStep -Action`,
`Use-GuardedResource` (Take, Prove and Release), `Confirm-Irreversible` (no
prompt), `Wait-ForState` (no polling), and secret resolution, declared or not (no
hidden prompt for a secret; each name that the environment, the file and every store
miss records a `would prompt` step and yields the placeholder `<NAME>`).
A dry run does not exercise success detection inside a step: no Prove block or
`Wait-ForState` predicate runs, so it checks the plan and the prerequisites, not
that the state is reached, and it does not replace `Wait-ForState`. Put every
effect inside a helper's block: a bare native command in `-Stages` runs in every
mode.
### 3. Hand off
Do not launch the script. Print the `# STAGES` block and get explicit approval
first: the agent runs nothing before the human has seen the stages. Check that
every effect sits inside a helper's block, because `-Test` runs a bare native
command. After approval the agent's own entry is `pwsh -File <script> -Test`,
which changes nothing when every effect is inside a helper: read
`result-dry-latest.json` (`status`, `delta`, `planned`) and fix what it
reports; if a fix changes the stages, print them and get approval again. Then
tell the human to launch it. Say which elevation mode it demands, that it requires PowerShell 7 (`pwsh`),
launched with `pwsh -File <script>` (Windows PowerShell 5.1 fails at
`#requires`), and the result path. Tell the human to run `-WhatIf` first, read
its narration and blast radius, and only then make the real launch. After the
real run, read `result-latest.json`. Do not ask them to paste the transcript.
## Next
`/wizard:generate` when a step is a dashboard click, a one-time code, or another action no script can perform.
## Gotchas
- `Assert-NotInside` reads `WSL_DISTRO_NAME`. `WIZARD_INSIDE_MARKER`, when set,
replaces it: that is the test seam `template.test.sh` uses, not an operator
setting. A script running inside a service or container passes the guard.
- The agent never launches the real run. A pipeline or an agent shell is the
wrong principal. `-Test` is the one exception, because it invokes no mutating
block. It also sets `-WhatIf`, so a cmdlet outside a helper that honors
`-WhatIf` is skipped too; a native command outside a helper is not.
- Dry-run behavior worth knowing: a standalone `Wait-ForState` is not polled,
because the state it waits for follows a mutation the dry run skipped;
`Use-GuardedResource` never lists the resource
in `held_resources`, because nothing was taken; `Resolve-UnattendedSecret`
and a declared secret return the placeholder `<NAME>` when they would prompt;
`Confirm-Irreversible`
still refuses an undeclared name, before it would prompt. A `-Done` probe that
throws fails a dry run as it fails a real one, so write probes that tolerate a
target that does not exist yet.
- `-Test` from the agent's own shell stops at `Assert-Elevation -Mode Required`
with status `failed` and `refusing to run unelevated`: the shell lacks the
privilege, and that is the expected result. The human's elevated `-WhatIf`
reaches the rest of the script.
- A `Take` that asks a system to reach a state (a drain with `--wait`) can exit
nonzero while the state is reached. Do not trust that exit code either way:
pass `-TolerateTakeExit` and prove the state with `Wait-ForState`.
- `wsl.exe` writes its listings (`--list`, `--status`) as UTF-16, which a
PowerShell capture decodes as text with embedded NULs, so a `-contains
'Ubuntu'` guard never matches and reads `absent`. `WSL_UTF8=1` makes it emit
UTF-8 (WSL 0.64.0 and later); wrap the call in `Invoke-NativeUtf8`. Verified
2026-09-29 against the 0.64.0 release notes
(<https://github.com/microsoft/WSL/releases/tag/0.64.0>), `WslClient.cpp` in
`microsoft/WSL` (reads `WSL_UTF8`; only the value `1` enables UTF-8), and
Microsoft's `diagnostics/collect-wsl-logs.ps1` (sets it beside
`[Console]::OutputEncoding`). The Microsoft Learn WSL pages do not mention
it. Recheck when a WSL release note changes or drops `WSL_UTF8`, or the Learn
basic-commands page documents a different switch for `wsl.exe` output
encoding.
- The store rung calls `Get-Secret -AsPlainText` with no `-Vault`, so it searches
every registered vault, the default vault first and remote ones included, on any
platform where the module and a vault are present. Nothing gates it to Windows. A
locked SecretStore whose `Interaction` is `Prompt` asks for its password in an
interactive session, so `-WhatIf` and `-Test` can prompt for it too; with
`Interaction` set to `None` the read fails and the rung is skipped.
`-AsPlainText` converts only a `String` or `SecureString`, so a `PSCredential`,
hashtable or `byte[]` secret is skipped with a warning and the ladder goes on
to the hidden prompt. SecretStore encrypts with .NET Core cryptographic APIs,
not DPAPI. Verified 2026-09-29 against the SecretManagement
[overview](https://learn.microsoft.com/en-us/powershell/utility-modules/secretmanagement/overview)
(SecretStore "uses .NET Core cryptographic APIs to encrypt file contents" and
"works on all platforms that support PowerShell 7"; the modules are "feature
complete" with the repository archived, at SecretManagement 1.1.2 and
SecretStore 1.0.6),
[Get-Secret](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/get-secret?view=ps-modules)
(with no vault named, "all registered vaults are searched"; `-AsPlainText` "has
no effect" on a secret that is not a `String` or `SecureString`) and
[Set-SecretStoreConfiguration](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretstore/set-secretstoreconfiguration?view=ps-modules)
(`-Interaction`). Recheck when the PowerShell Gallery lists a release of either
module after those versions, or the overview page drops its "feature complete"
notice.
- The native store rungs can block an unattended run. The Keychain can raise an
access dialog for an item the calling app is not trusted for or in a locked
keychain, and `pass` runs `gpg`, which asks for a passphrase through pinentry
when the agent has none cached. Before an unattended run, unlock the keychain
(`security unlock-keychain`) or add the calling app to the item's trusted
applications, and start `gpg-agent` with the passphrase cached (for example by
running `pass show <name>` once). `pass show` prints the whole file; the rung
takes the first line. Verified 2026-09-30 against the Apple
[`security` man page](https://keith.github.io/xcode-man-pages/security.1.html)
(`find-generic-password`: `-s` matches the service string, `-w` displays the
password only; `unlock-keychain`) and the passwordstore.org
[pass man page](https://git.zx2c4.com/password-store/plain/man/pass.1) (`show`
decrypts and prints the named password; `gpg-agent` is recommended so batch
decryption needs less intervention). Recheck when either man page changes
those flags or commands.
- A secret resolved at runtime stays in the human's process. Do not ask for
the value in chat.
- `Confirm-Irreversible` is the consent prompt. Do not skip it because the
rest of the script is unattended.
- Redaction runs when the script completes or throws. A run killed before
that (Ctrl+C, a closed window, a reboot) leaves the transcript unredacted,
so never print a secret, and tell the human to delete the transcript of an
interrupted run.
- `Assert-PriorResult` trusts the file it reads. When a lower-privilege stage
feeds an elevated one, put the result directory where only the elevated
principal can write.