Skip to content

Commit 44a4025

Browse files
committed
chore(wheelhouse): cascade template@69b7686a
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-bin-94050. 35 file(s) touched: - .claude/hooks/fleet/_shared/package-manager-auto-update.mts - .claude/hooks/fleet/c8-ignore-reason-guard/index.mts - .claude/hooks/fleet/c8-ignore-reason-guard/test/index.test.mts - .claude/hooks/fleet/commit-author-guard/README.md - .claude/hooks/fleet/commit-author-guard/index.mts - .claude/hooks/fleet/commit-author-guard/test/index.test.mts - .claude/hooks/fleet/no-revert-guard/README.md - .claude/hooks/fleet/no-revert-guard/index.mts - .claude/hooks/fleet/no-revert-guard/test/index.test.mts - .claude/hooks/fleet/package-manager-auto-update-guard/README.md - .claude/hooks/fleet/package-manager-auto-update-guard/index.mts - .claude/hooks/fleet/package-manager-auto-update-guard/package.json - .claude/hooks/fleet/package-manager-auto-update-guard/test/index.test.mts - .claude/hooks/fleet/package-manager-auto-update-guard/tsconfig.json - .claude/skills/fleet/cascading-fleet/lib/fleet-repos.txt - .config/fleet/oxlint-plugin/index.mts - .config/fleet/oxlint-plugin/rules/export-top-level-functions.mts - .config/fleet/oxlint-plugin/rules/no-use-strict-in-esm.mts - .config/fleet/oxlint-plugin/test/export-top-level-functions.test.mts - .config/fleet/oxlint-plugin/test/no-use-strict-in-esm.test.mts ... and 15 more
1 parent 8f5b58b commit 44a4025

35 files changed

