Stop scripts reporting success after doing nothing

A run_script command was judged solely on PowerShell's exit code, and
PowerShell treats most errors as non-terminating: it writes the error,
runs the next line, and still exits zero. The account-provisioning
script reported succeeded having provisioned nothing, and because no
output was captured there was no evidence to contradict the verdict.

Scripts now carry an errormode. Strict prepends $ErrorActionPreference
= 'Stop' so the first error ends the run and the exit code means what
it says. It is opt-in per script rather than global because bodies
authored against the permissive semantics rely on them - tolerating a
failing cleanup, probing with a cmdlet that errors when a thing is
absent, looping past a bad item - and would start aborting midway,
leaving endpoints half-configured. The default is unchanged, so every
existing script keeps running exactly as before.

The prologue is applied after digest verification, so the SHA-256 still
validates the bytes the server delivered.

stdout/stderr is captured on both paths: redirected streams for SYSTEM,
a transcript file under the ShopFloor profile for user-session runs,
since CreateProcessAsUser cannot inherit the service's pipes across the
session boundary. Output is always logged locally and returned to the
server only on failure, so routine output stays on the machine that
produced it instead of filling command history.

Also adds the optional per-script working directory, which the same
surfaces needed, and a collapsible explainer on the scripts page - the
dropdown is not self-explanatory, and an operator who does not know
about non-terminating errors has no reason to ever leave the default.

Agent 1.14.0. An older agent ignoring errormode keeps today's behaviour.
This commit is contained in:
benjamin.baker
2026-09-09 11:36:42 -04:00
parent f02e344101
commit d6163f54a4
16 changed files with 1411 additions and 20 deletions

View File

@@ -465,6 +465,9 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
connections with a `truncated` flag in `detail`, because an oversized payload
rejected by the server returns nothing at all and a partial answer is more
useful than a failed one. Standard reports carry listening ports only.
- `run_script` - run a reviewed PowerShell script from the script library. The
body is NOT carried on the command channel; the claim carries a descriptor and
the agent fetches the bytes separately (section 7.4).
### 7.1 Claim the next command (long-poll)
@@ -509,3 +512,69 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
gated by user JWT + `endpoints.command`. Only one command may be in flight per
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
### 7.4 `run_script` claim descriptor
A `run_script` claim carries a `script` object alongside the usual
`endpointcommandid` and `action`:
```jsonc
{
"scriptid": 12,
"scriptname": "sfld_autologon",
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
"sha256": "3f9a...", // 64 hex chars, lowercase
"runcontext": "system", // "system" | "user"
"workingdirectory": "C:\\Tools", // "" = agent staging folder
"errormode": "default" // "default" | "strict"
}
```
The agent MUST download `installpath`, verify the SHA-256 of the received bytes
against `sha256`, and refuse to run on mismatch. The body never travels on the
command channel so a queued command cannot be used to smuggle code past review.
`workingdirectory` is the folder the script runs from. Empty means the agent's
staging directory, which is correct for a self-contained script and wrong for
one invoking a separately-installed tool by relative path. A pinned folder that
does not exist MUST fail the command rather than falling back, so the script
never runs somewhere unintended.
`errormode` selects how the run is judged:
- `default` - PowerShell's normal behaviour. Most errors are non-terminating:
the script writes the error, continues, and still exits zero.
- `strict` - the agent prepends `$ErrorActionPreference = 'Stop'` so the first
error terminates the run and the exit code reflects reality.
`default` is the default precisely BECAUSE it is permissive. Every script
authored before this field existed relies on that behaviour, and some rely on it
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
when a thing is absent, looping past a bad item. Promoting `strict` globally
would make those bodies abort partway and leave endpoints half-configured, so it
is opt-in per script.
Two consequences the agent MUST honour:
- The strict prologue is applied AFTER digest verification, so the hash still
validates the bytes the server actually delivered.
- Because the prologue adds a line, an error reported on line N of a strict run
refers to line N-1 of the stored body. Surfaces showing the body should say so.
An agent that does not recognise an `errormode` value MUST treat it as
`default`. The server always sends the field explicitly, even at the default, so
an agent that received it and ignored it stays distinguishable from one that
never saw it.
#### Script output
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
It is returned to the server in `detail.output` only when the command FAILED.
Routine output stays on the machine that produced it: shipping every successful
run back would put arbitrary script output, including whatever an author printed
while debugging, into the server's command history for no diagnostic gain.
Output is truncated to 8000 characters and flagged when truncated, because an
oversized payload rejected by the server returns nothing at all.
Timed-out runs do not carry output: the script is still running and still holds
the capture file open, so anything read at that moment would be misleading.

