ai-install-guard 0.2.0 → 0.3.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +36 -18
- package/hooks/gate/gate.ts +116 -0
- package/hooks/gate/recheck.ts +33 -0
- package/hooks/gate/requests.ts +728 -0
- package/hooks/gate/resolve.ts +210 -0
- package/hooks/guard/decisions.ts +32 -0
- package/hooks/guard/pane.tsx +146 -22
- package/hooks/guard/passes.ts +34 -0
- package/hooks/guard/run.ts +158 -0
- package/hooks/guard/view.ts +6 -1
- package/hooks/register.tsx +49 -66
- package/hooks/scan/checks/mod.ts +9 -0
- package/hooks/scan/remote.ts +24 -3
- package/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// A scan shown in the pane: progress while it runs, then the verdict, the
|
|
2
|
+
// stored report Copy report copies and the view /guard report reopens.
|
|
3
|
+
|
|
4
|
+
import { atom, read, update } from 'claude-code'
|
|
5
|
+
import type { EngineInterface } from 'claude-code'
|
|
6
|
+
|
|
7
|
+
import type { GuardAction, GuardRequest, GuardView } from '../../types'
|
|
8
|
+
import { modHost } from '../mod-host'
|
|
9
|
+
import type { RemoteSpec } from '../scan/remote'
|
|
10
|
+
import { formatReport } from '../scan/report'
|
|
11
|
+
import { safeLine } from '../scan/safe-text'
|
|
12
|
+
import { scanFolder, scanRemote, type ScanReport } from '../scan/scan'
|
|
13
|
+
import { decisionFor, saveLastView, saveReport } from './decisions'
|
|
14
|
+
import { PANE_ID, scanningView, viewOf } from './view'
|
|
15
|
+
|
|
16
|
+
const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null)
|
|
17
|
+
|
|
18
|
+
export type ScanHow = { remote: RemoteSpec } | { folder: string }
|
|
19
|
+
|
|
20
|
+
export type ShowOptions = {
|
|
21
|
+
action: GuardAction
|
|
22
|
+
/** The install the pane's Install button carries out, for a held install. */
|
|
23
|
+
request?: GuardRequest
|
|
24
|
+
lookups: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Shown =
|
|
28
|
+
| { kind: 'done'; report: ScanReport; view: GuardView; text: string; placed: boolean; superseded: boolean }
|
|
29
|
+
| { kind: 'failed'; reason: string; view: GuardView; placed: boolean; superseded: boolean }
|
|
30
|
+
|
|
31
|
+
let scans = 0
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A name for one scan, unique in this session, that sorts after every scan
|
|
35
|
+
* started before it: the time, then a count for scans in the same moment.
|
|
36
|
+
*/
|
|
37
|
+
function newScanId(): string {
|
|
38
|
+
scans += 1
|
|
39
|
+
return `${Date.now().toString(36).padStart(10, '0')}-${scans.toString(36).padStart(6, '0')}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Opens the pane on a scan of `how`, and leaves the verdict in it. */
|
|
43
|
+
export async function showScan($: EngineInterface, target: string, how: ScanHow | (() => Promise<ScanHow>), options: ShowOptions): Promise<Shown> {
|
|
44
|
+
// Named before anything waits, so a scan asked for later is always the newer one.
|
|
45
|
+
const scanId = newScanId()
|
|
46
|
+
const host = modHost($)
|
|
47
|
+
const opened = await openPane($)
|
|
48
|
+
const scanning = { ...withRequest(scanningView(target, options.action), options.request), scanId }
|
|
49
|
+
if (!(await takePane($, scanning))) {
|
|
50
|
+
return { kind: 'failed', reason: `Could not verify ${safeLine(target)}: a newer scan has the pane`, view: scanning, placed: opened, superseded: true }
|
|
51
|
+
}
|
|
52
|
+
$.ui.status(`AI Install Guard: scanning ${safeLine(target)}`)
|
|
53
|
+
try {
|
|
54
|
+
const scanOptions = {
|
|
55
|
+
lookups: options.lookups,
|
|
56
|
+
// Stage names can carry a candidate's own words (a plugin's name).
|
|
57
|
+
onStage: (stage: string) => update($, viewAtom, current => (current === null || current.scanId !== scanId ? current : { ...current, stage: safeLine(stage) })).then(() => undefined),
|
|
58
|
+
}
|
|
59
|
+
const resolved = typeof how === 'function' ? await how() : how
|
|
60
|
+
const report = 'remote' in resolved ? await scanRemote(host, target, resolved.remote, scanOptions) : await scanFolder(host, target, resolved.folder, scanOptions)
|
|
61
|
+
// A tree you already decided on shows that decision, not the buttons
|
|
62
|
+
// again, unless it now judges worse than when you approved it.
|
|
63
|
+
const earlier = report.inventory === undefined ? undefined : await decisionFor($, { treeHash: report.inventory.treeHash, sha: report.source?.sha }, report.verdict.kind).catch(() => undefined)
|
|
64
|
+
const base = { ...withRequest(viewOf(report, options.action), options.request), scanId }
|
|
65
|
+
const view = earlier === undefined ? base : { ...base, decision: earlier.decision, decidedAt: earlier.decidedAt }
|
|
66
|
+
const text = formatReport(report, { details: true })
|
|
67
|
+
// A scan started since owns the pane now: this one leaves it, its report
|
|
68
|
+
// and the reopened report alone, so no press lands on the wrong scan.
|
|
69
|
+
if (!(await owns($, scanId))) return { kind: 'done', report, view, text, placed: opened, superseded: true }
|
|
70
|
+
// The report is stored under this scan's name before the pane shows the
|
|
71
|
+
// verdict, so Copy report copies it, and never another scan's.
|
|
72
|
+
await saveReport($, scanId, text)
|
|
73
|
+
if (!(await showOwn($, view))) return { kind: 'done', report, view, text, placed: opened, superseded: true }
|
|
74
|
+
$.ui.toast(`AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}`)
|
|
75
|
+
return { kind: 'done', report, view, text, placed: opened, superseded: false }
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const reason = `Could not verify ${safeLine(target)}: ${safeLine(error instanceof Error ? error.message : String(error))}`
|
|
78
|
+
const failed = await showFailure($, target, reason, options, scanId)
|
|
79
|
+
return { kind: 'failed', reason, view: failed.view, placed: opened, superseded: !failed.shown }
|
|
80
|
+
} finally {
|
|
81
|
+
$.ui.status(undefined)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Opens the pane on an install the guard could not scan: Could not verify,
|
|
87
|
+
* with the reason, and Install anyway behind a second confirmation.
|
|
88
|
+
* `superseded` says a newer scan has the pane instead.
|
|
89
|
+
*/
|
|
90
|
+
export async function showUnverified($: EngineInterface, target: string, reason: string, options: ShowOptions): Promise<{ view: GuardView; placed: boolean; superseded: boolean }> {
|
|
91
|
+
const scanId = newScanId()
|
|
92
|
+
const placed = await openPane($)
|
|
93
|
+
const scanning = { ...withRequest(scanningView(target, options.action), options.request), scanId }
|
|
94
|
+
if (!(await takePane($, scanning))) return { view: scanning, placed, superseded: true }
|
|
95
|
+
const failed = await showFailure($, target, `Could not verify ${safeLine(target)}: ${safeLine(reason)}`, options, scanId)
|
|
96
|
+
return { view: failed.view, placed, superseded: !failed.shown }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The pane, and the report /guard report reopens, show a failure: never a
|
|
101
|
+
* scanning pane, and never an older verdict in its place. Only while this
|
|
102
|
+
* scan still owns the pane; `shown` says whether it did.
|
|
103
|
+
*/
|
|
104
|
+
async function showFailure($: EngineInterface, target: string, reason: string, options: ShowOptions, scanId: string): Promise<{ view: GuardView; shown: boolean }> {
|
|
105
|
+
const failed: GuardView = {
|
|
106
|
+
...withRequest(scanningView(target, options.action), options.request),
|
|
107
|
+
scanId,
|
|
108
|
+
stage: '',
|
|
109
|
+
verdict: { kind: 'unverified', recommendation: 'Could not verify', reasons: [reason], counts: { pass: 0, warn: 0, fail: 0, info: 0 } },
|
|
110
|
+
}
|
|
111
|
+
if (!(await owns($, scanId).catch(() => false))) return { view: failed, shown: false }
|
|
112
|
+
await saveReport($, scanId, reason)
|
|
113
|
+
return { view: failed, shown: await showOwn($, failed).catch(() => false) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether the pane still shows this scan. */
|
|
117
|
+
async function owns($: EngineInterface, scanId: string): Promise<boolean> {
|
|
118
|
+
return (await read($, viewAtom))?.scanId === scanId
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Takes the pane for a scan just started, unless a newer scan already has it. */
|
|
122
|
+
async function takePane($: EngineInterface, view: GuardView): Promise<boolean> {
|
|
123
|
+
let taken = false
|
|
124
|
+
await update($, viewAtom, current => {
|
|
125
|
+
taken = current === null || current.scanId === undefined || view.scanId === undefined || current.scanId <= view.scanId
|
|
126
|
+
return taken ? view : current
|
|
127
|
+
})
|
|
128
|
+
return taken
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shows a scan's finished view while the pane still shows that scan, and
|
|
133
|
+
* saves it for /guard report. Should a newer scan take the pane while it is
|
|
134
|
+
* saved, the newer view is saved instead, so the reopened report is always
|
|
135
|
+
* the pane's.
|
|
136
|
+
*/
|
|
137
|
+
async function showOwn($: EngineInterface, view: GuardView): Promise<boolean> {
|
|
138
|
+
let shown = false
|
|
139
|
+
await update($, viewAtom, current => {
|
|
140
|
+
shown = current?.scanId === view.scanId
|
|
141
|
+
return shown ? view : current
|
|
142
|
+
})
|
|
143
|
+
if (!shown) return false
|
|
144
|
+
await saveLastView($, view).catch(() => undefined)
|
|
145
|
+
const now = await read($, viewAtom).catch(() => null)
|
|
146
|
+
if (now !== null && now.scanId !== view.scanId) await saveLastView($, now).catch(() => undefined)
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Opens the pane; false where no surface can show it (a headless run). */
|
|
151
|
+
async function openPane($: EngineInterface): Promise<boolean> {
|
|
152
|
+
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
153
|
+
return opened.isPlaced
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function withRequest(view: GuardView, request: GuardRequest | undefined): GuardView {
|
|
157
|
+
return request === undefined ? view : { ...view, request }
|
|
158
|
+
}
|
package/hooks/guard/view.ts
CHANGED
|
@@ -35,7 +35,7 @@ export function viewOf(report: ScanReport, action: GuardAction): GuardView {
|
|
|
35
35
|
// A remote scan's folder is a temporary one: its source names it instead.
|
|
36
36
|
root:
|
|
37
37
|
report.source !== undefined
|
|
38
|
-
?
|
|
38
|
+
? sourceRoot(report.source.url, report.source.subdir)
|
|
39
39
|
: report.inventory === undefined
|
|
40
40
|
? undefined
|
|
41
41
|
: safeLine(report.inventory.root),
|
|
@@ -48,6 +48,11 @@ export function viewOf(report: ScanReport, action: GuardAction): GuardView {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** How a decision names a remote source: its address, and the folder in it after `#`. */
|
|
52
|
+
export function sourceRoot(url: string, subdir: string | undefined): string {
|
|
53
|
+
return safeLine(`${url}${subdir === undefined ? '' : `#${subdir}`}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
function rowOf(result: CheckResult): GuardRow {
|
|
52
57
|
return {
|
|
53
58
|
id: result.id,
|
package/hooks/register.tsx
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type { EngineInterface, Register } from 'claude-code'
|
|
2
2
|
import { atom, update } from 'claude-code'
|
|
3
3
|
|
|
4
|
+
import { BROWSE_NOTE, judge } from './gate/gate'
|
|
5
|
+
import { readPluginArgs, readShellCommand } from './gate/requests'
|
|
6
|
+
import { expandHome } from './gate/resolve'
|
|
4
7
|
import { badgeFor } from './guard/badge'
|
|
5
|
-
import {
|
|
6
|
-
import { drawPane
|
|
7
|
-
import {
|
|
8
|
+
import { forgetDecisions, loadDecisions, loadLastView, loadReport } from './guard/decisions'
|
|
9
|
+
import { drawPane } from './guard/pane'
|
|
10
|
+
import { showScan } from './guard/run'
|
|
11
|
+
import { PANE_ID } from './guard/view'
|
|
8
12
|
import { modHost } from './mod-host'
|
|
9
13
|
import type { Host } from './scan/host'
|
|
10
14
|
import { looksRemote, parseRemote, type RemoteSpec } from './scan/remote'
|
|
11
|
-
import { formatReport } from './scan/report'
|
|
12
15
|
import { safeLine } from './scan/safe-text'
|
|
13
|
-
import { scanFolder, scanRemote } from './scan/scan'
|
|
14
16
|
|
|
15
17
|
// Claude Code reads every state reference from the file that uses it, so each
|
|
16
18
|
// module declares its own atom for the pane's view.
|
|
@@ -57,6 +59,33 @@ export const register: Register = (on, options) => {
|
|
|
57
59
|
return { text: USAGE }
|
|
58
60
|
}).catch(() => ({ text: 'AI Install Guard: the command failed before it could answer.' }))
|
|
59
61
|
|
|
62
|
+
// Gate A: the person's own /plugin install and /plugin marketplace add. An
|
|
63
|
+
// install is answered in the command's place, so nothing installs, until
|
|
64
|
+
// the person approves that exact version.
|
|
65
|
+
on('command.run', { command: 'plugin' }, async ($, e, next) => {
|
|
66
|
+
const reading = readPluginArgs(e.args)
|
|
67
|
+
if (reading.kind === 'pass') {
|
|
68
|
+
if (reading.browse) $.ui.toast(BROWSE_NOTE)
|
|
69
|
+
return next(e)
|
|
70
|
+
}
|
|
71
|
+
const judged = await judge($, reading, { origin: 'command', args: e.args, label: reading.label }, lookups)
|
|
72
|
+
if (judged.kind === 'hold') return { text: judged.text }
|
|
73
|
+
if (judged.note !== undefined) $.ui.toast(judged.note)
|
|
74
|
+
return next(e)
|
|
75
|
+
}).catch(($, e, next) => (next.called ? next(e) : { text: 'AI Install Guard stopped this install: it failed before it could check it, so nothing was installed.' }))
|
|
76
|
+
|
|
77
|
+
// Gate B: Claude installing a plugin or adding a marketplace with the claude
|
|
78
|
+
// command. The command is refused, with a reason Claude reads, until the
|
|
79
|
+
// person approves that exact version in the pane.
|
|
80
|
+
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
|
|
81
|
+
const reading = readShellCommand(e.command)
|
|
82
|
+
if (reading.kind === 'pass') return next(e)
|
|
83
|
+
const judged = await judge($, reading, { origin: 'tool', args: e.command, label: reading.label }, lookups)
|
|
84
|
+
if (judged.kind === 'hold') return { deny: judged.text }
|
|
85
|
+
if (judged.note !== undefined) $.ui.toast(judged.note)
|
|
86
|
+
return next(e)
|
|
87
|
+
}).catch(($, e, next) => (next.called ? next(e) : { deny: 'AI Install Guard stopped this command: it may install a plugin or add a marketplace, and the guard failed before it could check it. Nothing was installed.' }))
|
|
88
|
+
|
|
60
89
|
on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawPane($, e))
|
|
61
90
|
}
|
|
62
91
|
|
|
@@ -68,58 +97,28 @@ export const register: Register = (on, options) => {
|
|
|
68
97
|
*/
|
|
69
98
|
async function scan($: EngineInterface, target: string, details: boolean, flags: { ref?: string; path?: string }, lookups: boolean): Promise<string> {
|
|
70
99
|
const host = modHost($)
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const report = spec === undefined ? await scanFolder(host, target, await expandHome(host, target), options) : await scanRemote(host, target, spec, options)
|
|
85
|
-
// A tree you already decided on shows that decision, not the buttons
|
|
86
|
-
// again, unless it now judges worse than when you approved it.
|
|
87
|
-
const earlier = report.inventory === undefined ? undefined : await decisionFor($, { treeHash: report.inventory.treeHash, sha: report.source?.sha }, report.verdict.kind).catch(() => undefined)
|
|
88
|
-
const view = earlier === undefined ? viewOf(report, 'approve') : { ...viewOf(report, 'approve'), decision: earlier.decision, decidedAt: earlier.decidedAt }
|
|
89
|
-
const text = formatReport(report, { details: true })
|
|
90
|
-
// The report text is stored before the pane shows the verdict, so Copy
|
|
91
|
-
// report always copies this scan's report. It is held well inside the
|
|
92
|
-
// store's limit; a failed write leaves Copy with nothing, not old text.
|
|
93
|
-
const stored = text.length > MAX_REPORT_CHARS ? `${text.slice(0, MAX_REPORT_CHARS)}\n… the report was cut here to fit the store` : text
|
|
94
|
-
await $.store.set(REPORT_TEXT, stored).catch(() => undefined)
|
|
95
|
-
await update($, viewAtom, () => view)
|
|
96
|
-
await saveLastView($, view).catch(() => undefined)
|
|
97
|
-
$.ui.toast(`AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}`)
|
|
98
|
-
// Where no pane shows, the answer is the whole report: findings and advice.
|
|
99
|
-
if (!opened.isPlaced || details) return text
|
|
100
|
-
return `AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}. The report is in the pane; /guard report reopens it.`
|
|
101
|
-
} catch (error) {
|
|
102
|
-
const reason = `Could not verify ${safeLine(target)}: ${safeLine(reasonOf(error))}`
|
|
103
|
-
await $.store.set(REPORT_TEXT, reason).catch(() => undefined)
|
|
104
|
-
// The pane, and the report /guard report reopens, show this failure: never
|
|
105
|
-
// a scanning pane, and never an older verdict in its place.
|
|
106
|
-
const failed = { ...scanningView(target, 'approve'), stage: '', verdict: { kind: 'unverified' as const, recommendation: 'Could not verify', reasons: [reason], counts: { pass: 0, warn: 0, fail: 0, info: 0 } } }
|
|
107
|
-
await update($, viewAtom, () => failed).catch(() => undefined)
|
|
108
|
-
await saveLastView($, failed).catch(() => undefined)
|
|
109
|
-
return reason
|
|
110
|
-
} finally {
|
|
111
|
-
$.ui.status(undefined)
|
|
112
|
-
}
|
|
100
|
+
const shown = await showScan(
|
|
101
|
+
$,
|
|
102
|
+
target,
|
|
103
|
+
async () => {
|
|
104
|
+
const spec = await remoteSpecFor(host, target, flags)
|
|
105
|
+
return spec === undefined ? { folder: await expandHome(host, target) } : { remote: spec }
|
|
106
|
+
},
|
|
107
|
+
{ action: 'approve', lookups },
|
|
108
|
+
)
|
|
109
|
+
if (shown.kind === 'failed') return shown.reason
|
|
110
|
+
// Where no pane shows, the answer is the whole report: findings and advice.
|
|
111
|
+
if (!shown.placed || details) return shown.text
|
|
112
|
+
return `AI Install Guard: ${shown.report.verdict.recommendation} for ${safeLine(target)}. The report is in the pane; /guard report reopens it.`
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
async function reopen($: EngineInterface): Promise<string> {
|
|
116
116
|
const view = await loadLastView($)
|
|
117
117
|
if (view === undefined) return 'AI Install Guard: no report yet. Run /guard scan <path>.'
|
|
118
|
-
await update($, viewAtom, () => ({ ...view, confirming: false }))
|
|
118
|
+
await update($, viewAtom, () => ({ ...view, confirming: false, deciding: false }))
|
|
119
119
|
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
120
120
|
if (opened.isPlaced) return `AI Install Guard: reopened the report for ${safeLine(view.target)}.`
|
|
121
|
-
|
|
122
|
-
return typeof text === 'string' ? text : 'AI Install Guard: the last report is no longer stored.'
|
|
121
|
+
return (await loadReport($, view.scanId)) ?? 'AI Install Guard: the last report is no longer stored.'
|
|
123
122
|
}
|
|
124
123
|
|
|
125
124
|
async function badge($: EngineInterface): Promise<string> {
|
|
@@ -195,19 +194,3 @@ async function remoteSpecFor(host: Host, target: string, flags: { ref?: string;
|
|
|
195
194
|
function unquote(text: string): string {
|
|
196
195
|
return /^(["']).*\1$/.test(text) ? text.slice(1, -1) : text
|
|
197
196
|
}
|
|
198
|
-
|
|
199
|
-
async function expandHome(host: Host, path: string): Promise<string> {
|
|
200
|
-
if (path !== '~' && !path.startsWith('~/')) {
|
|
201
|
-
return path
|
|
202
|
-
}
|
|
203
|
-
const { exitCode, stdout } = await host.run(['printenv', 'HOME'])
|
|
204
|
-
const home = stdout.trim()
|
|
205
|
-
if (exitCode !== 0 || home === '') {
|
|
206
|
-
throw new Error('could not find the home folder')
|
|
207
|
-
}
|
|
208
|
-
return home + path.slice(1)
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function reasonOf(error: unknown): string {
|
|
212
|
-
return error instanceof Error ? error.message : String(error)
|
|
213
|
-
}
|
package/hooks/scan/checks/mod.ts
CHANGED
|
@@ -250,6 +250,15 @@ function eventCapability(use: ModuleUse): Capability {
|
|
|
250
250
|
if (SENSITIVE_COMMANDS.has(command)) return { risk: 'high', plain: `intercepts /${command}` }
|
|
251
251
|
return { risk: 'low', plain: `answers /${command}` }
|
|
252
252
|
}
|
|
253
|
+
// A tool hook narrowed to one tool is described as that tool's, still high risk.
|
|
254
|
+
if (use.name === 'tool.call' || use.name === 'tool.check') {
|
|
255
|
+
const tool = /\btool\s*:\s*['"`]([\w.-]{1,64})['"`]/.exec(use.detail ?? '')?.[1]
|
|
256
|
+
if (tool !== undefined) {
|
|
257
|
+
return use.name === 'tool.call'
|
|
258
|
+
? { risk: 'high', plain: `sees every ${tool} call and can deny, rewrite or answer it` }
|
|
259
|
+
: { risk: 'high', plain: `decides whether ${tool} calls are allowed, so it can auto-approve them` }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
253
262
|
if (Object.hasOwn(EVENTS, use.name)) return EVENTS[use.name] as Capability
|
|
254
263
|
const namespace = use.name.split('.')[0] ?? ''
|
|
255
264
|
return Object.hasOwn(NAMESPACES, namespace) ? (NAMESPACES[namespace] as Capability) : { risk: 'low', plain: 'an event this scanner does not know' }
|
package/hooks/scan/remote.ts
CHANGED
|
@@ -216,9 +216,7 @@ export function resolveRef(listed: RemoteRefs, ref: string | undefined): Pick<Pi
|
|
|
216
216
|
*/
|
|
217
217
|
export async function fetchPinned(host: Host, spec: RemoteSpec, onStage?: (stage: string) => Promise<void> | void): Promise<FetchedSource> {
|
|
218
218
|
await onStage?.(`resolving ${safeLine(spec.url)}`)
|
|
219
|
-
const
|
|
220
|
-
if (listing.exitCode !== 0) throw failure(listing, `could not reach ${spec.url}`)
|
|
221
|
-
const listed = parseLsRemote(listing.stdout)
|
|
219
|
+
const listed = await listRefs(host, spec.url)
|
|
222
220
|
const fromLink = spec.treePath !== undefined && spec.ref === undefined ? splitTreePath(listed, spec.treePath) : undefined
|
|
223
221
|
const subdir = spec.subdir ?? fromLink?.subdir
|
|
224
222
|
const pinned = resolveRef(listed, spec.ref ?? fromLink?.ref)
|
|
@@ -280,6 +278,29 @@ export async function fetchPinned(host: Host, spec: RemoteSpec, onStage?: (stage
|
|
|
280
278
|
}
|
|
281
279
|
}
|
|
282
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The commit `spec` names now, and the folder in it, without fetching
|
|
283
|
+
* anything: what a later fetch of the same spec would pin, so a decision made
|
|
284
|
+
* on that commit can be found before a scan.
|
|
285
|
+
*/
|
|
286
|
+
export async function resolvePin(host: Host, spec: RemoteSpec): Promise<{ sha: string; subdir?: string }> {
|
|
287
|
+
// A full commit id needs no listing: it already is the pin.
|
|
288
|
+
if (spec.ref !== undefined && spec.treePath === undefined && FULL_SHA.test(spec.ref.toLowerCase())) {
|
|
289
|
+
return { sha: spec.ref.toLowerCase(), ...(spec.subdir === undefined ? {} : { subdir: spec.subdir }) }
|
|
290
|
+
}
|
|
291
|
+
const listed = await listRefs(host, spec.url)
|
|
292
|
+
const fromLink = spec.treePath !== undefined && spec.ref === undefined ? splitTreePath(listed, spec.treePath) : undefined
|
|
293
|
+
const subdir = spec.subdir ?? fromLink?.subdir
|
|
294
|
+
return { sha: resolveRef(listed, spec.ref ?? fromLink?.ref).sha, ...(subdir === undefined ? {} : { subdir }) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The repository's branches and tags, as `git ls-remote` lists them. */
|
|
298
|
+
async function listRefs(host: Host, url: string): Promise<RemoteRefs> {
|
|
299
|
+
const listing = await git(host, ['ls-remote', '--symref', url, 'HEAD', 'refs/heads/*', 'refs/tags/*'], 60_000)
|
|
300
|
+
if (listing.exitCode !== 0) throw failure(listing, `could not reach ${url}`)
|
|
301
|
+
return parseLsRemote(listing.stdout)
|
|
302
|
+
}
|
|
303
|
+
|
|
283
304
|
function git(host: Host, args: readonly string[], timeoutMs = 60_000, env: Record<string, string> = GIT_ENV): Promise<RunResult> {
|
|
284
305
|
return host.run(['git', ...args], { timeoutMs, env })
|
|
285
306
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-install-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A pre-install security gate for Claude Code: scans plugins, mods, skills and MCP servers without running them, and installs nothing until you approve.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build:fixtures": "node scripts/build-fixtures.js",
|