Lines changed: 1604 additions & 506 deletions
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/**
2+
* @file Single source of truth for "is this package manager's auto-update
3+
* disabled on this machine?" — shared by the pkg-auto-update-guard hook
4+
* (point-of-use block), the audit-pkg-auto-update.mts script (drift report in
5+
* `check --all`), and setup-security-tools (which sets the knobs). A package
6+
* manager that auto-updates mid-task can change a tool's version underneath a
7+
* build/scan, add latency, or pull an unsoaked package — a reproducibility +
8+
* supply-chain hazard. The knob lives OUTSIDE the repo (env vars, npmrc,
9+
* chocolatey.config, winget settings) so it drifts per machine; this module
10+
* centralizes the knob + how to read it so the three consumers never diverge.
11+
*/
12+
13+
// oxlint-disable-next-line socket/prefer-async-spawn -- detection runs in a sync hook + sync audit script; needs typed string stdout, no async.
14+
import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
15+
import { existsSync, readFileSync } from 'node:fs'
16+
import os from 'node:os'
17+
import path from 'node:path'
18+
import process from 'node:process'
19+
20+
import { findInvocation } from './shell-command.mts'
21+
22+
export type PkgManagerPlatform = 'darwin' | 'linux' | 'win32' | 'all'
23+
24+
export interface AutoUpdateStatus {
25+
// The manager id (matches AutoUpdateCheck.id).
26+
id: string
27+
// 'disabled' = auto-update is off (good); 'enabled' = on (drift, blockable);
28+
// 'absent' = the manager isn't installed/configured on this machine, so the
29+
// check is not applicable (never blocks, never fails CI).
30+
state: 'disabled' | 'enabled' | 'absent'
31+
// One-line explanation of what was read.
32+
reason: string
33+
// Imperative fix the operator runs to disable auto-update.
34+
fix: string
35+
}
36+
37+
export interface AutoUpdateCheck {
38+
// Stable id, e.g. 'homebrew'.
39+
id: string
40+
// Binary names whose Bash invocation should be guarded.
41+
binaries: readonly string[]
42+
// Platforms this manager runs on; 'all' = every platform.
43+
platform: PkgManagerPlatform
44+
// Imperative fix string surfaced to the operator.
45+
fix: string
46+
// Read current machine state. Pure-ish: only reads env / files / `<mgr>
47+
// config`. Never mutates.
48+
detect: () => AutoUpdateStatus
49+
}
50+
51+
// Resolve an env var to its trimmed value, treating empty as unset.
52+
export function envValue(name: string): string | undefined {
53+
const v = process.env[name]
54+
return v === undefined || v === '' ? undefined : v
55+
}
56+
57+
// True when an env var is set to a truthy "on" value (1 / true / yes).
58+
export function envIsOn(name: string): boolean {
59+
const v = envValue(name)?.toLowerCase()
60+
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
61+
}
62+
63+
// Run a binary with args and return trimmed stdout, or undefined when the
64+
// binary is missing / the call exits non-zero (manager absent). Never throws.
65+
// Takes an arg array (not a shell string) so no shell parsing / injection.
66+
export function readCommand(
67+
binary: string,
68+
args: readonly string[],
69+
): string | undefined {
70+
try {
71+
const result = spawnSync(binary, args as string[], { stdio: 'pipe' })
72+
if (result.status !== 0) {
73+
return undefined
74+
}
75+
const { stdout } = result
76+
return typeof stdout === 'string' ? stdout.trim() : String(stdout).trim()
77+
} catch {
78+
return undefined
79+
}
80+
}
81+
82+
// True when `binary` resolves on PATH (manager installed). `command -v` is a
83+
// shell builtin (not spawnable directly), so probe with the platform's PATH
84+
// resolver binary: `where` on Windows, `which` elsewhere.
85+
export function hasBinary(binary: string): boolean {
86+
return os.platform() === 'win32'
87+
? readCommand('where', [binary]) !== undefined
88+
: readCommand('which', [binary]) !== undefined
89+
}
90+
91+
export const AUTO_UPDATE_CHECKS: readonly AutoUpdateCheck[] = [
92+
{
93+
id: 'homebrew',
94+
binaries: ['brew'],
95+
platform: 'darwin',
96+
fix: 'export HOMEBREW_NO_AUTO_UPDATE=1 (run setup-security-tools to persist it to ~/.zshenv)',
97+
detect(): AutoUpdateStatus {
98+
if (!hasBinary('brew')) {
99+
return {
100+
id: 'homebrew',
101+
state: 'absent',
102+
reason: 'brew not on PATH',
103+
fix: this.fix,
104+
}
105+
}
106+
const on = envIsOn('HOMEBREW_NO_AUTO_UPDATE')
107+
return {
108+
id: 'homebrew',
109+
state: on ? 'disabled' : 'enabled',
110+
reason: on
111+
? 'HOMEBREW_NO_AUTO_UPDATE is set'
112+
: 'HOMEBREW_NO_AUTO_UPDATE is unset — `brew install` triggers `brew update`',
113+
fix: this.fix,
114+
}
115+
},
116+
},
117+
{
118+
id: 'chocolatey',
119+
binaries: ['choco'],
120+
platform: 'win32',
121+
fix: 'choco feature disable -n autoUpdate',
122+
detect(): AutoUpdateStatus {
123+
if (!hasBinary('choco')) {
124+
return {
125+
id: 'chocolatey',
126+
state: 'absent',
127+
reason: 'choco not on PATH',
128+
fix: this.fix,
129+
}
130+
}
131+
// `choco feature list` prints e.g. "autoUpdate - [Disabled] ...".
132+
const out = readCommand('choco', ['feature', 'list', '-r']) ?? ''
133+
const line = out
134+
.split(/\r?\n/u)
135+
.find(l => l.toLowerCase().startsWith('autoupdate'))
136+
const disabled = line ? /disabled/iu.test(line) : false
137+
return {
138+
id: 'chocolatey',
139+
state: disabled ? 'disabled' : 'enabled',
140+
reason: disabled
141+
? 'choco autoUpdate feature is disabled'
142+
: 'choco autoUpdate feature is enabled',
143+
fix: this.fix,
144+
}
145+
},
146+
},
147+
{
148+
id: 'winget',
149+
binaries: ['winget'],
150+
platform: 'win32',
151+
fix: 'set winget settings.json `"network": { "downloader": "wininet" }` and disable source auto-update (autoUpdateIntervalInMinutes: 0)',
152+
detect(): AutoUpdateStatus {
153+
if (!hasBinary('winget')) {
154+
return {
155+
id: 'winget',
156+
state: 'absent',
157+
reason: 'winget not on PATH',
158+
fix: this.fix,
159+
}
160+
}
161+
const localAppData = process.env['LOCALAPPDATA'] ?? ''
162+
const settingsPath = path.join(
163+
localAppData,
164+
'Packages',
165+
'Microsoft.DesktopAppInstaller_8wekyb3d8bbwe',
166+
'LocalState',
167+
'settings.json',
168+
)
169+
let interval: number | undefined
170+
if (localAppData && existsSync(settingsPath)) {
171+
try {
172+
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
173+
source?: { autoUpdateIntervalInMinutes?: number } | undefined
174+
}
175+
interval = parsed.source?.autoUpdateIntervalInMinutes
176+
} catch {}
177+
}
178+
const disabled = interval === 0
179+
return {
180+
id: 'winget',
181+
state: disabled ? 'disabled' : 'enabled',
182+
reason: disabled
183+
? 'winget source auto-update interval is 0'
184+
: 'winget source auto-update interval is non-zero or unset',
185+
fix: this.fix,
186+
}
187+
},
188+
},
189+
{
190+
id: 'scoop',
191+
binaries: ['scoop'],
192+
platform: 'win32',
193+
fix: 'remove any scheduled `scoop update` task (Task Scheduler) and avoid `scoop update` in cron/CI',
194+
detect(): AutoUpdateStatus {
195+
if (!hasBinary('scoop')) {
196+
return {
197+
id: 'scoop',
198+
state: 'absent',
199+
reason: 'scoop not on PATH',
200+
fix: this.fix,
201+
}
202+
}
203+
// Scoop has no install-time auto-update; the drift is a scheduled
204+
// `scoop update` task. Look for one; absence = disabled.
205+
const tasks = readCommand('schtasks', ['/query', '/fo', 'csv']) ?? ''
206+
const hasTask = /scoop\s+update/iu.test(tasks)
207+
return {
208+
id: 'scoop',
209+
state: hasTask ? 'enabled' : 'disabled',
210+
reason: hasTask
211+
? 'a scheduled `scoop update` task exists'
212+
: 'no scheduled `scoop update` task',
213+
fix: this.fix,
214+
}
215+
},
216+
},
217+
{
218+
id: 'npm',
219+
binaries: ['npm'],
220+
platform: 'all',
221+
fix: 'npm config set update-notifier false (or export NO_UPDATE_NOTIFIER=1)',
222+
detect(): AutoUpdateStatus {
223+
if (!hasBinary('npm')) {
224+
return { id: 'npm', state: 'absent', reason: 'npm not on PATH', fix: this.fix }
225+
}
226+
if (envIsOn('NO_UPDATE_NOTIFIER')) {
227+
return {
228+
id: 'npm',
229+
state: 'disabled',
230+
reason: 'NO_UPDATE_NOTIFIER is set',
231+
fix: this.fix,
232+
}
233+
}
234+
const cfg = readCommand('npm', ['config', 'get', 'update-notifier'])
235+
const disabled = cfg === 'false'
236+
return {
237+
id: 'npm',
238+
state: disabled ? 'disabled' : 'enabled',
239+
reason: disabled
240+
? 'npm update-notifier is false'
241+
: 'npm update-notifier is not false',
242+
fix: this.fix,
243+
}
244+
},
245+
},
246+
{
247+
id: 'pnpm',
248+
binaries: ['pnpm'],
249+
platform: 'all',
250+
fix: 'export NO_UPDATE_NOTIFIER=1 (pnpm honors it)',
251+
detect(): AutoUpdateStatus {
252+
if (!hasBinary('pnpm')) {
253+
return { id: 'pnpm', state: 'absent', reason: 'pnpm not on PATH', fix: this.fix }
254+
}
255+
const disabled = envIsOn('NO_UPDATE_NOTIFIER')
256+
return {
257+
id: 'pnpm',
258+
state: disabled ? 'disabled' : 'enabled',
259+
reason: disabled
260+
? 'NO_UPDATE_NOTIFIER is set'
261+
: 'NO_UPDATE_NOTIFIER is unset',
262+
fix: this.fix,
263+
}
264+
},
265+
},
266+
]
267+
268+
// True when `name` (a platform string) applies to the current OS.
269+
export function platformApplies(platform: PkgManagerPlatform): boolean {
270+
return platform === 'all' || platform === os.platform()
271+
}
272+
273+
// The blanket bypass phrase that suppresses the guard for ALL managers.
274+
export const BLANKET_BYPASS_PHRASE = 'Allow package-manager-auto-update bypass'
275+
276+
// The bypass phrases that authorize skipping the check for one manager: the
277+
// blanket phrase OR a per-manager phrase `Allow <noun> auto-update bypass`
278+
// (e.g. `Allow brew auto-update bypass`, `Allow homebrew auto-update bypass`).
279+
// Per-manager lets an operator green one manager without disabling the guard
280+
// for the rest. Both the id and the binary names are accepted nouns.
281+
export function bypassPhrasesFor(check: AutoUpdateCheck): string[] {
282+
const nouns = [check.id, ...check.binaries]
283+
const phrases = [BLANKET_BYPASS_PHRASE]
284+
const seen = new Set<string>()
285+
for (let i = 0, { length } = nouns; i < length; i += 1) {
286+
const noun = nouns[i]!
287+
if (!seen.has(noun)) {
288+
seen.add(noun)
289+
phrases.push(`Allow ${noun} auto-update bypass`)
290+
}
291+
}
292+
return phrases
293+
}
294+
295+
// The check whose binary the command invokes, if any (AST-matched, no regex).
296+
// Used by the guard to map a Bash command → the manager to verify.
297+
export function matchInvokedManager(
298+
command: string,
299+
): AutoUpdateCheck | undefined {
300+
for (let i = 0, { length } = AUTO_UPDATE_CHECKS; i < length; i += 1) {
301+
const check = AUTO_UPDATE_CHECKS[i]!
302+
for (let j = 0, blen = check.binaries.length; j < blen; j += 1) {
303+
if (findInvocation(command, { binary: check.binaries[j]! })) {
304+
return check
305+
}
306+
}
307+
}
308+
return undefined
309+
}
310+
311+
// Run every check that applies to the current platform. Used by the audit
312+
// script; 'absent' results are informational (never a drift failure).
313+
export function auditCurrentPlatform(): AutoUpdateStatus[] {
314+
const results: AutoUpdateStatus[] = []
315+
for (let i = 0, { length } = AUTO_UPDATE_CHECKS; i < length; i += 1) {
316+
const check = AUTO_UPDATE_CHECKS[i]!
317+
if (platformApplies(check.platform)) {
318+
results.push(check.detect())
319+
}
320+
}
321+
return results
322+
}

0 commit comments

Comments
 (0)