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

@@ -26,11 +26,13 @@ gets the checkbox in the user editor.
"""
import os
import re
from flask import Blueprint, request, current_app
from shopdb.extensions import db
from shopdb.core.models import Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM, AuditLog
from shopdb.core.models import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
ERRORMODES, ERRORMODE_DEFAULT, AuditLog)
from shopdb.utils.responses import (
success_response,
error_response,
@@ -93,6 +95,69 @@ def _validate_runcontext(value):
return value, None
def _validate_errormode(value):
"""Normalize/validate an errormode, defaulting to permissive when absent.
Defaults to ERRORMODE_DEFAULT rather than strict on purpose. Strict promotes
PowerShell's non-terminating errors to terminating ones, so a body that was
written to tolerate a failing cleanup or to probe with a cmdlet that errors
when the target is absent would begin aborting partway through. An absent
field therefore means "what this script already did", never a silent upgrade.
"""
if value is None:
return ERRORMODE_DEFAULT, None
value = str(value).strip().lower()
if value not in ERRORMODES:
return None, 'errormode must be one of: {}'.format(', '.join(sorted(ERRORMODES)))
return value, None
def _validate_workingdirectory(value):
"""Normalize the optional folder a script runs in. Returns (value, error).
Empty means "the agent's staging directory", which is the behaviour every
script had before this field existed. The purpose is to let a script sit
beside a corporate tool already installed on the endpoint and call it by
relative path.
The checks are shape checks, NOT a privilege boundary - a script body is
already arbitrary SYSTEM PowerShell and can change its own directory. They
exist so a typo fails here with an explanation instead of on the endpoint:
- must be an absolute local path (a drive letter), because a relative one
is meaningless without knowing what it would be relative TO
- no UNC. The stated use case is a tool already on this machine; a UNC
working directory resolves as the MACHINE ACCOUNT against a file server,
which moves where the binaries come from and is a different trust story
than "Intune put this here"
- no traversal segment, which only ever obscures what the path really is
"""
if value is None:
return None, None
value = str(value).strip().strip('"')
if not value:
return None, None
if value.startswith('\\\\') or value.startswith('//'):
return None, ('workingdirectory must be a local path on the endpoint, '
'not a UNC share')
normalized = value.replace('/', '\\')
if not re.match(r'^[A-Za-z]:\\', normalized):
return None, ('workingdirectory must be an absolute path including a '
r'drive letter, for example C:\Program Files\Sysinternals')
if '..' in normalized.split('\\'):
return None, 'workingdirectory must not contain a ".." segment'
if len(normalized) > 255:
return None, 'workingdirectory must be 255 characters or fewer'
# Trailing separator is noise, EXCEPT at a drive root: "C:\" trimmed to
# "C:" is drive-RELATIVE on Windows and would resolve to wherever the
# process happened to be on that drive, which is the opposite of pinning a
# folder. Keep the root form intact.
trimmed = normalized.rstrip('\\')
if len(trimmed) < 3:
trimmed = normalized[:3]
return trimmed, None
@scripts_bp.route('', methods=['POST'])
@require_permission(SCRIPT_PERMISSION)
def create_script():
@@ -105,6 +170,12 @@ def create_script():
return error_response(ErrorCodes.VALIDATION_ERROR,
f'A script named {name!r} already exists')
runcontext, err = _validate_runcontext(data.get('runcontext'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
workingdirectory, err = _validate_workingdirectory(data.get('workingdirectory'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
errormode, err = _validate_errormode(data.get('errormode'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
@@ -112,12 +183,16 @@ def create_script():
scriptname=name,
scriptdescription=data.get('scriptdescription'),
runcontext=runcontext,
workingdirectory=workingdirectory,
errormode=errormode,
)
db.session.add(script)
db.session.commit()
AuditLog.log('created', 'Script', entityid=script.scriptid,
entityname=name,
changes={'runcontext': {'new': runcontext}})
changes={'runcontext': {'new': runcontext},
'workingdirectory': {'new': workingdirectory},
'errormode': {'new': errormode}})
db.session.commit()
return success_response(script.to_dict(), message='Script created', http_code=201)
@@ -147,6 +222,17 @@ def update_script(script_id: int):
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.runcontext = runcontext
if 'workingdirectory' in data:
workingdirectory, err = _validate_workingdirectory(
data.get('workingdirectory'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.workingdirectory = workingdirectory
if 'errormode' in data:
errormode, err = _validate_errormode(data.get('errormode'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.errormode = errormode
if 'isactive' in data:
script.isactive = bool(data.get('isactive'))