Install steps¶
install_steps: is an ordered list of typed actions the wrapper runs at install time. Each step records a reverse operation in the rollback journal before mutating state, so a failure (or a later setup.exe /Uninstall) can undo every change byte-identical.
The catalog is a closed set of 18 step types, listed in full below. It is not extended by configuration — a new type requires an amendment to ADR-008 and a change across the whole chain from the manifest model to the runtime. pre_install: and post_install: accept the same step shapes and run before / after install_steps: respectively; uninstall: accepts them too.
installer.hooks.* accepts the same 18 types as well — the catalog does not narrow for a hook. What changes is the journal, not the list: a hook runs outside the rollback journal, so whatever it does is never recorded and never undone, and an uninstall will not clean it up. That is why a hook's on_failure is fail or continue rather than rollback — there is nothing to unwind. Four of the types were missing from the hook list until R81, which was drift from the lane that introduced them rather than a policy; a manifest that reached for one got a bare schema-enum error and no explanation.
Write the destination as {install_dir}¶
Every example on this page writes its destination as {install_dir}. That is the installer's own resolved destination, and it is the single value everything else agrees on:
- it defaults to
<scope root>\<app name>—%ProgramFiles%\MyAppfor a machine install,%LocalAppData%\Programs\MyAppfor a per-user one; installer.install_dir:in the manifest overrides that default;- the wizard's Destination screen writes to it, and
/D=<path>overrides it on the command line; - an upgrade reuses the prior version's location;
- and it is what the containment guards described further down anchor on.
Do not declare a parameter called install_dir. It is tempting — ${parameters.install_dir} looks like it ought to mean the same thing — but a parameter is an unrelated second value: it does not follow the Destination screen, /D=, or an upgrade. The moment a user installs anywhere but the default, your steps write to one place while the installer records another.
And there is no %VAR% expansion in a step path. A parameter defaulting to "%ProgramFiles%\\MyApp" does not resolve to Program Files: %ProgramFiles% is taken as a literal directory name, so the path is relative to the installer's working directory and is refused for landing outside install_dir. That exact pair of mistakes is what the two shipped manifests under examples/exe-wrapper/ used to make.
file_copy¶
Copies one file or a glob pattern. A from: (or to:) path that starts with the payload:// scheme is rebased onto the wrapper's extracted payload root — payload://** means "everything in the payload," payload://app/** means "the app/ subtree of the payload." A path that does not start with payload:// is resolved relative to the installer's current working directory instead, which is almost never what you want — use payload:// for every payload-sourced from:. The destination is created if missing.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
from |
string | yes | - | File path or glob. ** recurses; *.txt is non-recursive. |
to |
string | yes | - | Destination directory. |
overwrite |
bool | - | true |
Overwrite existing files. When false, an existing file at to is left alone: the step skips it, logs kept existing <name>, and reports success. Nothing is journaled for a file it did not write, so a later rollback leaves it alone too. |
Use
overwrite: falseto protect a user's config across an upgrade. Anappsettings.json, a licence file or a database that your installer ships a default for, but must not clobber once the user has edited it, is exactly what this flag is for. A file that is absent is still copied — the flag guards existing files, it is not "never copy".
- id: deploy-payload
type: file_copy
from: payload://**
to: "{install_dir}"
overwrite: true
directory_create¶
Creates a directory (recursively, like mkdir -p). No-op if the directory already exists.
- id: create-logs-dir
type: directory_create
path: "{install_dir}\\logs"
directory_delete¶
Stashes the entire subtree to a temp location for rollback, then deletes. recursive defaults to false, and with recursive: false the step fails on a non-empty directory rather than leaving partial state — so that failure mode is the default. Set recursive: true explicitly to delete a tree.
- id: wipe-prior-install
type: directory_delete
path: "{install_dir}"
recursive: true
on_failure: continue
file_delete¶
Stashes the file's bytes for rollback, then deletes. if_missing: skip (default fail) lets the step succeed silently when the file is already gone.
- id: clear-stale-config
type: file_delete
path: "{install_dir}\\old.cfg"
if_missing: skip
registry_write¶
Writes a typed value under the requested hive / key / view. Snapshots the prior value for rollback first.
| Field | Notes |
|---|---|
hive |
HKLM, HKCU, HKCR, HKU, HKCC. |
key |
Subkey path. Supports ${...} substitution. |
name |
Value name (use "" for the default value). Supports ${...} substitution. |
type_value |
REG_SZ (default), REG_EXPAND_SZ, REG_DWORD, REG_QWORD, REG_MULTI_SZ, REG_BINARY. |
value |
Scalar, list (for REG_MULTI_SZ), or hex string (for REG_BINARY). |
view |
native (default), 32bit, 64bit. |
value_typeis accepted as a legacy alias fortype_value.None of
hive,type_valueorviewis validated at pack time.sigil validateaccepts any spelling; a bad one throws at install time, on the end user's machine, not in your build. Copy the values above exactly.
- id: stamp-install-dir
type: registry_write
hive: HKLM
key: "Software\\${app.name}"
name: InstallDir
# REG_SZ, not REG_EXPAND_SZ: {install_dir} resolves to a literal absolute path,
# so there is nothing left for the registry to expand at read time.
type_value: REG_SZ
value: "{install_dir}"
registry_delete_value¶
Snapshots the prior value for rollback, then deletes. Absent values are tolerated silently.
- id: drop-legacy-flag
type: registry_delete_value
hive: HKCU
key: "Software\\MyApp"
name: LegacyMode
registry_delete_key¶
Snapshots the immediate key's values for rollback, then deletes the key. Optionally recursive.
KNOWN GAP: with
recursive: true, only the top-level values are journaled; nested subkeys are not currently restorable. Use sparingly.
- id: drop-old-config-tree
type: registry_delete_key
hive: HKCU
key: "Software\\MyApp\\OldConfig"
recursive: true
shortcut_create¶
Writes a .lnk to a named anchor or an explicit directory. The journal records a DeleteShortcut so rollback removes it.
| Field | Notes |
|---|---|
target |
Path to the program. |
location |
start_menu, desktop, or an explicit directory path — see the containment note below. |
name |
Display name; .lnk is appended automatically. |
args |
List of CLI args appended to the target. |
working_dir |
Optional. |
icon |
Optional .ico path or exe,index. |
description |
Tooltip. |
- id: shortcut-desktop
type: shortcut_create
target: "{install_dir}\\app.exe"
location: desktop
name: MyApp
description: "Launch MyApp"
locationis anchored, but not toinstall_dir. The whole point ofdesktopandstart_menuis to write outside the installed application, so theinstall_dircontainment that governsfile_copyand the config editors cannot apply here. A wider anchor does: the resolved directory must sit underinstall_dir, under a Start Menu folder, or under a Desktop folder — either scope's — with no directory junction on the way down. Everything an installer normally does is inside that: a vendor subfolder such aslocation: "C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Contoso", a Startup shortcut, a{install_dir}\\Toolsfolder.Anything else fails the step, with the directory not created and the
.lnknot written. There is noallow_outside_install_diropt-out on this step. Before this rule, an explicitlocationwas checked by nothing at all: an elevated install wouldmkdir -pa tree anywhere on the volume and journal aDeleteShortcutthat unlinks that exact path at rollback or uninstall, whether or not this installer created it — which matters as soon aslocationcarries a${parameters.…}or{var.…}value sourced from a wizard field or aregistry_read.
env_set¶
Writes a Windows environment variable to the user or machine hive (machine scope requires admin) and broadcasts WM_SETTINGCHANGE so running shells pick the change up without a logoff. Snapshots the prior value for rollback.
| Field | Notes |
|---|---|
name |
Required. The environment variable's name. |
value |
Required. The value to set, append or prepend. |
scope |
user (default) or machine. |
action |
set (default), append, or prepend. |
separator |
Delimiter for append / prepend (default ;). Ignored when the prior value is empty or absent. |
- id: env-app-home
type: env_set
scope: machine
name: MYAPP_HOME
value: "{install_dir}"
run_program¶
Spawns an external executable and (optionally) waits for it to exit, asserting the exit code is in expected_exit_codes.
Records NO journal entry: an external process is not invertible. If run_program fails with on_failure: rollback, the engine walks back over previous steps' journal records.
| Field | Notes |
|---|---|
program |
Path or PATH-resolved binary. |
args |
List of arguments; each is independently quoted by the runtime. |
wait |
true (default) blocks until exit; false fires-and-forgets. |
cwd |
Working directory. |
expected_exit_codes |
List of acceptable exit codes (default [0]). |
timeout_seconds |
Kill + fail if the child exceeds this. |
- id: run-system-setup
type: run_program
program: "{install_dir}\\SystemActions.exe"
args: ["${parameters.domain_name}", "${parameters.server_ip}"]
wait: true
expected_exit_codes: [0]
timeout_seconds: 600
http_download¶
Downloads a file over HTTPS and verifies it against a mandatory SHA-256 checksum before anything else sees it. Records a RestoreFile rollback, exactly like file_copy: a pre-existing file at dest is stashed and put back on rollback or uninstall, and a newly created one is deleted.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
url |
string | yes | - | Must start with https://. A literal http:// URL is rejected at pack time (SIG0235); a URL built from {var.…} / {install_dir} tokens is re-checked at install time and fails the step. |
dest |
string | yes | - | Destination file path. Subject to the same install_dir containment as file_copy's to. |
sha256 |
string | yes | - | Hex SHA-256 of the expected file. Required — packing a download with no integrity check is refused with SIG0236. A mismatch fails the step immediately and is never retried: it is not a transient condition. |
timeout_seconds |
int | - | 300 |
Per-attempt timeout. |
retries |
int | - | 0 |
Additional attempts after the first, with exponential backoff, for transient network failures only. Total attempts are 1 + retries. There is no resume — a retry restarts the download. |
- id: fetch-model
type: http_download
url: "https://cdn.example.com/models/v3.bin"
dest: "{install_dir}\\models\\v3.bin"
sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
timeout_seconds: 600
retries: 2
{staging_dir}is the one destination that gets extra handling: a binary downloaded there is re-verified through a held file handle and Authenticode-checked immediately before it is launched. That is how the web-installer stub fetches its payload — see Updates. For an ordinary payload file,{install_dir}\…is the right destination.
ini_write¶
Writes a single key=value entry into an INI file, stashing the file's prior content for rollback.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
path |
string | yes | - | The INI file to edit. |
key |
string | yes | - | The key to write. |
section |
string | - | "" |
The section to write into. The default, an empty string, means the keys that appear before the first [section] header. |
value |
string | - | "" |
The value to write. |
create_if_missing |
bool | - | false |
When false (the default) and the file does not exist, the step fails. |
See also ini_write values cannot inject INI lines for the newline / leading-[ rejection rules.
json_edit¶
Sets one node in a JSON document, addressed by JSON Pointer, stashing the file's prior content for rollback.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
path |
string | yes | - | The JSON file to edit. |
pointer |
string | yes | - | RFC 6901 JSON Pointer to the node. Must start with /. |
value |
string | - | "" |
The value to write. |
value_type |
enum | - | string |
string or json — see below. |
create_if_missing |
bool | - | false |
When false (the default) and the file does not exist, the step fails. |
xml_edit¶
Sets an element's text or an attribute's value in an XML document, addressed by XPath, stashing the file's prior content for rollback.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
path |
string | yes | - | The XML file to edit. |
xpath |
string | yes | - | XPath selecting the element to edit. |
attribute |
string | - | (none) | The attribute to set. Omit it to set the selected element's text instead. |
value |
string | - | "" |
The value to write. |
create_if_missing |
bool | - | false |
When false (the default), a missing file fails the step and so does an xpath that matches no element. When true, a missing element is created — but only for a simple absolute path of the form /a/b/c; anything with predicates, wildcards or axes still fails. |
See also xml_edit refuses a document that declares a <!DOCTYPE>.
Privileged step targets are anchored — read this before the next four steps¶
service_install, scheduled_task_create, com_register and firewall_rule hand a path from your manifest to something running with SYSTEM-level authority: the SCM launches a service binary, schtasks is always invoked with /RU SYSTEM, com_register loads the DLL into the elevated installer process and calls its DllRegisterServer, and firewall_rule's program= grants that executable the exemption you asked for.
Two conditions, both enforced at install time. The resolved target must
- resolve inside
install_dir— after${…}/{…}substitution and canonicalization, with no directory junction anywhere on the way down (junctions need no privilege on Windows, so a link planted inside the install directory is the realistic redirection primitive); and- sit in a directory only administrators can write — owned by
NT AUTHORITY\SYSTEM,BUILTIN\AdministratorsorNT SERVICE\TrustedInstaller, with no write-class right granted to anyone else.A step whose target fails either condition fails with a message naming the condition it failed. Nothing is created and nothing is journaled.
The second condition is the one that matters. A path can be perfectly contained inside install_dir and still be replaceable by any unprivileged user — which is exactly the attack: a legitimately signed installer, a UAC prompt an administrator approves, and a SYSTEM-level task or service left pointing at a binary anyone can overwrite.
In practice this means these four steps need a machine-scope install into an admin-only location. %ProgramFiles% and %ProgramFiles(x86)% qualify. A per-user install root under %LocalAppData% does not — it is writable by the user who owns it — and neither does %ProgramData%, which grants BUILTIN\Users create rights by inheritance. install_dir is separately constrained to the scope root, so Setup.exe /allusers /D=C:\Users\Public\evil is refused before any step runs.
The examples below all use {install_dir}\…, which satisfies both conditions for a machine-scope install. A path hard-coded outside the install directory does not, and will fail the step.
A
payload://target is refused for these four steps.payload://resolves to the temporary directory the embedded payload is extracted into (%TEMP%\sigil-…), which fails both conditions: it is writable by the user who launched the installer, and Sigil deletes it when the run finishes — so a service or scheduled task pointing into it would be left pointing at a path that no longer exists. Sequence afile_copyfrompayload://intoinstall_dirfirst, then point the privileged step at the copied location. That is the orderingservice_installalready required for its own reasons.
service_install¶
Registers a Windows service via sc.exe create, optionally starts it. Records a RemoveService rollback so a failed install or setup.exe /Uninstall stops + deletes the service.
binary_pathis a privileged target. It must resolve insideinstall_dirand sit in a directory no non-administrator can write — see the anchoring rules above. Otherwise the step fails and no service is created.
| Field | Notes |
|---|---|
name |
Service name (sc.exe's positional name). |
binary_path |
Path to the service executable. Must exist before this step runs (sequence file_copy first). |
display_name |
Defaults to name. |
description |
Optional sc description value. |
start_type |
auto (default), demand, disabled, boot, system. |
service_account |
LocalSystem (default), NetworkService, LocalService. |
start_after_install |
Default true. sc start is best-effort; "already running" is treated as success. |
Neither
start_typenorservice_accountis validated at pack time. An unrecognizedstart_typesilently falls back toauto, and an unrecognizedservice_accountsilently falls back toLocalSystem— sostart_type: automaticorservice_account: NetworkServicesships without a diagnostic and installs a service configured differently from what the manifest says. Spell them exactly as listed above.
- id: install-update-service
type: service_install
name: MyAppUpdateService
binary_path: "{install_dir}\\Updater.exe"
display_name: "MyApp Update Service"
description: "Background updater for MyApp."
start_type: auto
service_account: LocalSystem
start_after_install: true
scheduled_task_create¶
Creates a Windows Scheduled Task via schtasks.exe /Create, always running the task as SYSTEM (/RU SYSTEM).
Machine-scope only. This step touches machine-global state, so the manifest must set
installer.scope: machine. Underuserorautoscope, packing fails with SIG0310 (installer.scope: machinerequired for this step).
programis a privileged target. The task always runs asSYSTEM(/RU SYSTEM), soprogrammust resolve insideinstall_dirand sit in a directory no non-administrator can write — see the anchoring rules above. Otherwise the step fails and no task is created.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name |
string | yes | - | Task name (schtasks's /TN). |
program |
string | yes | - | Path to the program the task runs (/TR). |
arguments |
string | - | - | Arguments appended to program in the task's command line. |
trigger |
enum | yes | - | logon, daily, or onstart. |
run_level |
enum | - | limited |
limited or highest (/RL). |
programmust not contain a double quote.schtasksparses the/TRvalue as its own miniature command line, so an embedded"moves where the executable token ends — the task would run something other than what the manifest says. A quote inprogramfails the step. This is only about author-supplied quotes: the quoting a spaced path needs is added for you, andargumentsis unrestricted (quoted flag values there are ordinary and cannot displace the executable).
For trigger: daily, the step always passes a fixed /ST 00:00 start time rather than the packing machine's wall-clock time — this keeps the produced task deterministic across repeated pack runs of the same manifest. logon and onstart triggers need no start time. The create is run with /F (force overwrite), so a repeat install/repair is idempotent.
Journals a DeleteScheduledTask record (task name only) before the create, so a mid-install crash or setup.exe /Uninstall both run schtasks /Delete /TN <name> /F to tear the task down.
- id: register-heartbeat-task
type: scheduled_task_create
name: MyAppHeartbeat
program: "{install_dir}\\heartbeat.exe"
trigger: daily
run_level: limited
com_register¶
Self-registers a COM DLL by loading it and invoking its exported HRESULT DllRegisterServer(void).
Machine-scope only.
DllRegisterServerwrites machine-global registration (HKLM\Software\Classes/HKCR\CLSID), so the manifest must setinstaller.scope: machine. Underuserorautoscope, packing fails with SIG0310.
pathis a privileged target — and the sharpest of the four. The DLL is loaded into the elevated installer process and one of its exports is called, so a user-writable path here is arbitrary code execution as administrator. It must resolve insideinstall_dirand sit in a directory no non-administrator can write — see the anchoring rules above. Otherwise the step fails and the DLL is never loaded.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
path |
string | yes | - | Path to the COM DLL to register. |
Journals an UnregisterCom record (DLL path only) before the register, so a rollback later in the run or setup.exe /Uninstall both call DllUnregisterServer on the same path. A DLL that fails to load or has no DllRegisterServer export fails the step with a diagnostic message and withdraws that record — not because Sigil can prove nothing was written, but because the undo it would be keeping cannot be called, so replaying it could only ever report a registration it never managed to probe. A DllRegisterServer that runs and returns a failure HRESULT keeps its record: it may have written part of its registration before giving up, and its DllUnregisterServer is callable.
Your
DllUnregisterServerhas to work. Unlike a missing service or firewall rule, a COM registration cannot be queried — callingDllUnregisterServeris the only way to find out — so Sigil treats anything other thanS_OKfrom it as the registration is still in place, reports the uninstall as failed, and keepsuninstall.jsonand the Add/Remove Programs entry so the user can retry. Ship both exports, and makeDllUnregisterServerreturnS_OKwhen there is nothing left to remove.And do not register from
DllMain. Put every registry write inDllRegisterServerand every removal inDllUnregisterServer.DllMainruns on load — before Sigil has looked forDllRegisterServerat all, and even on the load that ultimately fails (LoadLibraryExreports failure whenDllMainreturns FALSE, after it has already executed). Anything yourDllMainwrites is therefore outside what the step can reason about or reverse.What this step grants — read before you write one.
com_registeris an explicit grant of arbitrary code execution as administrator to the DLL you name. Sigil loads it into the elevated installer process and calls one of its exports; it guarantees only that the DLL is the one you shipped, sitting where only administrators can have put it (the anchoring rules above). It cannot guarantee anything about what yourDllRegisterServerthen does.Two practical consequences. A DLL that faults takes the installer down with it, and nothing is unwound: the rollback journal is held in memory and is only written to
uninstall.jsonwhen the install commits, so a process that dies mid-run leaves both the files already laid down and whatever yourDllRegisterServerhad written, with no record of either — the user has to clean up by hand. (An earlier version of this guide claimed the journal was already on disk and the install would still unwind; it is not, and it does not.) There is also no timeout, so aDllRegisterServerthat hangs wedges the install. Keep self-registration code short and defensive, and prefer failing with a non-zeroHRESULTover throwing.This is deliberate, not an oversight. Running the DLL in a
regsvr32.exechild would not lower its privileges — a child inherits the installer's elevated token, and machine-global COM registration is what the step is for — so it would buy crash isolation at the cost of bitness guesswork, unusable exit codes, and a heavily EDR-flagged process launch in every installer. The reasoning is recorded in ADR-012.
- id: register-shell-extension
type: com_register
path: "{install_dir}\\ShellExt.dll"
firewall_rule¶
Creates a Windows Defender Firewall rule via netsh advfirewall firewall add rule.
Machine-scope only. There is no per-user firewall policy store — firewall rules are always machine-global — so the manifest must set
installer.scope: machine. Underuserorautoscope, packing fails with SIG0310.
programis a privileged target. When set, it must resolve insideinstall_dirand sit in a directory no non-administrator can write — see the anchoring rules above. Otherwise the step fails and no rule is added. A rule with noprogramhas no target to anchor and is unaffected.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name |
string | yes | - | Rule name (name=). |
direction |
enum | yes | - | in or out (dir=). |
action |
enum | yes | - | allow or block (action=). |
program |
string | - | - | Restricts the rule to this executable (program=). |
port |
int | - | - | Restricts the rule to this local port (localport=). |
protocol |
enum | - | - | tcp or udp (protocol=). Defaults to tcp when port is set and protocol is left unset; stays unset for a whole-program rule with no port. |
Reinstall idempotency: unlike service_install/scheduled_task_create, a repeated netsh advfirewall firewall add rule with a duplicate name= adds a second rule rather than erroring. To keep a reinstall/repair idempotent, this step deletes any existing rule with the same name (best-effort, tolerating "no rules match") immediately before the add.
Journals a DeleteFirewallRule record (rule name only) before the delete-then-add, so a mid-install crash or setup.exe /Uninstall both run netsh advfirewall firewall delete rule name=<name> to tear the rule down.
- id: open-app-port
type: firewall_rule
name: MyApp Inbound
direction: in
action: allow
program: "{install_dir}\\app.exe"
port: 8443
protocol: tcp
Common fields¶
Every step accepts the same envelope:
| Field | Required | Default | Notes |
|---|---|---|---|
id |
yes | - | Stable identifier. Appears in logs and rollback journal entries. |
type |
yes | - | One of the step types above. |
when |
- | - | Expression gating execution. See Conditional installs. |
on_failure |
- | rollback |
In a journalled phase (install_steps:, pre_install:, post_install:, uninstall:) the value is rollback (abort and replay the whole journal in reverse — the default) or continue (log + proceed). In an installer.hooks.* phase it is fail (abort; there is no journal to unwind) or continue; the hook default is fail for pre_* and continue for post_*. The two families' words are not interchangeable: naming the other family's word is an error (SIG0233), not a silent alias. |
allow_outside_install_dir |
- | false |
Opts this step out of destination containment (below). Accepted only by file_copy, directory_create, file_delete, directory_delete, http_download, ini_write, json_edit and xml_edit; on any other step type it is an unrecognized field and has no effect. |
There is no "abort without rollback", and no "undo only up to this step". A journalled phase that aborts always replays the entire journal in reverse, across all phases.
on_failure: failused to be accepted here and did exactly that despite its name; it is now refused withSIG0233rather than quietly meaningrollback(R78). If you need a step's failure not to unwind the install, useon_failure: continueand test the condition yourself.
Every step destination is contained to install_dir¶
The steps that create, write, download or delete — file_copy (to), directory_create (path), file_delete (path), directory_delete (path), http_download (dest), ini_write / json_edit / xml_edit (path) — resolve their destination and then require it to be inside install_dir, with no directory junction anywhere on the way down.
Two things this stops. A path that escapes the install directory (..\.., an absolute path elsewhere, a junction planted inside install_dir) is refused rather than followed; and because File.WriteAllText truncates an existing file in place, keeping its owner and its access control list, a config edit that landed on an attacker-created placeholder would leave that file attacker-writable after your elevated installer wrote to it.
If a step genuinely needs to write outside the installed application — a machine-wide configuration file under ProgramData is the usual case — say so on that step:
- id: write-machine-config
type: ini_write
allow_outside_install_dir: true
path: "C:\\ProgramData\\MyApp\\machine.ini"
section: service
key: endpoint
value: "https://api.example.com"
There is no
%VAR%expansion in a step path.%ProgramData%\MyAppis not a path — it is a relative directory whose first component is literally%ProgramData%, and it would be created as such next to the running installer. The only substitutions a step path gets are the{…}runtime tokens listed under Paths and tokens and${…}parameter templates. If you want the path written once rather than hard-coded per environment,${ProgramData}is expanded bysigil packfrom the packing machine's environment — which means the resolved literal is baked into the package, so use it knowingly.
The opt-out is per step and deliberate: it is a declaration that this particular write is meant to leave the install tree, not a global switch. It does not relax the privileged-target rules on service_install, scheduled_task_create, com_register or firewall_rule — those have no opt-out.
Known limitation — an out-of-tree write is not undone at uninstall (register row R44). The step above is journaled, but the uninstaller's replay anchor currently accepts only records that point inside
install_dir. ARestoreConfigFilenamingC:\ProgramData\MyApp\machine.iniis therefore refused during replay, while the Add/Remove Programs entry and the install state are removed anyway — so the file you wrote stays on disk after uninstall and nothing tells the user.Until R44 lands (it resolves declared out-of-tree roots from the signed blob and widens the anchor to match), treat
allow_outside_install_diras install-only: use it for content you are content to leave behind, or delete that content explicitly from anuninstall:step, which runs before the journal replays.
ini_write values cannot inject INI lines¶
section, key and value are substituted and then concatenated into a single key=value line, so a value of 9\n[admin]\nenabled=true used to write entries into an entirely different section — which matters as soon as the value comes from a wizard field or a registry_read var rather than a literal. All three fields now reject a carriage return, a line feed, or a leading [ (how a section header begins), and the step fails with the file untouched.
Rejected rather than escaped: an INI file has no escape for a newline inside a value, so escaping would silently mangle what you wrote. All three are pack-time-authored, so failing puts the problem in front of the publisher. The leading-[ rule is deliberately conservative — a [ in a value cannot itself create a section — so write value: " [1,2,3]" or quote it differently if you need one.
json_edit writes a string unless you say value_type: json¶
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
value_type |
enum | - | string |
string writes the resolved value as a JSON string, always. json parses it and writes the resulting number, boolean, null, array or object. |
- id: set-endpoint # writes "endpoint": "https://api.example.com"
type: json_edit
path: "{install_dir}\\appsettings.json"
pointer: /Api/endpoint
value: "${parameters.endpoint}"
- id: set-worker-count # writes "workers": 4
type: json_edit
path: "{install_dir}\\appsettings.json"
pointer: /Api/workers
value: "4"
value_type: json
The step used to infer the type from the value: it ran every resolved value through a JSON parser and kept whatever came back, falling back to a string only when the parse failed. That is reasonable for a literal you typed into the manifest and wrong for everything else, because the same field also carries values resolved from a wizard field, a registry_read var or a /P<name>= argument — and those then chose the shape of the node written into your application's configuration. A value of {"admin":true} where you wrote and reviewed a string becomes an object your application reads as one. The encoding was never unsafe; the output is always well-formed JSON. What was unsafe is that the value's supplier picked its type.
So string is the default and the old inference is the opt-in. Two consequences worth knowing:
- A manifest that meant a number now writes a string until you add
value_type: json. That is a visible, one-line fix; the reverse — a silent type change driven by user input — is not. - Under
value_type: json, a value that is not valid JSON fails the step rather than quietly falling back to a string. With the intent declared, a non-parsing value is a manifest error, and the fallback would just be a second way for the supplier to pick the type.
xml_edit refuses a document that declares a <!DOCTYPE>¶
The XML a step edits is parsed with XmlResolver = null and DtdProcessing = Prohibit. Two separate guarantees:
- No external entity is ever dereferenced. A
<!ENTITY x SYSTEM "file:///C:/…">orSYSTEM "http://…"in the target file cannot make the elevated installer read a local file or reach the network on the document's behalf. .NET already defaults to this; Sigil sets it explicitly so that a future framework default,AppContextswitch or runtimeconfig knob cannot revoke the guarantee without this assignment also changing. - No DTD is parsed at all, including a purely internal subset with no external references. This is the half the resolver default never covered: an internal subset can define nested entities whose expansion is exponential (the "billion laughs" shape), and — per the containment note above — the file being edited can sit somewhere an attacker is able to write.
A document that declares a <!DOCTYPE> therefore fails the step with the file untouched rather than being edited, whether or not the doctype is expensive. If you must edit such a file, strip the doctype at pack time, or do the edit from a run_program step that owns its own parsing policy. Declarations, comments, processing instructions and whitespace all still survive an ordinary edit.
An unresolved token in a path fails the step¶
A {token} that is still present in a path after substitution — {var.instal_dir} for a typo'd installer.vars entry, say — fails the step. It is never written to disk. Previously an unknown brace token was left literal, so a single typo silently created a directory named {var.instal_dir} and the install "succeeded".
This applies to every path-valued field of every step, not just the destinations listed above: it is enforced where paths are resolved, so run_program.program, shortcut_create.target, service_install.binary_path and scheduled_task_create.program are covered too. allow_outside_install_dir does not suppress it — an unresolved token is a manifest mistake under any containment policy.
The tokens a step path may use are:
| Token | Resolves to |
|---|---|
{install_dir} |
The installer's resolved destination. |
{scope_root} |
The install root for the resolved scope (%ProgramFiles% / %LocalAppData%\Programs). |
{app.name} / {app.id} |
The manifest's app.name / app.id. |
{var.<name>} |
Each declared installer.vars entry. |
{temp_dir} |
The per-user temp directory, with no trailing separator. |
{staging_dir} |
A freshly created, GUID-named private directory under temp. This is the security-relevant one: a binary downloaded here gets the held-handle re-verification and the pre-launch Authenticode check. It is created as a side effect of resolving the token. |
Plus ${parameters.<name>} templates — ${param.<name>} is an accepted alias for the same thing, and it is the spelling the schema and ADR-008 use. Anything else in braces that looks like an identifier is treated as a mistake.
Two different substitution passes, easily confused: ${…} is a template over the identifier table (parameters, app.*, option.*, var.*, scope, …), and an unknown identifier there is a hard FormatException at install time. {…} is the runtime token pass listed above, and an unknown token left in a path fails the step. See Conditional installs for the full identifier table.