View File

@@ -2070,9 +2070,15 @@ def _resolve_run_script(target):
"""Resolve a run_script target to a script delivery descriptor.
Accepts either "script:<scriptid>" or a bare script name. The descriptor
carries a collector-authenticated download URL, the body's SHA-256, and the
per-script run context (system or user). The agent fetches the body with
its collector credential and runs it; the body never crosses the wire.
carries a collector-authenticated download URL, the body's SHA-256, the
per-script run context (system or user), and the optional folder to run in.
The agent fetches the body with its credential and runs it; the body never
crosses the wire.
workingdirectory is sent even when empty so the field is always present in
the claim: an agent that receives it and ignores it is then distinguishable
from one that never saw it, which matters while agents of both vintages are
in the field. errormode rides the same rule, and for the same reason.
"""
kind, _sep, raw = str(target or '').partition(':')
script = None
@@ -2100,6 +2106,8 @@ def _resolve_run_script(target):
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
'sha256': script.sha256,
'runcontext': script.runcontext,
'workingdirectory': script.workingdirectory or '',
'errormode': script.errormode or 'default',
}, None

View File

@@ -20,6 +20,66 @@
written to act "as the logged-on user" will not behave the way it reads.
</div>
<details class="explainer">
<summary>How does this work?</summary>
<div class="explainer-body">
<h4>Delivery</h4>
<p>
The body is stored here and hashed. At push time the agent downloads it
with its own credential and checks the digest before running anything,
so the bytes that run are the bytes that were reviewed. The body never
travels on the command channel and is deleted from the endpoint after
the run.
</p>
<h4>Runs in</h4>
<p>
<strong>System</strong> is the agent's own service account: no desktop,
no mapped drives, no signed-in person's credentials.
<strong>User</strong> runs in the interactive session instead, and only
works when somebody is actually signed in.
</p>
<h4>Run in folder</h4>
<p>
The working directory for the run. Blank means the agent's staging
folder, which is right for a self-contained script. Set it when the
script calls a tool installed separately on the endpoint by relative
path. If the folder is missing the run fails outright rather than
running somewhere unintended.
</p>
<h4>When a line fails</h4>
<p>
This is the one worth understanding. PowerShell treats most errors as
<em>non-terminating</em>: it writes the error, carries on to the next
line, and still exits zero. Because the command's verdict comes from
that exit code, <strong>Continue</strong> means a script can report
success having accomplished nothing.
</p>
<p>
<strong>Stop</strong> makes the first error end the run, so the verdict
means what you would assume it means. It is not the default because it
changes how a script behaves: a body that tolerates a failing cleanup,
probes with a cmdlet that errors when something is absent, or loops
past bad items will start aborting partway through, leaving the
endpoint half-configured. Read the script first, then switch it on.
</p>
<h4>Where the output goes</h4>
<p>
Script output is always written to the agent log on the endpoint. It is
sent back to ShopDB only when the run fails, so routine output stays on
the machine that produced it. Long output is truncated and marked as
such.
</p>
<p class="hint">
In Stop mode the agent adds one line to the top of the script, so an
error reported on line N refers to line N-1 of the body shown here.
</p>
</div>
</details>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
@@ -29,6 +89,8 @@
<tr>
<th>Name</th>
<th>Description</th>
<th>Runs in</th>
<th>On error</th>
<th>Body</th>
<th>Digest</th>
<th>Active</th>
@@ -41,6 +103,17 @@
<td class="cell-truncate" :title="s.scriptdescription">
{{ s.scriptdescription || '-' }}
</td>
<td class="cell-truncate mono" :title="s.workingdirectory">
{{ s.workingdirectory || 'Agent staging folder' }}
</td>
<td>
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
Stop on first error
</span>
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
Continue
</span>
</td>
<td>
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
<span v-else class="badge badge-warning">No body - cannot run</span>
@@ -53,7 +126,7 @@
</td>
</tr>
<tr v-if="scripts.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">
<td colspan="8" style="text-align:center; color: var(--text-light);">
No scripts
</td>
</tr>
@@ -99,6 +172,41 @@
No body uploaded yet. A script without a body cannot be pushed.
</p>
</div>
<div class="form-group">
<label>
Run in folder
<span class="hint">(optional, endpoint-local absolute path)</span>
</label>
<input
v-model="form.workingdirectory"
type="text"
class="form-control mono"
placeholder="C:\Program Files\CorporateTools"
/>
<p class="hint">
Leave blank to run from the agent's staging folder. Set this only
when the script calls a tool already installed on the endpoint by
relative path. The folder must exist on the endpoint or the push
fails rather than running somewhere else.
</p>
</div>
<div class="form-group">
<label>
When a line fails
<span class="hint">(how the run is judged)</span>
</label>
<select v-model="form.errormode" class="form-control">
<option value="default">Continue - report success if the script reaches the end</option>
<option value="strict">Stop on the first error and fail the command</option>
</select>
<p class="hint">
PowerShell normally writes an error and carries on, and still
exits zero, so a script can report success having done none of
its work. Stop makes the first failure end the run and fail the
command. Leave this on Continue unless you have read the script
and know it does not rely on a step being allowed to fail.
</p>
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
@@ -143,6 +251,8 @@ function emptyForm() {
return {
scriptname: '',
scriptdescription: '',
workingdirectory: '',
errormode: 'default',
isactive: true
}
}
@@ -175,6 +285,8 @@ function openModal(script = null) {
? {
scriptname: script.scriptname,
scriptdescription: script.scriptdescription || '',
workingdirectory: script.workingdirectory || '',
errormode: script.errormode || 'default',
isactive: script.isactive
}
: emptyForm()
@@ -194,6 +306,8 @@ async function save() {
const payload = {
scriptname: form.value.scriptname,
scriptdescription: form.value.scriptdescription,
workingdirectory: form.value.workingdirectory,
errormode: form.value.errormode,
isactive: form.value.isactive
}
let scriptid
@@ -249,4 +363,27 @@ onMounted(loadData)
margin-bottom: 1rem;
background: rgba(184, 134, 11, 0.08);
}
.explainer {
border: 1px solid var(--border, #ddd);
border-radius: 4px;
margin-bottom: 1rem;
background: var(--surface, #fff);
}
.explainer summary {
cursor: pointer;
padding: 0.6rem 1rem;
font-weight: 600;
}
.explainer-body {
padding: 0 1rem 0.75rem;
}
.explainer-body h4 {
margin: 0.9rem 0 0.25rem;
font-size: 0.95rem;
}
.explainer-body p {
margin: 0 0 0.5rem;
color: var(--text-light);
line-height: 1.5;
}
</style>

View File

@@ -47,4 +47,59 @@ describe('SystemScriptsSettings', () => {
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
})
it('offers an optional working directory and sends it on save', () => {
expect(source).toMatch(/form\.workingdirectory/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
})
it('shows the staging default rather than an empty cell', () => {
// A blank cell reads as "unknown". The folder is the difference between a
// corporate tool being found and the script failing further in, so the
// list has to state the default out loud.
expect(source).toMatch(/Agent staging folder/)
})
it('offers an error mode and sends it on save', () => {
expect(source).toMatch(/form\.errormode/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/errormode/)
})
it('defaults a new script to the permissive mode', () => {
// Strict changes how a body behaves partway through. Defaulting to it
// would silently alter every script authored from here on, so a new
// script has to start where the old ones already are.
const empty = source.slice(source.indexOf('function emptyForm'))
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
})
it('names both error modes by their consequence, not by their flag', () => {
// "default" and "strict" mean nothing to the person choosing. What they
// need to know is which one can report success having done nothing.
expect(source).toMatch(/Continue - report success if the script reaches the end/)
expect(source).toMatch(/Stop on the first error and fail the command/)
})
it('explains the non-terminating error trap that justifies the choice', () => {
// The whole feature exists because PowerShell exits zero after an error.
// If the page never says so, the dropdown looks like pedantry and stays
// on the setting that produces false green.
expect(source).toMatch(/non-terminating/i)
expect(source).toMatch(/exits? zero/i)
})
it('offers the explainer collapsed so it does not bury the page', () => {
expect(source).toMatch(/<details class="explainer">/)
expect(source).toMatch(/How does this work\?/)
expect(source).not.toMatch(/<details class="explainer" open>/)
})
it('states that output is kept locally and only returned on failure', () => {
// Operators otherwise assume either that nothing was captured or that
// every run is shipped back to the server.
const explainer = source.slice(source.indexOf('Where the output goes'))
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
})
})