@coworker-jp/aidr 0.1.298 → 0.1.301
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +27 -1
- package/src/merge.mjs +67 -0
- package/src/scanner-state.mjs +152 -0
- package/src/templates.mjs +27 -2
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -80,6 +80,11 @@ async function cmdInstall(opts) {
|
|
|
80
80
|
// the relevant options (agent, all, scope, yes, dry-run) to the uninstall
|
|
81
81
|
// flow. --key isn't needed but commander requires it via requiredOption,
|
|
82
82
|
// so we accept and ignore it.
|
|
83
|
+
//
|
|
84
|
+
// `--purge` is deliberately NOT reachable through this alias (issue #1958):
|
|
85
|
+
// it is the one option that deletes the quarantined package archives, and a
|
|
86
|
+
// convenience alias is the wrong place to be able to destroy evidence from.
|
|
87
|
+
// `aidr uninstall --purge` is the only spelling.
|
|
83
88
|
if (opts.uninstall) {
|
|
84
89
|
return cmdUninstall({
|
|
85
90
|
agent: opts.agent,
|
|
@@ -468,6 +473,18 @@ async function cmdUninstall(opts) {
|
|
|
468
473
|
}
|
|
469
474
|
}
|
|
470
475
|
|
|
476
|
+
// ai-scanner's own state (issue #1958). Resolved BEFORE phase 3: on an
|
|
477
|
+
// endpoint that only ever ran `aidr install`, the per-agent copies are the
|
|
478
|
+
// only ai-scanner binaries on the machine, and phase 3 deletes them.
|
|
479
|
+
const { scannerBinaryCandidates, findScannerBinary, cleanScannerState, describeScannerState } =
|
|
480
|
+
await import("./scanner-state.mjs");
|
|
481
|
+
const scannerBinary = await findScannerBinary(
|
|
482
|
+
scannerBinaryCandidates(home, plans.map(({ mod }) => mod?.meta?.agentDir))
|
|
483
|
+
);
|
|
484
|
+
for (const line of describeScannerState({ outcome: scannerBinary ? "dry-run" : "no-binary", binary: scannerBinary }, { purge: opts.purge })) {
|
|
485
|
+
console.error(line);
|
|
486
|
+
}
|
|
487
|
+
|
|
471
488
|
if (opts.dryRun) return;
|
|
472
489
|
|
|
473
490
|
// Phase 2: confirm
|
|
@@ -484,7 +501,15 @@ async function cmdUninstall(opts) {
|
|
|
484
501
|
}
|
|
485
502
|
}
|
|
486
503
|
|
|
487
|
-
// Phase
|
|
504
|
+
// Phase 3a: the scanner's own state, while its binaries still exist.
|
|
505
|
+
for (const line of describeScannerState(
|
|
506
|
+
cleanScannerState({ binary: scannerBinary, purge: opts.purge }),
|
|
507
|
+
{ purge: opts.purge }
|
|
508
|
+
)) {
|
|
509
|
+
console.log(line);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Phase 3b: execute
|
|
488
513
|
await Promise.all(plans.map(async ({ name, mod }) => {
|
|
489
514
|
try {
|
|
490
515
|
const res = await mod.uninstall(base, { dryRun: false });
|
|
@@ -834,6 +859,7 @@ export async function run(argv) {
|
|
|
834
859
|
.option("--scope <project|user>", "scope: user ($HOME) or project (cwd)", "user")
|
|
835
860
|
.option("-y, --yes", "proceed without confirmation prompt", false)
|
|
836
861
|
.option("--dry-run", "print what would be removed without touching disk", false)
|
|
862
|
+
.option("--purge", "also delete the quarantined package archives (they are evidence, so an ordinary uninstall keeps them)", false)
|
|
837
863
|
.action(cmdUninstall);
|
|
838
864
|
|
|
839
865
|
const redTeam = program
|
package/src/merge.mjs
CHANGED
|
@@ -150,6 +150,73 @@ export function buildCursorHooksJson(scope = "project", absHome = "") {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// Windsurf hooks.json — flat entries, one command per event (no matcher).
|
|
153
|
+
//
|
|
154
|
+
// NO `timeout` HERE, AND THAT IS DELIBERATE (#1896). Windsurf's hook entry
|
|
155
|
+
// schema has no such field: the documented entry keys are `command`,
|
|
156
|
+
// `powershell`, `show_output` and `working_directory`, and the page says
|
|
157
|
+
// nothing about any time limit at all (docs.windsurf.com/windsurf/cascade/hooks
|
|
158
|
+
// → docs.devin.ai/desktop/cascade/hooks, read 2026-08-25). The other four
|
|
159
|
+
// agents each declare one because their schemas accept it; Windsurf cannot,
|
|
160
|
+
// so this is a DECLARED inequality rather than an oversight — which is the
|
|
161
|
+
// whole reason this comment exists. Emitting an unknown key would not buy a
|
|
162
|
+
// cap and could be rejected by a future schema validation.
|
|
163
|
+
//
|
|
164
|
+
// What bounds a Windsurf hook instead: the in-script `run_scanner` cap
|
|
165
|
+
// (`AI_SCANNER_HOOK_TIMEOUT`, default 30s) plus `ensure_scanner.sh`'s
|
|
166
|
+
// `flock -w 120`. Those are OUR caps, so they hold on every editor — but
|
|
167
|
+
// there is no second, editor-side net here the way there is elsewhere.
|
|
168
|
+
// `tests/merge-hooks-json.test.mjs` pins both directions of that statement.
|
|
169
|
+
//
|
|
170
|
+
// NO `powershell` HERE EITHER, AND THAT IS A DECLARED INEQUALITY (#1907) —
|
|
171
|
+
// it is NOT a claim that Windows is covered.
|
|
172
|
+
//
|
|
173
|
+
// What the vendor actually documents (docs.windsurf.com/windsurf/cascade/hooks
|
|
174
|
+
// → docs.devin.ai/desktop/cascade/hooks, read 2026-08-25, quoted verbatim):
|
|
175
|
+
//
|
|
176
|
+
// command "The shell command to execute on **macOS/Linux** (run via
|
|
177
|
+
// `bash -c`). At least one of `command` or `powershell` must
|
|
178
|
+
// be specified."
|
|
179
|
+
// powershell "Optional. The command to execute on **Windows** (run via
|
|
180
|
+
// `powershell -Command`). If omitted on Windows, `command` is
|
|
181
|
+
// used as a fallback."
|
|
182
|
+
//
|
|
183
|
+
// and its cross-platform table, for the row we are actually in:
|
|
184
|
+
// | Windows | `command` ✓ | `powershell` ✗ | Falls back to `command` via `powershell -Command` |
|
|
185
|
+
//
|
|
186
|
+
// So on Windows the entry is NOT skipped. #1907 was filed on the assumption
|
|
187
|
+
// that it would be; the primary source says otherwise, and this comment is
|
|
188
|
+
// the corrected record. The real question is narrower and still open: what
|
|
189
|
+
// gets handed to `powershell -Command` is THE PATH OF A POSIX SHELL SCRIPT
|
|
190
|
+
// (`.windsurf/coworker-ai/pre_command.sh`), and every hook adapter this
|
|
191
|
+
// product ships — all five agents, 42 files — is a `.sh`. There is no
|
|
192
|
+
// Windows-native adapter anywhere in this tree to point a `powershell` key at.
|
|
193
|
+
//
|
|
194
|
+
// Why the outcome is invisible either way: the same page's exit-code table
|
|
195
|
+
// says `0` proceeds, `2` blocks, and "Any other | Error | Action proceeds
|
|
196
|
+
// normally". A fallback that fires and cannot run therefore produces the same
|
|
197
|
+
// user-visible result as a scan that ran and found nothing. Fail-open is
|
|
198
|
+
// deliberate, and it is exactly what hides this.
|
|
199
|
+
//
|
|
200
|
+
// UNMEASURED: whether that fallback reaches ai-scanner on a real Windows
|
|
201
|
+
// machine. This development host has no Windows machine with Windsurf on it.
|
|
202
|
+
//
|
|
203
|
+
// Two wrong moves, spelled out so nobody makes them from this comment alone:
|
|
204
|
+
// * emit `powershell: "bash <path>.sh"` — plausible, and unverified. If Git
|
|
205
|
+
// Bash is not on PATH it exits non-zero, which the table above turns into
|
|
206
|
+
// "proceeds normally" — the same silent gap, now with a tree that CLAIMS
|
|
207
|
+
// Windows is handled. A bare `bash` on Windows can also resolve to WSL's
|
|
208
|
+
// `C:\Windows\System32\bash.exe`, which sees a different filesystem than
|
|
209
|
+
// the path we wrote.
|
|
210
|
+
// * delete this comment because "the docs say it falls back" — the fallback
|
|
211
|
+
// is documented; the fallback WORKING is not.
|
|
212
|
+
//
|
|
213
|
+
// HOW THIS INEQUALITY RETIRES (so it cannot sit here forever): install
|
|
214
|
+
// Windsurf on a Windows machine, install these hooks, and drive a pre_* hook
|
|
215
|
+
// with an input the scanner MUST block. Judge on the block (exit 2), not on a
|
|
216
|
+
// log line or a telemetry field — fail-open makes "ran and allowed" and
|
|
217
|
+
// "never ran" byte-identical. Then either delete this block (it reaches the
|
|
218
|
+
// scanner) or add the `powershell` value the measurement showed to be right.
|
|
219
|
+
// `tests/merge-hooks-json.test.mjs` pins both directions until that day.
|
|
153
220
|
export function buildWindsurfHooksJson(scope = "project", absHome = "") {
|
|
154
221
|
const p = scope === "user" ? `${absHome}/.windsurf/coworker-ai` : ".windsurf/coworker-ai";
|
|
155
222
|
return {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-scanner's own on-disk state, at uninstall time (issue #1958).
|
|
3
|
+
*
|
|
4
|
+
* `aidr uninstall` used to remove hooks, adapter scripts and the per-agent
|
|
5
|
+
* binaries, and nothing else. The scanner's state directory
|
|
6
|
+
* (`~/.coworker/aidr/{ai-scanner,quarantine}`) survived every uninstall, which
|
|
7
|
+
* is wrong in both directions:
|
|
8
|
+
*
|
|
9
|
+
* - **Reinstalling did not reset anything.** The 7-day-rule verdict cache
|
|
10
|
+
* came back with the endpoint, so "uninstall it and install it again" —
|
|
11
|
+
* the support step a customer is told to take — did not do what it says.
|
|
12
|
+
* - **The quarantined package archives were left with no owner.** They are
|
|
13
|
+
* specimens of the packages this endpoint blocked, i.e. evidence, sitting
|
|
14
|
+
* in a directory of a product that is no longer installed and that nothing
|
|
15
|
+
* tells the user about.
|
|
16
|
+
*
|
|
17
|
+
* ## What decides what goes
|
|
18
|
+
*
|
|
19
|
+
* Nothing here. The rule — caches go, archives stay unless a purge was asked
|
|
20
|
+
* for — lives in `ScannerPaths::uninstall_state` (docker/scanner/src/paths.rs)
|
|
21
|
+
* and is reached by running the binary's own `uninstall-state` subcommand. The
|
|
22
|
+
* debian `postrm`, the Windows uninstaller and the macOS teardown call the same
|
|
23
|
+
* subcommand for the same reason: a policy transcribed into four languages
|
|
24
|
+
* drifts in three of them, and the direction it drifts in is "somebody deleted
|
|
25
|
+
* the evidence".
|
|
26
|
+
*
|
|
27
|
+
* ## Why the binary must be run BEFORE the agent directories are removed
|
|
28
|
+
*
|
|
29
|
+
* On an endpoint that only ever ran `aidr install`, the per-agent copies are
|
|
30
|
+
* the only ai-scanner binaries on the machine. Remove them first and there is
|
|
31
|
+
* nothing left to ask.
|
|
32
|
+
*/
|
|
33
|
+
import path from "path";
|
|
34
|
+
import fs from "fs/promises";
|
|
35
|
+
import { spawnSync } from "child_process";
|
|
36
|
+
|
|
37
|
+
/** Executable name, per platform. */
|
|
38
|
+
export const SCANNER_EXE =
|
|
39
|
+
process.platform === "win32" ? "ai-scanner.exe" : "ai-scanner";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Locations an OS-native installer or the sentinel daemon provisions, in
|
|
43
|
+
* preference order. Mirrors `scheduler::default_scanner_paths()` on the Rust
|
|
44
|
+
* side; kept as its own function so a test can assert both platforms without
|
|
45
|
+
* being run on both.
|
|
46
|
+
*/
|
|
47
|
+
export function systemScannerPaths(platform = process.platform, env = process.env) {
|
|
48
|
+
if (platform === "win32") {
|
|
49
|
+
const pf = env.ProgramFiles || "C:\\Program Files";
|
|
50
|
+
return [path.join(pf, "coworker", "aidr", "bin", "ai-scanner.exe")];
|
|
51
|
+
}
|
|
52
|
+
return ["/opt/coworker/aidr/bin/ai-scanner", "/usr/local/bin/ai-scanner"];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every place this uninstall could find a scanner binary: the agents being
|
|
57
|
+
* uninstalled first (they are the copies `aidr install` put there, and the
|
|
58
|
+
* only ones on an agent-only endpoint), then the OS-native locations.
|
|
59
|
+
*/
|
|
60
|
+
export function scannerBinaryCandidates(home, agentDirs, platform = process.platform, env = process.env) {
|
|
61
|
+
const exe = platform === "win32" ? "ai-scanner.exe" : "ai-scanner";
|
|
62
|
+
const perAgent = (agentDirs || [])
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.map((d) => path.join(home, d, "bin", exe));
|
|
65
|
+
return [...perAgent, ...systemScannerPaths(platform, env)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** First candidate that exists, or `null`. */
|
|
69
|
+
export async function findScannerBinary(candidates, { access } = {}) {
|
|
70
|
+
const probe = access || ((p) => fs.access(p, fs.constants.X_OK));
|
|
71
|
+
for (const candidate of candidates) {
|
|
72
|
+
try {
|
|
73
|
+
await probe(candidate);
|
|
74
|
+
return candidate;
|
|
75
|
+
} catch {
|
|
76
|
+
// Try the next one. A candidate that is missing, or present but not
|
|
77
|
+
// executable, is equally unusable here.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask the scanner to remove its own state.
|
|
85
|
+
*
|
|
86
|
+
* Returns `{ outcome, binary, status, stdout, stderr }` where `outcome` is one
|
|
87
|
+
* of `"cleaned"`, `"unresolved"` (the binary ran and reported something is
|
|
88
|
+
* still on the machine), `"failed"` (it could not be run) or `"no-binary"`.
|
|
89
|
+
*
|
|
90
|
+
* **`"no-binary"` is not success.** It is the case where we do not know what
|
|
91
|
+
* is on this machine, and the caller has to say so rather than print the same
|
|
92
|
+
* reassuring line it prints for a clean sweep.
|
|
93
|
+
*/
|
|
94
|
+
export function cleanScannerState({ binary, purge = false, dryRun = false, run = spawnSync }) {
|
|
95
|
+
if (!binary) return { outcome: "no-binary", binary: null };
|
|
96
|
+
if (dryRun) return { outcome: "dry-run", binary };
|
|
97
|
+
const args = purge ? ["uninstall-state", "--purge"] : ["uninstall-state"];
|
|
98
|
+
const res = run(binary, args, { encoding: "utf8" });
|
|
99
|
+
if (res.error) {
|
|
100
|
+
return { outcome: "failed", binary, error: res.error.message };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
outcome: res.status === 0 ? "cleaned" : "unresolved",
|
|
104
|
+
binary,
|
|
105
|
+
status: res.status,
|
|
106
|
+
stdout: res.stdout || "",
|
|
107
|
+
stderr: res.stderr || "",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The lines a user sees. Split out from `cleanScannerState` so the wording can
|
|
113
|
+
* be asserted without spawning anything.
|
|
114
|
+
*
|
|
115
|
+
* Every branch ends on what to do next. A line that stops at "could not" reads,
|
|
116
|
+
* to someone who has just uninstalled a security product, as "so there is
|
|
117
|
+
* nothing to worry about" — and in the `"no-binary"` branch we specifically do
|
|
118
|
+
* not know that.
|
|
119
|
+
*/
|
|
120
|
+
export function describeScannerState(result, { purge = false } = {}) {
|
|
121
|
+
switch (result.outcome) {
|
|
122
|
+
case "dry-run":
|
|
123
|
+
return [
|
|
124
|
+
` [scanner] ${purge ? "remove" : "clear"} this endpoint's scanner state via ${result.binary} uninstall-state${purge ? " --purge" : ""}`,
|
|
125
|
+
purge
|
|
126
|
+
? " (cached verdicts AND the quarantined package archives)"
|
|
127
|
+
: " (cached verdicts; quarantined package archives are kept and their location is printed)",
|
|
128
|
+
];
|
|
129
|
+
case "cleaned":
|
|
130
|
+
return (result.stdout || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`);
|
|
131
|
+
case "unresolved":
|
|
132
|
+
return [
|
|
133
|
+
...(result.stdout || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`),
|
|
134
|
+
...(result.stderr || "").split("\n").filter(Boolean).map((l) => `[scanner] ${l}`),
|
|
135
|
+
"[scanner] Some of this endpoint's scanner state is still on the machine (listed above).",
|
|
136
|
+
"[scanner] Next: delete those paths yourself, or ask your IT administrator to.",
|
|
137
|
+
];
|
|
138
|
+
case "failed":
|
|
139
|
+
return [
|
|
140
|
+
`[scanner] ${result.binary} could not be run (${result.error}), so this endpoint's scanner state was left where it is.`,
|
|
141
|
+
"[scanner] It is under ~/.coworker/aidr/ unless AI_SCANNER_DATA_DIR / AI_SCANNER_QUARANTINE_DIR name another location.",
|
|
142
|
+
"[scanner] Next: delete that directory yourself, or ask your IT administrator to.",
|
|
143
|
+
];
|
|
144
|
+
case "no-binary":
|
|
145
|
+
default:
|
|
146
|
+
return [
|
|
147
|
+
"[scanner] No ai-scanner binary was found on this endpoint, so its state was left where it is.",
|
|
148
|
+
"[scanner] It is under ~/.coworker/aidr/ unless AI_SCANNER_DATA_DIR / AI_SCANNER_QUARANTINE_DIR name another location, and it holds the quarantined package archives this endpoint kept as evidence.",
|
|
149
|
+
"[scanner] Next: delete that directory yourself once you no longer need it, or ask your IT administrator to.",
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/templates.mjs
CHANGED
|
@@ -55,6 +55,14 @@ mkdir -p "$BIN_DIR"
|
|
|
55
55
|
|
|
56
56
|
# macOS has no base \`timeout\` (it's in GNU coreutils). Shim it so hooks work
|
|
57
57
|
# on fresh Macs without brew install coreutils.
|
|
58
|
+
#
|
|
59
|
+
# NOTE (issue #1879): the last branch — a bare macOS with neither \`timeout\` nor
|
|
60
|
+
# \`gtimeout\` — DROPS the duration and execs directly. On such a machine every
|
|
61
|
+
# \`timeout N "$BIN" ...\` below is unbounded, and the only remaining safety net
|
|
62
|
+
# is the editor's own hook timeout (Claude Code / Codex: 60s, written by
|
|
63
|
+
# merge.mjs; Cursor / Windsurf / Kiro: unverified). Installing coreutils is the
|
|
64
|
+
# documented remedy. The shim is deliberately kept — a hook that refused to run
|
|
65
|
+
# without coreutils would be worse than one that runs unbounded.
|
|
58
66
|
if ! command -v timeout >/dev/null 2>&1; then
|
|
59
67
|
if command -v gtimeout >/dev/null 2>&1; then
|
|
60
68
|
timeout() { gtimeout "$@"; }
|
|
@@ -63,6 +71,23 @@ if ! command -v timeout >/dev/null 2>&1; then
|
|
|
63
71
|
fi
|
|
64
72
|
fi
|
|
65
73
|
|
|
74
|
+
# Bounds for the two ai-scanner invocations this script makes. Both sit
|
|
75
|
+
# STRICTLY ABOVE the binary's own internal network bound for that subcommand,
|
|
76
|
+
# so the wrapper only ever fires on a genuinely hung binary and never pre-empts
|
|
77
|
+
# a slow-but-working one. (A bound that fires on healthy-but-slow would print
|
|
78
|
+
# the "we did NOT check" message spuriously — a false positive, which is how
|
|
79
|
+
# guards stop being read.)
|
|
80
|
+
#
|
|
81
|
+
# version: load_plan -> /verify, reqwest timeout 10s
|
|
82
|
+
# (docker/scanner/src/main.rs). 20s leaves 10s for process start.
|
|
83
|
+
# update: /verify (10s) THEN the binary download (30s), sequential
|
|
84
|
+
# (docker/scanner/src/main.rs cmd_update) = 40s internal worst case.
|
|
85
|
+
# 60s leaves 20s for hashing and the rename. Killing an update
|
|
86
|
+
# mid-flight is safe: it writes to a temp file in the same directory
|
|
87
|
+
# and only renames over \$BIN once the advertised hash matches.
|
|
88
|
+
VERSION_TIMEOUT=20
|
|
89
|
+
UPDATE_TIMEOUT=60
|
|
90
|
+
|
|
66
91
|
# Generic throttle: true (0) if stamp missing or older than PULL_INTERVAL
|
|
67
92
|
should_check_update() {
|
|
68
93
|
[ ! -f "$PULL_STAMP" ] && return 0
|
|
@@ -248,7 +273,7 @@ rm -f "$UNSCANNED_STAMP"
|
|
|
248
273
|
# operator can act on; "|| true" turned it into silence.
|
|
249
274
|
if should_check_update; then
|
|
250
275
|
want_host=$(printf '%s' "$DOWNLOAD_BASE" | sed -E 's#^[a-z]+://([^/]+).*#\\1#')
|
|
251
|
-
have_host=$("$BIN" version 2>/dev/null | sed -n 's#^server: [a-z]*://\\([^/]*\\).*#\\1#p' | head -1)
|
|
276
|
+
have_host=$(timeout "$VERSION_TIMEOUT" "$BIN" version 2>/dev/null | sed -n 's#^server: [a-z]*://\\([^/]*\\).*#\\1#p' | head -1)
|
|
252
277
|
if [ -n "$have_host" ] && [ -n "$want_host" ] && [ "$have_host" != "$want_host" ]; then
|
|
253
278
|
echo "ai-scanner: the installed binary talks to $have_host, but this installation provisions from $want_host, so its access key can never be accepted (detection logs are refused and discarded). Re-downloading the matching binary." >&2
|
|
254
279
|
download_binary "\${DOWNLOAD_BASE}/\${PLATFORM_SLUG}" || \\
|
|
@@ -256,7 +281,7 @@ if should_check_update; then
|
|
|
256
281
|
elif [ -z "$have_host" ]; then
|
|
257
282
|
echo "ai-scanner: could not read which license server the installed binary uses, so the environment match was NOT checked. If detection logs are being refused, re-run the installer." >&2
|
|
258
283
|
fi
|
|
259
|
-
"$BIN" update 2>/dev/null || \\
|
|
284
|
+
timeout "$UPDATE_TIMEOUT" "$BIN" update 2>/dev/null || \\
|
|
260
285
|
echo "ai-scanner: the scanner could not update itself just now. It keeps scanning with the rules it already has; if this repeats, re-run the installer or contact your IT administrator." >&2
|
|
261
286
|
date +%s > "$PULL_STAMP"
|
|
262
287
|
fi
|