Files
shopdb-flask/plugins/endpoints/frontend/views/SystemScriptsSettings.vue
benjamin.baker b49fb60c0c Add log collection, script output capture, and timestamp fixes
Log Collector: a new collect_log action reads a plaintext log file from a
known path and returns its tail for viewing and download. Rides the existing
endpoints.updateapp grant rather than inventing one, since collecting a log is
the same trust level as pushing an application. Collected logs land in their
own table with per-asset retention of 20 instead of overloading the command
audit row, and the agent refuses UNC, traversal, wildcards, directories and
non-text content independently of the server check.

collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the
action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops
anything unnamed - so every policied endpoint refused log collection outright,
including for admins, who saw a permission-shaped error for a grant they held.
Exemption from policy is not exemption from permission: the elevated grant
still applies, and a regression test asserts both halves so a future edit
cannot quietly loosen the grant while "fixing" the policy gate.

Script output capture: successful runs can return output, gated per script by
captureoutput. Failures always returned it; success never did, which left a
green badge as the only evidence a run produced.

Timestamps: Activity and Operator Reports rendered date-only because the shared
formatter's 'date' branch drops the clock time. Run Scripts additionally
labels its timestamp, since a bare time meant "completed" on a terminal row but
"queued" on an in-flight one - indistinguishable, and the wrong reading.

Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked
for it fails closed on the unknown-action default.
2026-09-09 19:23:43 -04:00

444 lines
15 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>System Scripts</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
</div>
<p class="setting-description">
PowerShell the site agent can be told to run on an endpoint. A script is
stored here once, pinned by digest, and fetched by the agent at push time
so the bytes that run are the bytes that were reviewed.
</p>
<div class="warning-banner">
<strong>Every script here runs as SYSTEM.</strong>
There is no user context and no prompt: the agent is a service, so a
script cannot see a signed-in person's desktop, mapped drives, or
credentials. Scripts are intended to TRIGGER an already registered
scheduled task on the endpoint, which is what does the real work. Anything
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>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Runs in</th>
<th>On error</th>
<th>Body</th>
<th>Digest</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in scripts" :key="s.scriptid">
<td>{{ s.scriptname }}</td>
<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>
<span
v-if="s.captureoutput"
class="badge badge-secondary"
title="A successful run returns its output to ShopDB"
>
Returns output
</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>
</td>
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
</td>
</tr>
<tr v-if="scripts.length === 0">
<td colspan="8" style="text-align:center; color: var(--text-light);">
No scripts
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="warning-banner">
<strong>This script will run as SYSTEM on every endpoint it is
pushed to.</strong>
Write it to trigger a scheduled task that already exists on the
endpoint. It has no user session, so anything depending on a
person being signed in will not work.
</div>
<div class="form-group">
<label>Name *</label>
<input v-model="form.scriptname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description <span class="hint">(what it triggers, and why)</span></label>
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>
Script file <span class="hint">(.ps1, replaces the stored body)</span>
</label>
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
<p v-if="editing && editing.hasbody" class="hint">
Current: {{ editing.filename }}. Uploading replaces it and
restamps the digest.
</p>
<p v-else-if="editing" class="hint">
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"
/>
<details class="field-help">
<summary>How this works</summary>
<p>
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>
</details>
</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>
<details class="field-help">
<summary>How this works</summary>
<p>
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>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.captureoutput" type="checkbox" />
Return output to ShopDB on success
</label>
<details class="field-help">
<summary>How this works</summary>
<p>
A failed run always sends its output back, so you can diagnose
it from the Run Scripts history without going to the machine.
A successful run does not, unless you tick this.
</p>
<p>
Leave it off for scripts whose output could contain anything
sensitive - account names, share paths, anything a Write-Host
left behind. Turn it on for scripts where seeing the output is
the only way to tell the run did real work. Either way the
full output is written to the agent log on the endpoint.
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { scriptsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const scripts = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const pendingfile = ref(null)
const form = ref(emptyForm())
function emptyForm() {
return {
scriptname: '',
scriptdescription: '',
workingdirectory: '',
errormode: 'default',
captureoutput: false,
isactive: true
}
}
function shortdigest(s) {
return s.sha256 ? s.sha256.slice(0, 12) : '-'
}
async function loadData() {
loading.value = true
try {
const response = await scriptsApi.list({ active: 'false' })
scripts.value = response.data.data || []
} catch (err) {
toast.error(apiError(err, 'Failed to load scripts'))
} finally {
loading.value = false
}
}
function pickfile(event) {
pendingfile.value = event.target.files[0] || null
}
function openModal(script = null) {
error.value = ''
pendingfile.value = null
editing.value = script
form.value = script
? {
scriptname: script.scriptname,
scriptdescription: script.scriptdescription || '',
workingdirectory: script.workingdirectory || '',
errormode: script.errormode || 'default',
captureoutput: !!script.captureoutput,
isactive: script.isactive
}
: emptyForm()
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
pendingfile.value = null
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
scriptname: form.value.scriptname,
scriptdescription: form.value.scriptdescription,
workingdirectory: form.value.workingdirectory,
errormode: form.value.errormode,
captureoutput: form.value.captureoutput,
isactive: form.value.isactive
}
let scriptid
if (editing.value) {
await scriptsApi.update(editing.value.scriptid, payload)
scriptid = editing.value.scriptid
} else {
const created = await scriptsApi.create(payload)
scriptid = created.data.data.scriptid
}
// Second call on purpose: the body is multipart and the catalog row is
// JSON. A failure here leaves a bodyless row, which the list marks as
// unrunnable rather than pretending it is ready to push.
if (pendingfile.value) {
await scriptsApi.uploadBody(scriptid, pendingfile.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save script')
} finally {
saving.value = false
}
}
async function remove(s) {
if (!confirm(`Delete script "${s.scriptname}"?`)) return
try {
await scriptsApi.delete(s.scriptid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
onMounted(loadData)
</script>
<style scoped>
.cell-truncate {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono { font-family: monospace; }
.hint { color: var(--text-light); font-weight: normal; }
.warning-banner {
border: 1px solid var(--warning);
border-left-width: 4px;
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: rgba(184, 134, 11, 0.08);
}
.explainer {
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 1rem;
background: var(--bg-card);
color: var(--text);
}
.explainer summary {
cursor: pointer;
padding: 0.6rem 1rem;
font-weight: 600;
color: var(--text);
}
.explainer-body {
padding: 0 1rem 0.75rem;
}
.explainer-body h4 {
margin: 0.9rem 0 0.25rem;
font-size: 0.95rem;
color: var(--text);
}
.explainer-body p {
margin: 0 0 0.5rem;
color: var(--text-light);
line-height: 1.5;
}
.field-help {
margin-top: 0.4rem;
}
.field-help summary {
cursor: pointer;
color: var(--text-light);
font-size: 0.85rem;
}
.field-help p {
margin: 0.35rem 0 0;
color: var(--text-light);
font-size: 0.85rem;
line-height: 1.5;
}
</style>